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>
18 KiB
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, DBnexamain, 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:
- 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.
- 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 byfusion_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:
helpdesk19.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, aliassupport(→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: whenpartner_emailis given andpartner_idis not, it callsmail.thread._partner_find_from_emails_single([partner_email], {name, company_id})to find-or-create the partner and setpartner_id(helpdesk_ticket.py≈ L564–572).create()subscribes the customer as a follower (the "make customer follower" loop, ≈ L600–620), 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→ rendershelpdesk.tickets_followup(reply composer included);/my/ticket/close/<id>/<token>posts a message withauthor_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): insubmit(), add to the create payload:partner_name=request.env.user.namepartner_email= confirmed value from the form (defaultrequest.env.user.email or .login, editable)x_fc_client_label=cfg['client_label']
- Central side (
fusion_helpdesk_central): addx_fc_client_label(Char, indexed) tohelpdesk.ticketand 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_centralconfig + 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.ticketinherit:x_fc_client_labelfield + 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.
- New — today's form, plus a confirmed/editable "Your email" field (prefilled from the logged-in
user; used as
- 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', withauthor_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(m2ores.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 checkshas_groupbefore 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.templatesent to the customer with the magic-link CTA so they can track immediately. Fires for any ticket on the portal-enabled team that has apartner_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 viactx(CLAUDE rule 12). Implementation note: verifywebsite_helpdeskdoes 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 locallast_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 fromrequest.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)]
- regular user →
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…/replyre-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 installhelpdesk):x_fc_client_labelfield exists + is searchable.- Integration:
helpdesk.ticket.create({partner_email, partner_name, x_fc_client_label})resolvespartner_idand 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_labelalways ANDed. …/replyrejects a ticket outside the caller's scope.- Thread fetch excludes internal notes.
unread_countmath againstfusion.helpdesk.ticket.seen.- Refactor the remote proxy so it is injectable/mockable.
- Scoping: regular vs admin domain construction;
- 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.py—submit(),_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:
helpdesk19.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, aliassupport. - 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 (≈L564–572) + follower subscription (≈L600–620).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.usersgroup field isgroup_ids(notgroups_id).message_post(body=…)HTML must be wrapped inMarkup().mail.templatectxisenv.context; pass dynamic data viawith_context(**data).res.config.settingsBoolean viaconfig_parameterdoesn't persistFalse.- SQL constraints/indexes use declarative
models.Constraint/models.Index.