diff --git a/docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md b/docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md new file mode 100644 index 00000000..282590c9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md @@ -0,0 +1,336 @@ +# 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/'` (← 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` ≈ 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//` (auth=`public`) + → validates token via `_document_check_access` → renders `helpdesk.tickets_followup` (reply composer + included); `/my/ticket/close//` posts a message with `author_id = partner_id`; public web + form at `/helpdesk/`. + +**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/` | `{id, subject, stage, messages:[…], can_reply}` | **Public comments only** — internal notes excluded (§8). Re-checks scope. | +| `/fusion_helpdesk/ticket//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 = ` 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/` 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// + 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.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: `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/'`; `create()` partner find-or-create (≈L564–572) + follower subscription + (≈L600–620). +- `helpdesk/controllers/portal.py` — `/my/tickets`, `/my/ticket//`, + `/my/ticket/close//`. +- `website_helpdesk/controllers/main.py` — `/helpdesk/` 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`.