# Owner Approval Flow — Design Spec **Date**: 2026-05-27 **Author**: Gurpreet (with Claude) **Status**: Approved — ready for implementation plan **Touches**: `fusion_helpdesk` (client / entech), `fusion_helpdesk_central` (nexa) ## Problem Some in-app feature requests and bug reports require sign-off from a real decision-maker at the client (the "owner" — the person paying the bill, not just an Odoo Manager-by-permission). Today this happens out-of-band via WhatsApp or phone, leaving no record on the ticket and forcing Gurpreet to remember who said what to whom. We need a structured way to loop the client's owner in on tickets that need approval, on-demand from the central support side, with a low-friction approve/reject flow for the owner and a transcript of the decision living on the ticket itself. ## Goals - Central support (Gurpreet on nexa) decides *which* tickets need approval — never automatic. - Owner approves or rejects with **one click** from their email, no login required. - The approval decision is **publicly visible** on the ticket (per existing chatter / inbox plumbing) — both the originating employee and central support see who approved or rejected and any optional comment. - Owner contact lives in **entech settings** (source of truth) and stays automatically fresh on nexa via piggyback on every ticket submission. - An **AI summary** of the ticket goes in the approval email so the owner can decide in 30 seconds without reading the whole thread. - **Single-shot reminder** if no response in N days. - **Bulk engagement** when multiple requests need the same owner's sign-off in one batch. - **Reporting dashboard** so Gurpreet can spot stuck approvals at a glance. ## Non-goals - Manager-tier approvals (rejected during brainstorming — "manager" by Odoo permission ≠ business-authority owner; only owner needed). - SLAs / hard deadlines on owner response. - Multi-step approval chains (one owner, one decision). - Owner-facing mobile app or portal beyond the approve / reject confirmation page — email + magic link is the entire UX. - Auto-progressing the ticket stage on approval — Gurpreet still manually completes the work. ## Architecture ### Module split | Module | Role | Touches | |---|---|---| | `fusion_helpdesk` (entech, client) | Lets the client configure their owner contact; sends contacts upstream on every ticket | 2 ICP settings, settings view, `/fusion_helpdesk/submit` payload | | `fusion_helpdesk_central` (nexa) | Owns the engagement flow end-to-end: storage, wizard, email, public portal, reminder cron, dashboard | New wizard model, ticket fields, mail template, public controllers, OpenAI client, reporting views | ### Data model #### Entech (`fusion_helpdesk`) Two new `ir.config_parameter` keys exposed in **Settings → Fusion Helpdesk → Owner Approval**: - `fusion_helpdesk.owner_email` — Char - `fusion_helpdesk.owner_name` — Char `controllers/main.py::submit` piggybacks both keys on every ticket payload (alongside the existing identity keys). Both are optional — leaving them blank disables the Engage button on central for that client. #### Central (`fusion_helpdesk_central`) Extend existing `fusion.helpdesk.client.key` (one row per client deployment): | Field | Type | Purpose | |---|---|---| | `owner_email` | Char | Current owner contact for this client. Upserted on every incoming ticket from the submit payload. | | `owner_name` | Char | Display name for greeting / chatter attribution. | Extend `helpdesk.ticket`: | Field | Type | Purpose | |---|---|---| | `x_fc_engagement_state` | Selection (`none`/`pending`/`approved`/`rejected`) | Drives kanban badge + state pill on form. Default `none`. | | `x_fc_engagement_email` | Char | Snapshot of owner email reached for *this* engagement. Survives later edits to `client_key.owner_email`. | | `x_fc_engagement_name` | Char | Snapshot of owner name. | | `x_fc_engagement_token` | Char (UUID4) | Single-use token in the magic link. Cleared on confirm. | | `x_fc_engagement_sent_at` | Datetime | When the engagement email was first queued. | | `x_fc_engagement_reminded_at` | Datetime, nullable | When the single reminder went out. Set by cron. | | `x_fc_engagement_decided_at` | Datetime, nullable | When state transitioned to `approved`/`rejected`. Drives turnaround metric. | | `x_fc_ai_summary` | Text | The brief used in the email; editable in the wizard before send; read-only after. | | `x_fc_engagement_turnaround_hours` | Float, `store=True`, computed | `(decided_at - sent_at) / 3600`. Lets the pivot view aggregate. | New transient model `fusion.helpdesk.engagement.wizard` — see Engagement Wizard below. New `ir.config_parameter` keys (Helpdesk → Configuration): - `fusion_helpdesk_central.openai_api_key` — Char, system-only readable - `fusion_helpdesk_central.openai_model` — Char, default `gpt-4o-mini` - `fusion_helpdesk_central.engagement_reminder_days` — Integer, default `3`; `0` disables reminders ## Engagement flow (single ticket) 1. Support opens the ticket → clicks **`Request Owner Approval`** (header button; only rendered when `x_fc_client_label` is set and `client_key.owner_email` is configured). 2. Wizard `fusion.helpdesk.engagement.wizard` opens: - **AI Summary** textarea — auto-populated on `default_get` via one OpenAI call against `{ticket.name + html2plaintext(ticket.description) + each public chatter message}`. Editable. - **Personal note** textarea — Gurpreet's own one-liner that prepends the email body. - Read-only display of `owner_email` / `owner_name` resolved from `client_key`. - **[Send]** button. 3. On send: - `token = uuid4().hex` - Ticket fields written: `engagement_state='pending'`, `engagement_email`, `engagement_name`, `engagement_token`, `engagement_sent_at=now`, `ai_summary` - Mail template `mail_template_engagement` rendered → queued (`mail.mail`, `auto_delete=True`) - Wizard closes 4. Owner receives email → reads → clicks **`Approve`** or **`Reject`** (two big buttons, each a `https://erp.nexasystems.ca/fusion_helpdesk/engagement//` URL). 5. Public controller resolves the token → renders a small standalone QWeb page (not the heavy portal layout): - Header strip with Nexa Systems branding - Ticket title + one-line AI summary - Optional comment textarea - **[Confirm Approval]** / **[Confirm Rejection]** button - If token invalid / used / wrong state → friendly "This link has already been used or is no longer valid" page 6. On confirm: - Resolve owner partner: find-or-create `res.partner` by email (reusing the existing `_resolve_author`-style pattern from customer replies) - Post chatter message on ticket, attributed to that partner, subtype `mail.mt_comment` (public): ``` ✓ Approved by {{ owner_name }} {{ comment }} ← only if comment provided ``` - Write `engagement_state='approved'|'rejected'`, `engagement_token=False`, `engagement_decided_at=now` - The chatter message propagates to the employee's My Tickets thread via the existing `_public_messages` filter, satisfying the "Fully visible" UX choice. - Gurpreet receives the standard Odoo follower notification. 7. Support sees the state pill flip from amber `⏳ Awaiting approval from Kris` to green `✓ Approved by Kris`, then progresses the ticket as normal. ### Re-engagement If Gurpreet clicks **`Request Owner Approval`** on a ticket that's already `pending` / `approved` / `rejected`, the wizard opens normally; on send it overwrites the token, snapshot fields, summary, `sent_at`, and clears `reminded_at` and `decided_at`. State resets to `pending`. Old chatter messages from prior engagements stay as audit history. Old tokens are immediately dead (the token field has changed). ### Token security UUID4 is 122 bits of entropy — sufficient against guessing. Tokens are single-use (cleared on confirm). No date-based expiry in v1 — keep it simple; if abuse appears, add a 14-day `engagement_sent_at` cutoff in the controller. ## AI summary (OpenAI integration) - Model: `gpt-4o-mini` (configurable via ICP). ~$0.15/1M input tokens; one call per Engage click. ~$0.01/month at 10 engagements/week. - Transport: `urllib.request` against `https://api.openai.com/v1/chat/completions` — no new pip dependency. - Timeout: 15 seconds. On failure → summary field renders empty + soft banner "AI summary unavailable — write a quick brief manually." Wizard remains usable. - HTML stripping: `odoo.tools.mail.html2plaintext()` (built-in). - Token cap: assembled prompt truncated to 8000 characters (well below context window, bounds cost on tickets with 50+ messages). - Prompt is a Python constant (`fusion_helpdesk_central/utils.py::SUMMARY_PROMPT`) so it's editable in one place without UI churn. See Engagement Wizard for prompt text. - **Privacy**: ticket description + chatter goes to OpenAI. Document in client onboarding. Empty API key disables the auto-fill but keeps the wizard working with a manual summary. ## Engagement Wizard (`fusion.helpdesk.engagement.wizard`) `models.TransientModel` with: - `ticket_id` Many2one (or `ticket_ids` for bulk — see below) - `personal_note` Char - `ai_summary` Text - `owner_email_display` Char (computed, readonly) - `owner_name_display` Char (computed, readonly) - `is_reminder` Boolean (set by cron, not by user) `default_get` triggers `_compute_ai_summary()` which: 1. Reads ticket name, description (`html2plaintext`), and public messages 2. Builds the prompt from `SUMMARY_PROMPT` template 3. Truncates to 8000 chars 4. POSTs to OpenAI, parses response, sets `ai_summary` 5. Catches all exceptions → logs warning, sets `ai_summary=''` `action_send` performs all writes + queues mail and returns `{'type': 'ir.actions.act_window_close'}`. ### Summary prompt (frozen Python constant) ``` You are summarising a customer support ticket for a busy executive who needs to decide whether to approve the work. Output rules: - 4–6 short bullet points, plain text (no markdown). - First bullet: the ask, in one sentence. - Second bullet: the business impact if approved. - Third bullet: the business impact if NOT approved (or "none material"). - Optional bullets: cost / effort signals if any are mentioned. - Final bullet: open questions the approver should think about. - Do not invent facts. If the thread doesn't say, write "not stated". - No greetings, no sign-offs, no preamble. Ticket title: {name} Original report: {description_plain} Replies so far: {messages_plain} ``` ## Email + magic links `mail.template` shipped in `fusion_helpdesk_central/data/mail_template_engagement.xml`. - **From**: outgoing mail server default - **Reply-To**: Gurpreet's email (`gs@nexasystems.ca`) — replies don't fall into the bot inbox - **To**: `x_fc_engagement_email` - **Subject**: `Action needed: please review request "{{ ticket.name }}"` - **Reminder subject** (when wizard's `is_reminder=True`, set by cron): `Reminder: still waiting on your approval — "{{ ticket.name }}"` - **Body**: branded HTML matching the existing ack template style; greeting uses `engagement_name`; includes personal note, summary, full description + chatter in a `
` collapsible, two big approve/reject buttons. ### Public approval portal Routes (both `auth='public'`, `csrf=False`): - `GET /fusion_helpdesk/engagement//` — renders the confirmation page (or "no longer valid" page if token / state invalid). `decision` is validated against `('approve', 'reject')`. - `POST /fusion_helpdesk/engagement//` — accepts optional `comment` form field, performs the state transition + chatter post, renders a "Thanks — your decision is recorded" page. Token resolution helper `_resolve_engagement(token, decision)` returns the ticket or raises a friendly error if anything's off. Used by both GET and POST. ## Bulk engagement Server action on `helpdesk.ticket` list view: **`Request Owner Approval (bulk)`**. ### Validation (hard errors) - All selected tickets share the same `x_fc_client_label` — otherwise: "Cannot bulk-engage tickets across different deployments." - All selected tickets have `engagement_state in ('none', 'rejected')` — otherwise: "{n} of the selected tickets already have a pending or approved engagement. Engage them individually." - `client_key.owner_email` is configured for the deployment — otherwise the standard tooltip error. ### Wizard Same `fusion.helpdesk.engagement.wizard` model gains a `ticket_ids` Many2many to `helpdesk.ticket` (single-ticket mode keeps using `ticket_id`; the wizard checks which is set and branches). Per-ticket AI summaries generated **in parallel** via `concurrent.futures.ThreadPoolExecutor(max_workers=5)` with a 30-second overall timeout. Each per-ticket summary is editable in its own row in the wizard view via a child transient model `fusion.helpdesk.engagement.wizard.line` (fields: `wizard_id`, `ticket_id`, `ai_summary`). ### Email A single combined email with one card per ticket. Each card has its own `[Approve][Reject]` buttons, each pointing at that ticket's unique token. Owner can decide per-ticket, ignore some, come back to the same email later (links stay live until clicked or re-engaged). ### Layout (rendered HTML) ``` Hi Kris, 5 requests from ENTECH need your sign-off. Each can be approved or rejected independently — clicking a button on one card only acts on that card. ──── Request 1 of 5 ────────────────────────────── "Drag and drop steps" • [✓ Approve] [✗ Reject] ──── Request 2 of 5 ────────────────────────────── ... ``` ## Reminder cron `ir.cron`, daily at 09:00, sudo: ```python N = int(ICP.get_param('fusion_helpdesk_central.engagement_reminder_days') or 3) if N <= 0: return # disabled cutoff = fields.Datetime.now() - timedelta(days=N) to_remind = self.env['helpdesk.ticket'].search([ ('x_fc_engagement_state', '=', 'pending'), ('x_fc_engagement_sent_at', '<=', cutoff), ('x_fc_engagement_reminded_at', '=', False), ]) for ticket in to_remind: template.with_context(is_reminder=True).send_mail( ticket.id, force_send=False) ticket.x_fc_engagement_reminded_at = fields.Datetime.now() ``` **Single-shot by design** — no second reminder. If still no response after one nudge, the right action is human (call the owner), not another email. Same token, same magic links — the owner can click either the original or the reminder email. ## Reporting dashboard Menu: **Helpdesk → Reporting → Owner Engagements** (new entry, after Tickets Analysis). Action opens four views over `helpdesk.ticket` filtered by `('x_fc_engagement_state', '!=', 'none')`: 1. **Pivot** (default): rows = `x_fc_client_label`, columns = `x_fc_engagement_state`, measures = count + avg `x_fc_engagement_turnaround_hours` 2. **Graph (bar)**: engagement count over time grouped by `x_fc_client_label` 3. **List**: ticket_ref, client, owner name/email, state, sent_at, reminded_at, decided_at, turnaround_hours 4. **Kanban (default group by state)**: at-a-glance count per state Filters: by client, by state, by date range. Canned filter "Pending > 7 days" highlights stuck approvals. No new model; everything is derived from `helpdesk.ticket`. The stored computed field `x_fc_engagement_turnaround_hours` makes the pivot fast on large datasets. ## UI changes ### Helpdesk ticket form (nexa) - New header button **`Request Owner Approval`** (visible iff `x_fc_client_label` set AND `client_key.owner_email` set; tooltip on disabled state explains why) - State pill right of the title: - `none` → no pill - `pending` → amber `⏳ Awaiting approval from {{ engagement_name }}` - `approved` → green `✓ Approved by {{ engagement_name }}` - `rejected` → red `✗ Rejected by {{ engagement_name }}` - New collapsible group **`Owner Engagement`** showing `ai_summary` (read-only after send), `engagement_email`, `engagement_name`, `engagement_sent_at`, `engagement_reminded_at`, `engagement_decided_at`, `engagement_turnaround_hours` ### Helpdesk ticket kanban (nexa) Amber corner dot when `engagement_state == 'pending'` — surfaces blockers in the kanban view without opening each card. ### Entech settings UI New section **Owner Approval** under existing Fusion Helpdesk group: - `Owner email` text input - `Owner name` text input - Help text: "Used when Nexa Systems support requests approval for a feature or bug fix that needs sign-off. Leave blank if your deployment doesn't require approvals." ## Edge cases | Case | Behaviour | |---|---| | Owner contact not configured on entech | `Request Owner Approval` button disabled, tooltip: "Owner contact not configured for this client. Ask them to fill it in under Settings → Fusion Helpdesk." | | Token reused after first click | Friendly "This approval link has already been used or is no longer valid" page with a `mailto:support@nexasystems.ca` link. | | Owner gets re-engaged | New token replaces old; old immediately invalid. State resets to `pending`. Old chatter is preserved. `reminded_at` / `decided_at` cleared. | | OpenAI down / no API key | Wizard opens with empty summary + soft banner; you type your own brief, send normally. | | Owner replies to the email instead of clicking | Mail gateway treats it as a regular comment (existing flow). State stays `pending` until they click a magic link. | | Employee files a follow-up while owner is deciding | Reply lands in chatter normally; owner sees it next time they reload, but their engagement is tied to the snapshot AI summary (intentional — owner judges a stable artifact). | | Bulk action selects tickets across clients | Hard error before wizard opens. | | Bulk action selects tickets that already have pending engagements | Hard error specifying the count of disallowed tickets. | | Approved ticket needs to be "reversed" | No undo button. Re-engage with a fresh wizard → new summary → re-send. Audit chain stays in chatter. | ## Tests Pure helpers in `fusion_helpdesk_central/utils.py` (new file): - `build_summary_prompt(ticket_dict, messages)` → str - `truncate_for_openai(prompt, max_chars=8000)` → str - `format_engagement_chatter(decision, owner_name, comment)` → Markup `fusion_helpdesk_central/tests/test_utils.py`: - Prompt structure (correct ordering, all fields present, empty-thread fallback) - Truncation (preserves the prefix and ticket title) - Chatter formatting (approve / reject / with-comment / without-comment) `fusion_helpdesk_central/tests/test_engagement.py`: - Token generation is unique per call - Wizard `action_send` writes all expected fields, queues mail, returns close action - Re-engagement clears the old token + decided_at + reminded_at, resets state to `pending` - Public controller rejects invalid / used / wrong-decision tokens with friendly error - Public controller `POST` confirms decision, posts chatter, writes state - State transitions are correctly one-way (approved → approved is no-op, approved → re-engaged → pending works) - Bulk wizard rejects mixed-client selection - Bulk wizard rejects already-pending tickets in selection - Reminder cron only acts on rows past cutoff and not already reminded - Computed `turnaround_hours` matches expected delta after decision OpenAI is mocked in tests — no live API calls in CI. ## Versions - `fusion_helpdesk` → bump to `19.0.2.0.0` (minor feature, new settings) - `fusion_helpdesk_central` → bump to `19.0.2.0.0` (major feature, multiple new fields + wizard + controllers + cron + reporting) ## Deployment order 1. Deploy `fusion_helpdesk_central` first (it owns the storage, the wizard, the email template, the public routes, the cron, the reporting). It can sit dormant — no Engage button is reachable until `client_key.owner_email` is populated. 2. Deploy `fusion_helpdesk` second (adds the entech settings + payload piggyback). First ticket filed after this deploy populates `client_key.owner_email` on central. 3. Backfill: for any client that already has owner contact info known to Gurpreet (e.g., entech → kris@enplating.ca), edit the `client_key` row directly on nexa via the existing config UI. Or simply wait — the next ticket from that client will populate it.