Files
Odoo-Modules/docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md
gsinghpal 6c15a7b1cf feat(fusion_helpdesk): customer follow-up + embedded ticket inbox
Squash-merge of feat/helpdesk-customer-followup. The billing and
fusion_login_audit work from that branch is already on main (landed
separately); this lands only the helpdesk feature.

- Identity keystone: submit() forwards partner_email/partner_name/
  x_fc_client_label so the central Helpdesk find-or-creates the customer
  partner and subscribes them as a follower (enables reply emails + magic link).
- Embedded in-app 'My Tickets' inbox: server-side scoped read/reply RPC
  endpoints, per-user seen tracking (fusion.helpdesk.ticket.seen), systray
  unread badge. Defense-in-depth scope domain + _norm_email normalisation
  (wildcard emails cannot widen scope).
- fusion_helpdesk_central: x_fc_client_label field + list/search views +
  branded acknowledgement email template.
- Deployed and smoke-tested live: nexa central 19.0.1.1.0, entech client
  19.0.1.4.1 (requires Contact Creation on the central service account).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:23:33 -04:00

18 KiB
Raw Blame History

Fusion Helpdesk — Customer Follow-up & Embedded Ticket Inbox

  • Date: 2026-05-27
  • Status: Approved design (ready for implementation plan)
  • Branch: feat/helpdesk-customer-followup
  • Modules touched: fusion_helpdesk (client deployments), fusion_helpdesk_central (central Odoo)
  • Target system: odoo-nexa / erp.nexasystems.ca, DB nexamain, Odoo 19 Enterprise

1. Summary

Today, end users at client deployments (ENTECH, MOBILITY, …) file helpdesk tickets through an in-app "Report a Bug / Request a Feature" systray dialog. Those tickets land on the central Odoo Helpdesk but carry no customer identity, so:

  • support replies email nobody,
  • the submitter can't see or follow up on their ticket,
  • the ticket never appears in any customer portal.

This design makes ticket follow-up work end to end. It rests on one keystone fix (attach the submitter's identity to every ticket) and then exposes two follow-up surfaces matched to two audiences:

  1. In-app embedded inbox — the systray dialog becomes a small ticket inbox (New + My Tickets). Client staff read replies and follow up without leaving their own Odoo or logging into the central system.
  2. Native Enterprise portal — for external web/email customers, the existing Odoo portal + magic-link
    • free sign-up does the job; they have no workspace to embed into.

Scope tier: Polished (light branding + acknowledgement email + in-app unread badge). Not a custom portal theme.


2. Problem & Diagnosis (grounded in the live system)

2.1 Current architecture

  • fusion_helpdesk (installed on client deployments): OWL systray dialog → POST /fusion_helpdesk/submit → forwards to central over XML-RPC as a shared bot account (API key issued by fusion_helpdesk_central). Ticket payload today is only {name, description, team_id}. The reporter's name/login is embedded as HTML text inside the description's "Diagnostic context" table — not as structured fields.
  • fusion_helpdesk_central (installed on central Odoo): manages the per-client API keys on the shared bot user. Does not touch tickets, portal, notifications.

2.2 The actual bug (verified on nexamain, 2026-05-27)

All 51/51 tickets have partner_id, partner_email, partner_name = NULL (0 coverage). With no customer attached, Odoo has nobody to email, nobody to add as follower, no /my/tickets to populate, and no recipient for a magic link.

2.3 The platform already does the hard part

Installed & enabled on odoo-nexa:

  • Modules: helpdesk 19.0.1.6, website_helpdesk, website_helpdesk_knowledge, helpdesk_account, helpdesk_sale, portal, website, auth_signup.
  • auth_signup.invitation_scope = b2c (free customer sign-up ON), auth_signup.reset_password = True.
  • web.base.url = https://erp.nexasystems.ca, mail.catchall.domain = nexasystems.ca, 4 working SMTP servers → outbound email works.
  • Team 1 "Customer Care" is already portal-ready: privacy_visibility = portal, use_website_helpdesk_form = true, allow_portal_ticket_closing = true, use_alias = true, alias support (→ support@nexasystems.ca).

helpdesk.ticket model (Enterprise source, verified):

  • _inherit = ['portal.mixin', 'mail.thread.cc', 'rating.mixin']; _mail_thread_customer = True; _primary_email = 'partner_email'; access_url = '/my/ticket/<id>' (← that is the magic link).
  • create() auto-resolves the partner: when partner_email is given and partner_id is not, it calls mail.thread._partner_find_from_emails_single([partner_email], {name, company_id}) to find-or-create the partner and set partner_id (helpdesk_ticket.py ≈ L564572).
  • create() subscribes the customer as a follower (the "make customer follower" loop, ≈ L600620), so they receive reply notifications by email.
  • Portal routes: /my/tickets (auth=user); /my/ticket/<int:ticket_id>/<access_token> (auth=public) → validates token via _document_check_access → renders helpdesk.tickets_followup (reply composer included); /my/ticket/close/<id>/<token> posts a message with author_id = partner_id; public web form at /helpdesk/<team>.

Consequence: the keystone fix is small — pass partner_email + partner_name in the create payload and native helpdesk creates the partner, links it, and subscribes it. Replies then email the customer with a magic-link "View Ticket" button automatically.


3. Goals / Non-Goals

Goals

  • Every new ticket carries the submitter's real identity (partner_email, partner_name, x_fc_client_label).
  • Agent replies reach the customer by email with a working magic link.
  • In-app staff can list, read, and reply to their tickets inside their own Odoo — no login, no context switch.
  • External web/email customers get the native portal + magic link + free sign-up.
  • Light branding (logo/colours) + an acknowledgement email on ticket creation.
  • Hybrid in-app visibility: regular users see their own tickets; a designated admin sees all of their deployment's tickets.

Non-Goals

  • No custom portal theme, custom website submission form, KB-deflection, or SLA timeline UI (that was Tier C — deliberately out of scope).
  • No replication of tickets into the client database — the in-app inbox is a live RPC view.
  • No backfill of the 51 existing identity-less tickets (low value; their only identity is free text).
  • No changes to the billing module (fusion_centralize_billing) — separate work.

4. Audiences & channels (locked decisions)

Decision Choice
Channels Both — in-app reporter and external web/email
In-app visibility Hybrid — own by default; designated admin sees all of their deployment's tickets
Scope tier Polished — light branding + ack email + in-app unread badge
Acknowledgement email on create Yes (immediate magic link)
Reporter email at submit Confirmed / editable in the New form
"See all" gating New group on the client deployment

5. Architecture

5.1 Keystone — identity layer

  • Client side (fusion_helpdesk): in submit(), add to the create payload:
    • partner_name = request.env.user.name
    • partner_email = confirmed value from the form (default request.env.user.email or .login, editable)
    • x_fc_client_label = cfg['client_label']
  • Central side (fusion_helpdesk_central): add x_fc_client_label (Char, indexed) to helpdesk.ticket and surface it in the agent backend (list column + search filter) so support can filter by client. Native helpdesk does the partner resolution + follower subscription.

x_fc_client_label is the structured tag that makes deployment-scoped queries (and the admin "see all" view) reliable — far better than parsing the [ENTECH] subject prefix.

5.2 Two surfaces

  • Surface A — in-app embedded inbox (fusion_helpdesk, client deployments). New work.
  • Surface B — native Enterprise portal (fusion_helpdesk_central config + light branding). Mostly configuration; near-zero new code.

5.3 Module responsibilities

fusion_helpdesk (client) — majority of new work

  • Controller (controllers/main.py): keystone payload change + new endpoints (§6.1).
  • OWL dialog (static/src/js/…, static/src/xml/…): New + My Tickets tabs; thread view; reply box.
  • Systray (fusion_helpdesk_systray.js): unread badge.
  • res.groups: group_reporter_admin ("Helpdesk Reporter Admin").
  • Model fusion.helpdesk.ticket.seen: per-user read tracking for the badge.
  • res.config.settings: (existing) — no new config required beyond what exists.

fusion_helpdesk_central (central) — small additions

  • helpdesk.ticket inherit: x_fc_client_label field + backend list/search exposure.
  • mail.template: branded acknowledgement on ticket create (with the magic-link CTA).
  • Data/doc: confirm the "Customer Care" team portal config (already correct on live — assert via comment or light data, don't fight existing config).

6. Surface A — In-app embedded inbox (detail)

6.1 Controller endpoints

All type='jsonrpc', auth='user'. Identity is always derived server-side from request.env.user — never from request parameters. All remote calls go through the existing bot XML-RPC layer.

Route Returns Notes
POST /fusion_helpdesk/submit (modified) {ok, ticket_id, ticket_url} Adds x_fc_client_label + partner_name; the confirmed form email is sent as partner_email (param may be named reply_email, but it maps straight to partner_email).
/fusion_helpdesk/my_tickets [{id, ref, subject, stage, last_update, has_unread}] Scoped (§8). Reuses one remote search_read.
/fusion_helpdesk/ticket/<int:ticket_id> {id, subject, stage, messages:[…], can_reply} Public comments only — internal notes excluded (§8). Re-checks scope.
/fusion_helpdesk/ticket/<int:ticket_id>/reply {ok} Re-checks scope; posts message_post with author_id = replier's partner.
/fusion_helpdesk/unread_count {count} For the systray badge (§7).

6.2 Dialog UX

  • The existing dialog gains two tabs:
    • New — today's form, plus a confirmed/editable "Your email" field (prefilled from the logged-in user; used as reply_email).
    • My Tickets — list of the user's tickets (ref, subject, stage chip, last-update, unread dot). Admins (in group_reporter_admin) see a "Mine / All [LABEL]" toggle.
  • Clicking a ticket opens a thread view: customer-visible messages (author, timestamp, body, attachments) + a reply box (text + attach) + a "Done"/back control. Opening a ticket marks it seen.

6.3 Reply attribution

  • Replies post to central as message_type='comment', subtype_xmlid='mail.mt_comment', with author_id = the replying user's partner on central (resolved find-or-create by their email). For a user replying to their own ticket that's the ticket's customer; for an admin replying to a colleague's ticket it's the admin's own identity (correct attribution).
  • A customer reply notifies the assigned agent + followers (native), closing the two-way loop.

6.4 Read tracking & admin group

  • Model fusion.helpdesk.ticket.seen (client DB): user_id (m2o res.users), central_ticket_id (Integer), last_seen_message_id (Integer) — unique (user_id, central_ticket_id). This is read-tracking metadata only (no ticket content is stored) — it preserves the live-RPC-view principle while letting the badge work without re-fetching on every page load.
  • group_reporter_admin — an Odoo group on the client deployment. Membership unlocks the "All [LABEL]" query path server-side (the controller checks has_group before broadening scope).

7. Notifications & emails

  • Agent → customer: customer is a follower → native email with a "View Ticket" magic link (portal.mixin access_url + token). Satisfies "they get replies in their email." In-app users also see the reply in My Tickets and the badge increments.
  • Acknowledgement on create: branded mail.template sent to the customer with the magic-link CTA so they can track immediately. Fires for any ticket on the portal-enabled team that has a partner_email, regardless of channel (in-app, web, email). Per Odoo 19, the template renders the link from the record (object.access_url / portal URL); no need to pass it via ctx (CLAUDE rule 12). Implementation note: verify website_helpdesk does not already send its own "ticket received" confirmation for web-form submissions — if it does, gate ours so external customers don't get two acknowledgements.
  • Unread badge: unread_count = number of the user's in-scope tickets whose latest customer-visible support message id is greater than the local last_seen_message_id. Cleared per-ticket on open.

8. Security & scoping (the sharp edge)

The shared bot can read every client's tickets on central, so the client-side controller is the security boundary.

  • Endpoints are auth='user'; identity is taken from request.env.user, never from the browser.
  • Scoped domain, built server-side:
    • regular user → [('x_fc_client_label','=',label), ('partner_email','=ilike', me.email or me.login)]
    • admin (group_reporter_admin) → [('x_fc_client_label','=',label)]
  • x_fc_client_label = <my deployment> is ALWAYS ANDed in (defense in depth) so no user — regular or admin — can ever read another deployment's tickets, even if two deployments share a reporter email.
  • ticket/<id> and …/reply re-resolve the ticket through the same scoped domain before reading or posting; a ticket outside scope returns not-found.
  • Thread fetch returns only customer-visible messages (exclude internal notes — subtype_id.internal = True), mirroring what the portal shows. Internal agent discussion never reaches a client.
  • Reuse the module's existing granular remote-error handling for auth/network failures.

9. Data flow

SUBMIT (in-app)
  staff clicks icon → New tab → confirm email → submit
  client controller adds partner_email + partner_name + x_fc_client_label
  → XML-RPC create on central (as bot)
  → helpdesk find-or-creates partner_id + subscribes follower
  → branded acknowledgement email w/ magic link

AGENT REPLY (Nexa support)
  reply as a comment in the ticket chatter on central
  → native email to customer w/ "View Ticket" magic link
  → in-app users also see it in My Tickets; badge increments

CUSTOMER FOLLOW-UP (any of three, same thread)
  in-app dialog reply   → RPC message_post (author = replier's partner)
  portal magic link     → native reply on /my/ticket/<id>/<token>
  email reply           → native email-in via support@nexasystems.ca

10. Edge cases

  • Missing/invalid reporter email — New form prefills + lets the user confirm/edit. If still empty, the ticket is created without a customer (degrades to today's behaviour) and the dialog flags "no follow-up email captured."
  • Same email across deployments — partner is shared (their portal shows all their tickets), but the in-app inbox still scopes by x_fc_client_label, so each deployment shows only its own.
  • Admin replies to a colleague's ticket — author = the admin's own partner, not the ticket customer.
  • Existing 51 orphan tickets — left as-is (no reliable identity to backfill).
  • Bot key revoked/rotated (managed by fusion_helpdesk_central) — endpoints fail gracefully via the existing typed remote-error responses.
  • Internal notes — never returned to the client (subtype filter).

11. Testing strategy

  • fusion_helpdesk_central (Enterprise; runs on an Enterprise env such as odoo-trial, like the billing module — local dev is Community and can't install helpdesk):
    • x_fc_client_label field exists + is searchable.
    • Integration: helpdesk.ticket.create({partner_email, partner_name, x_fc_client_label}) resolves partner_id and adds the partner as a follower.
    • Acknowledgement template renders the magic link from the record.
  • fusion_helpdesk (client; XML-RPC layer mocked — no live central in unit tests):
    • Scoping: regular vs admin domain construction; x_fc_client_label always ANDed.
    • …/reply rejects a ticket outside the caller's scope.
    • Thread fetch excludes internal notes.
    • unread_count math against fusion.helpdesk.ticket.seen.
    • Refactor the remote proxy so it is injectable/mockable.
  • Manual QA on odoo-nexa: full round-trip — submit → agent reply → email + badge → in-app reply → portal magic link → external sign-up shows /my/tickets.

12. Out of scope / future

  • Custom portal theme, branded custom web form, KB deflection, SLA/status timeline (Tier C).
  • Backfilling identity on historical tickets.
  • Push/websocket live updates in the dialog (polling/refresh is sufficient for v1).

13. References

Current code (this repo)

  • fusion_helpdesk/controllers/main.pysubmit(), _read_config(), _authenticate(), _build_diag_block() (XML-RPC forwarder; today sends only {name, description, team_id}).
  • fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js — OWL submission dialog.
  • fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js — systray entry (badge target).
  • fusion_helpdesk/models/res_config_settings.py — remote endpoint config params.
  • fusion_helpdesk_central/models/fusion_helpdesk_client_key.py — bot user + API-key management.

Live system facts (verified 2026-05-27 on nexamain)

  • Modules installed: helpdesk 19.0.1.6, website_helpdesk, website_helpdesk_knowledge, helpdesk_account, helpdesk_sale, portal, website, auth_signup.
  • auth_signup.invitation_scope=b2c; web.base.url=https://erp.nexasystems.ca; mail.catchall.domain=nexasystems.ca; 4 SMTP servers.
  • Team 1 "Customer Care": privacy_visibility=portal, use_website_helpdesk_form=t, allow_portal_ticket_closing=t, use_alias=t, alias support.
  • 51/51 tickets have NULL partner_id/partner_email/partner_name.

Enterprise source (read-only, on container)

  • helpdesk/models/helpdesk_ticket.py_inherit (portal.mixin, mail.thread.cc, rating.mixin); access_url='/my/ticket/<id>'; create() partner find-or-create (≈L564572) + follower subscription (≈L600620).
  • helpdesk/controllers/portal.py/my/tickets, /my/ticket/<id>/<access_token>, /my/ticket/close/<id>/<token>.
  • website_helpdesk/controllers/main.py/helpdesk/<team> public web form.

Odoo 19 gotchas to respect (from repo CLAUDE.md)

  • res.users group field is group_ids (not groups_id).
  • message_post(body=…) HTML must be wrapped in Markup().
  • mail.template ctx is env.context; pass dynamic data via with_context(**data).
  • res.config.settings Boolean via config_parameter doesn't persist False.
  • SQL constraints/indexes use declarative models.Constraint / models.Index.