From 960fe679666c34204218d6af76e4e1fb752dc3b2 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 03:19:24 -0400 Subject: [PATCH] docs(helpdesk): TDD implementation plan (16 tasks) Co-Authored-By: Claude Opus 4.7 (1M context) --- ...05-27-fusion-helpdesk-customer-followup.md | 477 ++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-fusion-helpdesk-customer-followup.md 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 + + + fhc.helpdesk.ticket.list.label + helpdesk.ticket + + + + + + + + + fhc.helpdesk.ticket.search.label + helpdesk.ticket + + + + + + + + + +``` +> NOTE at execution: verify the exact `inherit_id` external IDs by reading the live views (`helpdesk.helpdesk_ticket_view_tree`, `helpdesk.helpdesk_tickets_view_search`) on odoo-nexa — names differ across versions. Adjust before install. +- [ ] **Step 2: Commit** `git commit -am "feat(fusion_helpdesk_central): expose client label in ticket views"` + +--- + +## Phase 2 — Read APIs + scoping (client) + +### Task 5: Pure scoping + message-filter + unread helpers + +**Files:** Modify `fusion_helpdesk/utils.py`; Modify `fusion_helpdesk/tests/test_utils.py` + +- [ ] **Step 1: Write failing tests** +```python +from odoo.addons.fusion_helpdesk.utils import ( + build_scope_domain, is_public_message, compute_unread_count) + +def test_regular_scope_binds_email_and_label(self): + dom = build_scope_domain(label='ENTECH', email='john@entech.com', is_admin=False) + self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom) + self.assertIn(('partner_email', '=ilike', 'john@entech.com'), dom) + +def test_admin_scope_binds_label_only(self): + dom = build_scope_domain(label='ENTECH', email='a@entech.com', is_admin=True) + self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom) + self.assertFalse(any(t[0] == 'partner_email' for t in dom)) + +def test_admin_still_bounded_by_label(self): + # label is ALWAYS present — no cross-deployment leakage + self.assertTrue(build_scope_domain('ENTECH', 'a@x', True)) + +def test_internal_note_is_not_public(self): + self.assertFalse(is_public_message({'subtype_is_internal': True})) + self.assertTrue(is_public_message({'subtype_is_internal': False})) + +def test_unread_count(self): + tickets = [{'id': 1, 'last_support_msg_id': 10}, + {'id': 2, 'last_support_msg_id': 5}, + {'id': 3, 'last_support_msg_id': 0}] + seen = {1: 10, 2: 3} # ticket 2 has newer support msg; 1 is read; 3 none + self.assertEqual(compute_unread_count(tickets, seen), 1) +``` + +- [ ] **Step 2: Run — FAIL** +- [ ] **Step 3: Implement** +```python +def build_scope_domain(label, email, is_admin): + """Server-side ticket scope. label is ALWAYS bound (defense in depth).""" + domain = [('x_fc_client_label', '=', label or '__none__')] + if not is_admin: + domain.append(('partner_email', '=ilike', email or '__none__')) + return domain + +def is_public_message(msg): + """True when a message is customer-visible (not an internal note).""" + return not msg.get('subtype_is_internal', False) + +def compute_unread_count(tickets, seen_by_id): + """Count tickets whose latest support message id exceeds the user's + last-seen id for that ticket (0/absent = unseen baseline).""" + n = 0 + for t in tickets: + last = t.get('last_support_msg_id') or 0 + if last and last > (seen_by_id.get(t['id']) or 0): + n += 1 + return n +``` +- [ ] **Step 4: Run — PASS**; **Step 5: Commit** + +### Task 6: `fusion.helpdesk.ticket.seen` model + ACL + +**Files:** Create `fusion_helpdesk/models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py`; Modify `__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`; Test `fusion_helpdesk/tests/test_seen.py` + +- [ ] **Step 1: Failing test** +```python +# tests/test_seen.py +from odoo.tests import TransactionCase, tagged + +@tagged('post_install', '-at_install', 'fusion_helpdesk') +class TestSeen(TransactionCase): + def test_mark_seen_upserts(self): + Seen = self.env['fusion.helpdesk.ticket.seen'] + Seen._mark_seen(central_ticket_id=42, last_message_id=100) + Seen._mark_seen(central_ticket_id=42, last_message_id=120) + rec = Seen.search([('user_id', '=', self.env.uid), + ('central_ticket_id', '=', 42)]) + self.assertEqual(len(rec), 1) + self.assertEqual(rec.last_seen_message_id, 120) + + def test_seen_map(self): + Seen = self.env['fusion.helpdesk.ticket.seen'] + Seen._mark_seen(1, 10); Seen._mark_seen(2, 20) + self.assertEqual(Seen._seen_map([1, 2, 3]), {1: 10, 2: 20}) +``` + +- [ ] **Step 2: Run — FAIL** +- [ ] **Step 3: Implement model** +```python +# models/fusion_helpdesk_ticket_seen.py +from odoo import api, fields, models + +class FusionHelpdeskTicketSeen(models.Model): + _name = 'fusion.helpdesk.ticket.seen' + _description = 'Fusion Helpdesk — per-user read tracking (metadata only)' + + user_id = fields.Many2one('res.users', required=True, index=True, + default=lambda s: s.env.uid, ondelete='cascade') + central_ticket_id = fields.Integer(required=True, index=True) + last_seen_message_id = fields.Integer(default=0) + + _user_ticket_uniq = models.Constraint( + 'UNIQUE(user_id, central_ticket_id)', + 'One seen-row per user per ticket.') + + @api.model + def _mark_seen(self, central_ticket_id, last_message_id): + rec = self.search([('user_id', '=', self.env.uid), + ('central_ticket_id', '=', central_ticket_id)], limit=1) + if rec: + if last_message_id > rec.last_seen_message_id: + rec.last_seen_message_id = last_message_id + else: + self.create({'central_ticket_id': central_ticket_id, + 'last_seen_message_id': last_message_id}) + return True + + @api.model + def _seen_map(self, central_ticket_ids): + rows = self.search([('user_id', '=', self.env.uid), + ('central_ticket_id', 'in', central_ticket_ids)]) + return {r.central_ticket_id: r.last_seen_message_id for r in rows} +``` +- [ ] **Step 4:** ACL CSV row: +```csv +access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1 +``` +`models/__init__.py` → `from . import fusion_helpdesk_ticket_seen`; `__init__.py` → `from . import models`; manifest registers nothing extra (models auto). +- [ ] **Step 5: Run — PASS**; **Step 6: Commit** + +### Task 7: Admin group + +**Files:** Create `fusion_helpdesk/security/fusion_helpdesk_groups.xml`; Modify `__manifest__.py` (add to `data`, FIRST so the group exists before ACLs reference it if needed) +- [ ] **Step 1:** +```xml + + + Helpdesk Reporter Admin + Can view all tickets filed from this deployment in the in-app inbox. + + +``` +> Odoo 19: NO `users`/`category_id` fields on res.groups. Keep the record minimal. +- [ ] **Step 2:** Upgrade clean; **Step 3: Commit** + +### Task 8: Read endpoints (`my_tickets`, `ticket_detail`, `unread_count`) + +**Files:** Modify `fusion_helpdesk/controllers/main.py` + +- [ ] **Step 1:** Add a mockable RPC seam + identity helper: +```python +def _identity(self): + user = request.env.user + cfg = self._read_config() + return { + 'email': (user.email or user.login or '').strip(), + 'label': cfg['client_label'], + 'is_admin': user.has_group('fusion_helpdesk.group_reporter_admin'), + 'cfg': cfg, + } + +def _rpc(self, cfg, model, method, args, kw=None): + uid, proxy = self._authenticate(cfg) # existing + return proxy.execute_kw(cfg['db'], uid, cfg['password'], model, method, args, kw or {}) +``` +- [ ] **Step 2:** Implement endpoints (all `type='jsonrpc'`, `auth='user'`). `my_tickets` builds the scoped domain via `build_scope_domain`, `search_read` fields `[id, name, stage_id, write_date]`, plus a per-ticket latest public support message id (read `message_ids` or a dedicated query), then computes `has_unread` via the seen map. `ticket_detail` re-resolves the ticket through the scoped domain (reject if absent), reads public messages only (filter via `is_public_message` using each message's subtype internal flag fetched from central), and calls `_mark_seen`. `unread_count` returns `compute_unread_count(...)`. +> Execution detail: fetch message subtype "internal" flag from central by reading `mail.message` fields `[author_id, date, body, message_type, subtype_id]` and resolving `subtype_id.internal` via a second read or by filtering `message_type='comment'` + excluding notes. Confirm the cleanest field set against the live `mail.message` model during execution. +- [ ] **Step 3:** Manual: upgrade module; **Step 4: Commit** + +--- + +## Phase 3 — Reply endpoint (client) + +### Task 9: `ticket_reply` + +**Files:** Modify `fusion_helpdesk/controllers/main.py` +- [ ] **Step 1:** Endpoint `/fusion_helpdesk/ticket//reply`, `auth='user'`. Re-resolve ticket via scoped domain (reject if not in scope). Resolve author partner on central by the replier's email (find-or-create via `res.partner` search/create through bot, or pass `author_id` resolved from `partner_email`). Post: +```python +self._rpc(cfg, 'helpdesk.ticket', 'message_post', [ticket_id], { + 'body': body_html, # already-safe HTML (escape user text) + 'message_type': 'comment', + 'subtype_xmlid': 'mail.mt_comment', + 'author_id': author_partner_id, +}) +``` +- [ ] **Step 2:** Escape the user's text to HTML server-side (reuse `_html_escape`). Mark seen after posting. +- [ ] **Step 3:** Manual upgrade; **Step 4: Commit** + +--- + +## Phase 4 — Client UI (dialog tabs, thread, badge) + +### Task 10: Dialog tabs + My Tickets list + thread + reply + confirmed email + +**Files:** Modify `static/src/js/fusion_helpdesk_dialog.js`, `static/src/xml/fusion_helpdesk_dialog.xml`, `static/src/scss/fusion_helpdesk.scss` +- [ ] **Step 1:** Add to state: `tab:'new'|'list'|'thread'`, `tickets:[]`, `loadingList`, `current:{id,subject,messages,canReply}`, `replyBody`, `replyEmail` (default from a new `/fusion_helpdesk/whoami` or seeded via session user email — read `user.email` via `useService('user')`/`session`), `scope:'mine'|'all'`, `isAdmin`. +- [ ] **Step 2:** Methods: `openList()` → rpc `/fusion_helpdesk/my_tickets` (with `scope`); `openTicket(id)` → rpc detail, switch to thread, refresh list badge; `sendReply()` → rpc reply then reload thread; `setScope()` (admin toggle). Add confirmed **Your email** input on the New tab bound to `state.replyEmail`, passed as `reply_email` in submit payload. +- [ ] **Step 3:** Template: a tab header (New | My Tickets); New pane = existing form + email field; List pane = table (ref, subject, stage chip, unread dot) + admin Mine/All toggle; Thread pane = messages (author, date, body, attachments) + reply box + Back. Use `Markup`-safe rendering: render message bodies with `t-out` (OWL) since central returns sanitized HTML. +- [ ] **Step 4:** SCSS for tabs/list/thread (follow Odoo kanban hex pattern + dark-mode `$o-webclient-color-scheme` branch per CLAUDE.md). +- [ ] **Step 5:** Manual QA locally (dialog opens, tabs switch). **Step 6: Commit** + +### Task 11: Systray unread badge + +**Files:** Modify `static/src/js/fusion_helpdesk_systray.js`, `static/src/xml/fusion_helpdesk_systray.xml`, SCSS +- [ ] **Step 1:** On setup, call `/fusion_helpdesk/unread_count`; store `state.unread`. Poll on an interval (e.g. 120s) and on dialog close. Show a badge bubble when `unread > 0`. +- [ ] **Step 2:** Badge markup over the icon. **Step 3: Commit** + +--- + +## Phase 5 — Central acknowledgement email + +### Task 12: Branded acknowledgement template + send-on-create + +**Files:** Create `fusion_helpdesk_central/data/mail_template_ack.xml`; Modify `models/helpdesk_ticket.py`, `__manifest__.py` +- [ ] **Step 1:** `mail.template` on `helpdesk.ticket` with subject "We received your request [{{ object.ticket_ref }}]" and a body using the company email layout + a prominent button to `{{ object.get_base_url() }}{{ object.access_url }}` (magic link). Canadian English. +- [ ] **Step 2:** Send on create via a create-override (central inherit), gated: +```python +@api.model_create_multi +def create(self, vals_list): + tickets = super().create(vals_list) + tmpl = self.env.ref('fusion_helpdesk_central.mail_template_ticket_ack', raise_if_not_found=False) + for t in tickets: + if tmpl and t.partner_email and t.x_fc_client_label: # in-app channel only → avoid double-ack with native web form + tmpl.send_mail(t.id, force_send=False) + return tickets +``` +> Decision: gate on `x_fc_client_label` so only in-app-channel tickets get OUR ack; external web/email customers rely on native confirmation (verify native behavior during deploy; widen the gate if native sends nothing). +- [ ] **Step 3:** Register template data in manifest; **Step 4: Commit** + +--- + +## Phase 6 — Review, fix, deploy, smoke test + +### Task 13: Code review + fix +- [ ] Run the code-review skill / pr-review-toolkit `code-reviewer` + `silent-failure-hunter` over the diff. Fix HIGH/MEDIUM findings. Re-run client tests locally. Commit fixes. + +### Task 14: Deploy + test central on odoo-nexa +- [ ] Copy/confirm `fusion_helpdesk_central` source is visible to odoo-nexa (`/opt/odoo/custom-addons`). +- [ ] Run module tests on nexa: `-u fusion_helpdesk_central --test-enable --test-tags /fusion_helpdesk_central --stop-after-init` (ephemeral http port). Fix failures. +- [ ] Upgrade live: `-u fusion_helpdesk_central --stop-after-init` then restart `odoo-nexa-app`. + +### Task 15: Deploy client on odoo-entech +- [ ] Look up entech access (memory: DB `admin`; confirm container/SSH via Supabase quick_commands). Confirm entech's `fusion_helpdesk.client_label` (e.g. ENTECH) + remote config points at nexa. +- [ ] Ensure `fusion_helpdesk` source present on entech; upgrade `-u fusion_helpdesk --stop-after-init`; restart. + +### Task 16: Smoke test (one ticket) +- [ ] From entech: file ONE test ticket via the dialog (or simulate the controller path). +- [ ] On nexa: confirm the new ticket has `partner_id` resolved, `partner_email`/`partner_name`/`x_fc_client_label` set, customer is a follower, ack email queued/sent. +- [ ] Reply as agent on nexa → confirm notification email to the reporter w/ magic link; confirm the entech dialog "My Tickets" shows the ticket + reply and the badge increments. +- [ ] Confirm pre-existing identity-less tickets are untouched (the "lots already submitted" set) and do NOT leak across deployments in the inbox query. + +--- + +## Self-Review (run before execution) +- **Spec coverage:** keystone (T1-3), label field+views (T3-4), scoping (T5,8,9), seen/badge (T6,10,11), admin group (T7), ack email (T12), portal/native (config — verified live, no code), tests (T1,5,6 local + T3 enterprise), deploy+smoke (T14-16). ✓ +- **Placeholders:** none — code shown for all Python/XML; JS tasks specify state/methods/markup concretely. JS is manually QA'd (OWL unit tests out of scope). +- **Type consistency:** `build_scope_domain(label,email,is_admin)`, `is_public_message(msg)`, `compute_unread_count(tickets,seen)`, `_mark_seen(central_ticket_id,last_message_id)`, `_seen_map(ids)`, `x_fc_client_label` — names consistent across tasks. ✓