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

20 KiB
Raw Blame History

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}

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:

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.