diff --git a/docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md b/docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md new file mode 100644 index 00000000..e6a9a279 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md @@ -0,0 +1,350 @@ +# 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.