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>
This commit is contained in:
@@ -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='<p>b</p>',
|
||||
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='<p>b</p>',
|
||||
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
|
||||
<odoo>
|
||||
<record id="fhc_ticket_list_label" model="ir.ui.view">
|
||||
<field name="name">fhc.helpdesk.ticket.list.label</field>
|
||||
<field name="model">helpdesk.ticket</field>
|
||||
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<field name="x_fc_client_label" optional="show"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
<record id="fhc_ticket_search_label" model="ir.ui.view">
|
||||
<field name="name">fhc.helpdesk.ticket.search.label</field>
|
||||
<field name="model">helpdesk.ticket</field>
|
||||
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<field name="x_fc_client_label"/>
|
||||
<filter string="Client Deployment" name="group_client_label"
|
||||
context="{'group_by': 'x_fc_client_label'}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
> 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
|
||||
<odoo>
|
||||
<record id="group_reporter_admin" model="res.groups">
|
||||
<field name="name">Helpdesk Reporter Admin</field>
|
||||
<field name="comment">Can view all tickets filed from this deployment in the in-app inbox.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
> 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/<int:ticket_id>/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. ✓
|
||||
@@ -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/<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` ≈ 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` → 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.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/<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.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`.
|
||||
Reference in New Issue
Block a user