docs(spec): owner approval flow design

End-to-end spec for the owner-approval feature on fusion_helpdesk +
fusion_helpdesk_central. Captures data model, engagement flow (single +
bulk), magic-link approval portal, OpenAI summary, reminder cron,
reporting dashboard, edge cases, and test plan. Ready for the
writing-plans skill to turn into an implementation plan.
This commit is contained in:
gsinghpal
2026-05-27 12:37:57 -04:00
parent e596723ba5
commit 4acf9d7f85

View File

@@ -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/<token>/<decision>` 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 }}
<i>{{ comment }}</i> ← 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:
- 46 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 `<details>` collapsible, two big approve/reject buttons.
### Public approval portal
Routes (both `auth='public'`, `csrf=False`):
- `GET /fusion_helpdesk/engagement/<token>/<string:decision>` — renders the confirmation page (or "no longer valid" page if token / state invalid). `decision` is validated against `('approve', 'reject')`.
- `POST /fusion_helpdesk/engagement/<token>/<string:decision>` — 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"
• <summary bullets>
[✓ 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.