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:
350
docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md
Normal file
350
docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md
Normal 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:
|
||||
- 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 `<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.
|
||||
Reference in New Issue
Block a user