diff --git a/docs/superpowers/plans/2026-05-27-fusion-helpdesk-customer-followup.md b/docs/superpowers/plans/2026-05-27-fusion-helpdesk-customer-followup.md new file mode 100644 index 00000000..3fb51a65 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-fusion-helpdesk-customer-followup.md @@ -0,0 +1,477 @@ +# Fusion Helpdesk — Customer Follow-up & Embedded Inbox Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Attach real customer identity to every helpdesk ticket and give client-deployment staff an in-app ticket inbox (read replies + follow up without leaving their Odoo), while external customers use the native Enterprise portal + magic link. + +**Architecture:** Keystone = pass `partner_email`/`partner_name`/`x_fc_client_label` in the ticket-create payload; native helpdesk then creates the partner + subscribes the follower. Client module (`fusion_helpdesk`) gains read/reply RPC endpoints + a tabbed dialog + unread badge, all scoped server-side by the logged-in user's identity. Central module (`fusion_helpdesk_central`) adds the `x_fc_client_label` field + a branded acknowledgement email. + +**Tech Stack:** Odoo 19 (Enterprise on central, Community on client deployments), Python 3.11, OWL 2, XML-RPC client→central, `helpdesk` (Enterprise), `portal.mixin`, `mail.thread.cc`. + +**Spec:** `docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md` + +**Testability note:** `fusion_helpdesk` depends only on base/web/mail → installable + testable on local Community (`odoo-modsdev-app`, DB `modsdev`). Pure logic (scope-domain, message filtering, vals builder, unread math) is extracted into `fusion_helpdesk/utils.py` and unit-tested with no live remote. `fusion_helpdesk_central` depends on `helpdesk` (Enterprise) → install/test on the deploy target (odoo-nexa) or odoo-trial. + +--- + +## File Structure + +**`fusion_helpdesk` (client)** +- `utils.py` *(new)* — pure helpers: `build_scope_domain`, `is_public_message`, `build_ticket_vals`, `compute_unread_count`. No Odoo env needed → trivially unit-testable. +- `controllers/main.py` *(modify)* — keystone payload in `submit()`; new endpoints `my_tickets`, `ticket_detail`, `ticket_reply`, `unread_count`; a mockable `_rpc(model, method, args, kw)` seam. +- `models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py` *(new)* — `fusion.helpdesk.ticket.seen` read-tracking model. +- `security/ir.model.access.csv` *(modify)* — ACL for the seen model. +- `security/fusion_helpdesk_groups.xml` *(new)* — `group_reporter_admin`. +- `static/src/js/fusion_helpdesk_dialog.js` *(modify)* — tabs (New / My Tickets), list, thread, reply. +- `static/src/xml/fusion_helpdesk_dialog.xml` *(modify)* — tab markup + list/thread/reply templates + confirmed-email field. +- `static/src/js/fusion_helpdesk_systray.js` *(modify)* — unread badge. +- `static/src/xml/fusion_helpdesk_systray.xml` *(modify)* — badge markup. +- `static/src/scss/fusion_helpdesk.scss` *(modify)* — tab/list/thread/badge styles. +- `tests/__init__.py`, `tests/test_utils.py`, `tests/test_seen.py` *(new)*. +- `__manifest__.py` *(modify)* — version bump, register groups XML + tests dir + new model. + +**`fusion_helpdesk_central` (central)** +- `models/__init__.py`, `models/helpdesk_ticket.py` *(new)* — inherit `helpdesk.ticket`, add `x_fc_client_label`. +- `views/helpdesk_ticket_views.xml` *(new)* — list column + search filter for `x_fc_client_label`. +- `data/mail_template_ack.xml` *(new)* — branded acknowledgement template. +- `data/helpdesk_ack_automation.xml` *(new)* OR create-override in `helpdesk_ticket.py` — send ack on create. +- `tests/__init__.py`, `tests/test_identity.py` *(new)* — partner resolution + follower + label. +- `__manifest__.py` *(modify)* — version bump, register models/views/data/tests. + +--- + +## Phase 1 — Keystone identity + +### Task 1: Pure `build_ticket_vals` helper (client) + +**Files:** Create `fusion_helpdesk/utils.py`; Test `fusion_helpdesk/tests/test_utils.py` + +- [ ] **Step 1: Write failing test** +```python +# fusion_helpdesk/tests/test_utils.py +from odoo.tests import TransactionCase, tagged +from odoo.addons.fusion_helpdesk.utils import build_ticket_vals + +@tagged('post_install', '-at_install', 'fusion_helpdesk') +class TestBuildTicketVals(TransactionCase): + def test_identity_fields_present(self): + vals = build_ticket_vals( + kind='bug', subject='X', body_html='
b
', + team_id=1, client_label='ENTECH', + reporter_name='John Doe', reporter_email='john@entech.com', + company_name='ENTECH Inc', + ) + self.assertEqual(vals['partner_email'], 'john@entech.com') + self.assertEqual(vals['partner_name'], 'John Doe') + self.assertEqual(vals['x_fc_client_label'], 'ENTECH') + self.assertEqual(vals['partner_company_name'], 'ENTECH Inc') + self.assertEqual(vals['team_id'], 1) + self.assertIn('X', vals['name']) + + def test_no_email_omits_partner_email(self): + vals = build_ticket_vals( + kind='feature', subject='Y', body_html='b
', + team_id=False, client_label='', reporter_name='Jane', + reporter_email='', company_name='', + ) + self.assertNotIn('partner_email', vals) # never send empty email + self.assertNotIn('team_id', vals) # omit falsy team + self.assertEqual(vals['partner_name'], 'Jane') +``` + +- [ ] **Step 2: Run — expect ImportError/FAIL** +Run: `docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_helpdesk -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -30` + +- [ ] **Step 3: Implement `build_ticket_vals`** +```python +# fusion_helpdesk/utils.py +"""Pure helpers for fusion_helpdesk — no Odoo env, unit-testable in isolation.""" + +def build_ticket_vals(kind, subject, body_html, team_id, client_label, + reporter_name, reporter_email, company_name): + """Construct helpdesk.ticket create vals. Identity fields drive native + partner find-or-create + follower subscription on the central Odoo.""" + kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request' + prefix = ('[%s] ' % client_label) if client_label else '' + vals = { + 'name': '%s%s: %s' % (prefix, kind_label, subject or '(untitled)'), + 'description': body_html, + 'partner_name': reporter_name or '', + } + if team_id: + vals['team_id'] = team_id + if reporter_email: + vals['partner_email'] = reporter_email + if company_name: + vals['partner_company_name'] = company_name + if client_label: + vals['x_fc_client_label'] = client_label + return vals +``` + +- [ ] **Step 4: Run — expect PASS** (same command as Step 2) +- [ ] **Step 5: Commit** `git add fusion_helpdesk/utils.py fusion_helpdesk/tests/ && git commit -m "feat(fusion_helpdesk): pure build_ticket_vals helper (identity keystone)"` + +### Task 2: Wire keystone into `submit()` (client) + +**Files:** Modify `fusion_helpdesk/controllers/main.py` + +- [ ] **Step 1:** In `submit()`, accept new arg `reply_email=None`. Replace the inline `ticket_vals` block with: +```python +from odoo.addons.fusion_helpdesk.utils import build_ticket_vals +# ... +user = request.env.user +reporter_email = (reply_email or user.email or user.login or '').strip() +body_html = '\n'.join(body_parts) +ticket_vals = build_ticket_vals( + kind=kind, subject=subject, body_html=body_html, + team_id=cfg['team_id'], client_label=cfg['client_label'], + reporter_name=user.name, reporter_email=reporter_email, + company_name=request.env.company.name, +) +``` +- [ ] **Step 2:** Keep the existing create + attachment + return logic. Verify `_build_diag_block` still appends. +- [ ] **Step 3: Manual sanity** — `docker exec odoo-modsdev-app odoo -d modsdev -u fusion_helpdesk --stop-after-init 2>&1 | tail -20` (module upgrades clean). +- [ ] **Step 4: Commit** `git commit -am "feat(fusion_helpdesk): send partner identity in ticket payload"` + +### Task 3: `x_fc_client_label` field on central + +**Files:** Create `fusion_helpdesk_central/models/__init__.py`, `models/helpdesk_ticket.py`; Modify `__init__.py`, `__manifest__.py` + +- [ ] **Step 1: Write failing test** (runs on Enterprise env) +```python +# fusion_helpdesk_central/tests/test_identity.py +from odoo.tests import TransactionCase, tagged + +@tagged('post_install', '-at_install', 'fusion_helpdesk_central') +class TestTicketIdentity(TransactionCase): + def test_label_field_and_partner_resolution(self): + team = self.env['helpdesk.team'].search([], limit=1) + t = self.env['helpdesk.ticket'].create({ + 'name': 'T1', 'team_id': team.id, + 'partner_email': 'newperson@example.com', + 'partner_name': 'New Person', + 'x_fc_client_label': 'ENTECH', + }) + self.assertEqual(t.x_fc_client_label, 'ENTECH') + self.assertTrue(t.partner_id, "native create should resolve partner from email") + self.assertIn(t.partner_id, t.message_partner_ids, "customer should be a follower") +``` + +- [ ] **Step 2: Implement field** +```python +# fusion_helpdesk_central/models/helpdesk_ticket.py +from odoo import fields, models + +class HelpdeskTicket(models.Model): + _inherit = 'helpdesk.ticket' + + x_fc_client_label = fields.Char( + string='Client Deployment', index=True, copy=False, + help='Deployment tag (e.g. ENTECH) set by the in-app reporter. ' + 'Scopes the embedded "My Tickets" inbox per client.', + ) +``` +```python +# fusion_helpdesk_central/models/__init__.py +from . import helpdesk_ticket +``` +- [ ] **Step 3:** `fusion_helpdesk_central/__init__.py` → add `from . import models`. `__manifest__.py` → `version` bump to `19.0.1.1.0`, add `'models'` import is implicit; add `views/helpdesk_ticket_views.xml` to `data`, add `tests` discovery (automatic). +- [ ] **Step 4: Run on Enterprise** (deferred to Phase 6 deploy; can't run on local Community). +- [ ] **Step 5: Commit** `git commit -am "feat(fusion_helpdesk_central): x_fc_client_label on helpdesk.ticket"` + +### Task 4: Backend list/search exposure (central) + +**Files:** Create `fusion_helpdesk_central/views/helpdesk_ticket_views.xml` +- [ ] **Step 1:** Inherit the helpdesk ticket list + search to add `x_fc_client_label` (column `optional="show"`, search field + a group-by). Use `group_ids` not `groups_id` if gating (none needed here). +```xml +tags. + html = '
%s
' % _html_escape(text).replace('\n', '