Files
Odoo-Modules/docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md
gsinghpal 4acf9d7f85 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.
2026-05-27 12:37:57 -04:00

351 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.