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.
20 KiB
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— Charfusion_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 readablefusion_helpdesk_central.openai_model— Char, defaultgpt-4o-minifusion_helpdesk_central.engagement_reminder_days— Integer, default3;0disables reminders
Engagement flow (single ticket)
- Support opens the ticket → clicks
Request Owner Approval(header button; only rendered whenx_fc_client_labelis set andclient_key.owner_emailis configured). - Wizard
fusion.helpdesk.engagement.wizardopens:- AI Summary textarea — auto-populated on
default_getvia 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_nameresolved fromclient_key. - [Send] button.
- AI Summary textarea — auto-populated on
- 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_engagementrendered → queued (mail.mail,auto_delete=True) - Wizard closes
- Owner receives email → reads → clicks
ApproveorReject(two big buttons, each ahttps://erp.nexasystems.ca/fusion_helpdesk/engagement/<token>/<decision>URL). - 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
- On confirm:
- Resolve owner partner: find-or-create
res.partnerby 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_messagesfilter, satisfying the "Fully visible" UX choice. - Gurpreet receives the standard Odoo follower notification.
- Resolve owner partner: find-or-create
- Support sees the state pill flip from amber
⏳ Awaiting approval from Kristo 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.requestagainsthttps://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_idMany2one (orticket_idsfor bulk — see below)personal_noteCharai_summaryTextowner_email_displayChar (computed, readonly)owner_name_displayChar (computed, readonly)is_reminderBoolean (set by cron, not by user)
default_get triggers _compute_ai_summary() which:
- Reads ticket name, description (
html2plaintext), and public messages - Builds the prompt from
SUMMARY_PROMPTtemplate - Truncates to 8000 chars
- POSTs to OpenAI, parses response, sets
ai_summary - 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).decisionis validated against('approve', 'reject').POST /fusion_helpdesk/engagement/<token>/<string:decision>— accepts optionalcommentform 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_emailis 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).
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:
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'):
- Pivot (default): rows =
x_fc_client_label, columns =x_fc_engagement_state, measures = count + avgx_fc_engagement_turnaround_hours - Graph (bar): engagement count over time grouped by
x_fc_client_label - List: ticket_ref, client, owner name/email, state, sent_at, reminded_at, decided_at, turnaround_hours
- 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 iffx_fc_client_labelset ANDclient_key.owner_emailset; tooltip on disabled state explains why) - State pill right of the title:
none→ no pillpending→ amber⏳ Awaiting approval from {{ engagement_name }}approved→ green✓ Approved by {{ engagement_name }}rejected→ red✗ Rejected by {{ engagement_name }}
- New collapsible group
Owner Engagementshowingai_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 emailtext inputOwner nametext 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)→ strtruncate_for_openai(prompt, max_chars=8000)→ strformat_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_sendwrites 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
POSTconfirms 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_hoursmatches expected delta after decision
OpenAI is mocked in tests — no live API calls in CI.
Versions
fusion_helpdesk→ bump to19.0.2.0.0(minor feature, new settings)fusion_helpdesk_central→ bump to19.0.2.0.0(major feature, multiple new fields + wizard + controllers + cron + reporting)
Deployment order
- Deploy
fusion_helpdesk_centralfirst (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 untilclient_key.owner_emailis populated. - Deploy
fusion_helpdesksecond (adds the entech settings + payload piggyback). First ticket filed after this deploy populatesclient_key.owner_emailon central. - Backfill: for any client that already has owner contact info known to Gurpreet (e.g., entech → kris@enplating.ca), edit the
client_keyrow directly on nexa via the existing config UI. Or simply wait — the next ticket from that client will populate it.