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. ✓ 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`. diff --git a/fusion_helpdesk/__manifest__.py b/fusion_helpdesk/__manifest__.py index 11f38e4d..59190928 100644 --- a/fusion_helpdesk/__manifest__.py +++ b/fusion_helpdesk/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Helpdesk Reporter', - 'version': '19.0.1.3.0', + 'version': '19.0.1.4.1', 'category': 'Productivity', 'summary': 'One-click in-app bug reporting & feature requesting — ' 'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.', @@ -27,6 +27,7 @@ module bundle. No dependencies on the rest of Fusion Plating. 'license': 'OPL-1', 'depends': ['base', 'web', 'mail'], 'data': [ + 'security/fusion_helpdesk_groups.xml', 'security/ir.model.access.csv', 'data/ir_config_parameter_data.xml', 'views/res_config_settings_views.xml', diff --git a/fusion_helpdesk/controllers/main.py b/fusion_helpdesk/controllers/main.py index b5ad514f..4e92c90a 100644 --- a/fusion_helpdesk/controllers/main.py +++ b/fusion_helpdesk/controllers/main.py @@ -23,6 +23,15 @@ from odoo import _, http from odoo.exceptions import UserError from odoo.http import request +from odoo.addons.fusion_helpdesk.utils import ( + build_ticket_vals, + build_scope_domain, + is_public_message, + compute_unread_count, + escape_like, + _norm_email, +) + _logger = logging.getLogger(__name__) @@ -34,7 +43,7 @@ class FusionHelpdeskController(http.Controller): ) def submit(self, kind, subject, description, error_code=None, attachments=None, - page_url=None, user_agent=None): + page_url=None, user_agent=None, reply_email=None): """Forward a bug report or feature request to the central Odoo Helpdesk and return {ok, ticket_id, ticket_url, error}. @@ -60,10 +69,6 @@ class FusionHelpdeskController(http.Controller): } # ---- Build the ticket payload --------------------------------- - prefix = ('[%s] ' % cfg['client_label']) if cfg['client_label'] else '' - kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request' - full_subject = '%s%s: %s' % (prefix, kind_label, subject or '(untitled)') - body_parts = [] if description: body_parts.append( @@ -77,12 +82,22 @@ class FusionHelpdeskController(http.Controller): ) body_parts.append(self._build_diag_block(page_url, user_agent)) - ticket_vals = { - 'name': full_subject, - 'description': '\n'.join(body_parts), - } - if cfg['team_id']: - ticket_vals['team_id'] = cfg['team_id'] + # Identity keystone: send the reporter's name + email so the central + # helpdesk find-or-creates the customer partner and subscribes them as + # a follower — which is what enables reply emails, the magic link, and + # the scoped "My Tickets" inbox. reply_email is the (editable) value the + # user confirmed in the dialog; fall back to their Odoo email/login. + user = request.env.user + # Normalise the confirmed email (and fall back to the user's own). + # Normalising rejects garbage / wildcard-bearing values so the stored + # partner_email — which is also the inbox scope key — stays clean. + reporter_email = _norm_email(reply_email, user.email, user.login) + ticket_vals = build_ticket_vals( + kind=kind, subject=subject, body_html='\n'.join(body_parts), + team_id=cfg['team_id'], client_label=cfg['client_label'], + reporter_name=user.name, reporter_email=reporter_email, + company_name=request.env.company.name, + ) # ---- Talk to remote Odoo -------------------------------------- try: @@ -133,7 +148,12 @@ class FusionHelpdeskController(http.Controller): return _network_error_response(cfg['url'], e) # ---- Push attachments ----------------------------------------- + # The ticket already exists; an attachment failure must NOT bubble up + # as a 500 (the user would think the whole submission failed and file + # a duplicate). Catch network errors too, count failures, and report + # them back so the dialog can tell the user which files didn't make it. attached = 0 + failed = 0 for att in attachments or []: data_b64 = (att or {}).get('data_b64') name = (att or {}).get('name') or 'attachment.bin' @@ -152,10 +172,12 @@ class FusionHelpdeskController(http.Controller): }], ) attached += 1 - except xmlrpc.client.Fault as e: + except (xmlrpc.client.Fault, xmlrpc.client.ProtocolError, + socket.timeout, OSError, ssl.SSLError) as e: + failed += 1 _logger.warning( 'fusion_helpdesk: attachment "%s" upload failed: %s', - name, e.faultString, + name, e, ) ticket_url = urljoin( @@ -163,9 +185,9 @@ class FusionHelpdeskController(http.Controller): 'odoo/helpdesk/%s' % ticket_id, ) _logger.info( - 'fusion_helpdesk: created remote ticket #%s (%s attachments) ' + 'fusion_helpdesk: created remote ticket #%s (%s attached, %s failed) ' 'on %s for user %s', - ticket_id, attached, cfg['url'], + ticket_id, attached, failed, cfg['url'], request.env.user.login, ) return { @@ -173,6 +195,7 @@ class FusionHelpdeskController(http.Controller): 'ticket_id': ticket_id, 'ticket_url': ticket_url, 'attached': attached, + 'failed': failed, } # ------------------------------------------------------------------ @@ -351,6 +374,318 @@ class FusionHelpdeskController(http.Controller): body += '' return body + # ================================================================== + # Embedded ticket inbox — identity, RPC seam, helpers + # ================================================================== + def _identity(self): + """Resolve the caller's scope from the SERVER-SIDE session only. + + Never trust an email / label / scope sent by the browser — this is + the security boundary that stops one deployment reading another's + tickets through the shared bot account.""" + user = request.env.user + cfg = self._read_config() + return { + 'cfg': cfg, + # Normalised so a self-set wildcard email ('%') can't widen scope. + 'email': _norm_email(user.email, user.login), + 'label': cfg['client_label'], + 'is_admin': user.has_group('fusion_helpdesk.group_reporter_admin'), + 'name': user.name, + } + + def _config_ready(self, cfg): + return all([cfg['url'], cfg['db'], cfg['login'], cfg['password']]) + + def _rpc(self, cfg, model, method, args, kw=None): + """Authenticate + execute_kw against the central Odoo as the bot. + + A ProtocolError on the execute_kw leg (e.g. a 502/503/429 from the + central reverse proxy) is NOT an OSError subclass, so we convert it to + a _RemoteError here — otherwise it would escape every endpoint's + except-tuple and surface as a raw 500 (mislabelled "Network error").""" + uid, proxy = self._authenticate(cfg) + try: + return proxy.execute_kw( + cfg['db'], uid, cfg['password'], model, method, args, kw or {}, + ) + except xmlrpc.client.ProtocolError as e: + _logger.warning('fusion_helpdesk: HTTP %s on %s.%s: %s', + e.errcode, model, method, e.errmsg) + raise _RemoteError( + 'remote_http_error', + _('The central Helpdesk returned HTTP %(code)s. Please try ' + 'again in a moment.') % {'code': e.errcode}, + ) + + def _internal_subtype_map(self, cfg, subtype_ids): + """{subtype_id: internal_bool} so internal notes can be hidden.""" + ids = [s for s in set(subtype_ids) if s] + if not ids: + return {} + rows = self._rpc(cfg, 'mail.message.subtype', 'read', + [ids], {'fields': ['internal']}) + return {r['id']: r.get('internal', False) for r in rows} + + def _ticket_messages(self, cfg, ticket_ids): + """Raw comment/email messages for a set of tickets (one RPC).""" + if not ticket_ids: + return [] + return self._rpc( + cfg, 'mail.message', 'search_read', + [[('model', '=', 'helpdesk.ticket'), + ('res_id', 'in', list(ticket_ids)), + ('message_type', 'in', ['comment', 'email'])]], + {'fields': ['id', 'res_id', 'author_id', 'subtype_id']}, + ) + + def _last_support_map(self, cfg, tickets, msgs): + """{ticket_id: latest customer-visible SUPPORT message id}. + + A support message is a public comment NOT authored by the ticket's + own customer (internal notes and the customer's own posts excluded).""" + internal = self._internal_subtype_map( + cfg, [m['subtype_id'][0] for m in msgs if m.get('subtype_id')]) + customer = { + t['id']: (t['partner_id'][0] if t['partner_id'] else None) + for t in tickets + } + last = {} + for m in msgs: + st = m.get('subtype_id') + if st and internal.get(st[0]): + continue # internal note — never counts / never shown + author = m['author_id'][0] if m['author_id'] else None + rid = m['res_id'] + if author and author == customer.get(rid): + continue # the customer's own reply isn't an unread "support" msg + if m['id'] > last.get(rid, 0): + last[rid] = m['id'] + return last + + def _public_messages(self, cfg, ticket_id): + """Customer-visible thread for one ticket, oldest first.""" + raw = self._rpc( + cfg, 'mail.message', 'search_read', + [[('model', '=', 'helpdesk.ticket'), + ('res_id', '=', ticket_id), + ('message_type', 'in', ['comment', 'email'])]], + {'fields': ['id', 'date', 'body', 'author_id', 'subtype_id', + 'attachment_ids'], + 'order': 'id asc'}, + ) + internal = self._internal_subtype_map( + cfg, [m['subtype_id'][0] for m in raw if m.get('subtype_id')]) + out = [] + for m in raw: + st = m.get('subtype_id') + msg = { + 'id': m['id'], + 'date': m['date'], + 'body': m['body'] or '', + 'author': (m['author_id'][1] if m['author_id'] else ''), + 'author_id': (m['author_id'][0] if m['author_id'] else False), + 'attachment_count': len(m.get('attachment_ids') or []), + 'subtype_is_internal': internal.get(st[0], False) if st else False, + } + if is_public_message(msg): + out.append(msg) + return out + + def _resolve_author(self, cfg, ident, ticket): + """Find-or-create the replier's OWN partner on central so their reply + is correctly attributed. + + `ident['email']` is already normalised (no wildcards); we escape it for + the =ilike search as belt-and-suspenders. On any failure we log and + return False — message_post then attributes the reply to the service + account, which is honest. We deliberately do NOT fall back to the + ticket's customer: for an admin replying to a colleague's ticket that + would silently impersonate the customer.""" + email = ident['email'] + if not email: + return False + try: + pids = self._rpc(cfg, 'res.partner', 'search', + [[('email', '=ilike', escape_like(email))]], {'limit': 1}) + if pids: + return pids[0] + return self._rpc(cfg, 'res.partner', 'create', + [{'name': ident['name'], 'email': email}]) + except (xmlrpc.client.Fault, _RemoteError) as e: + _logger.warning( + 'fusion_helpdesk: could not resolve reply author for %s on ' + 'ticket %s (%s); posting as the service account.', + email, ticket.get('id'), e) + return False + + def _mark_ticket_seen(self, ticket_id, messages): + """Best-effort read-tracking. Runs AFTER the remote read/post, so it + must never raise — otherwise a local DB hiccup here would turn an + already-successful reply into a reported failure, and the user would + resubmit (posting a duplicate). Bookkeeping only; log and swallow.""" + if not messages: + return + try: + request.env['fusion.helpdesk.ticket.seen']._mark_seen( + ticket_id, max(m['id'] for m in messages)) + except Exception: # noqa: BLE001 — non-critical bookkeeping + _logger.exception( + 'fusion_helpdesk: mark-seen failed for ticket %s', ticket_id) + + def _remote_failure(self, cfg, err): + """Map a mid-RPC failure to the dialog's response shape.""" + if isinstance(err, _RemoteError): + return err.to_response() + if isinstance(err, (socket.timeout, OSError, ssl.SSLError)): + return _network_error_response(cfg['url'], err) + return {'ok': False, 'error': 'remote_error', + 'message': _('The central Helpdesk returned an error: %s' + ) % str(err)} + + # ================================================================== + # Embedded ticket inbox — endpoints (auth='user', server-side scoped) + # ================================================================== + @http.route('/fusion_helpdesk/my_tickets', + type='jsonrpc', auth='user', methods=['POST']) + def my_tickets(self, scope='mine'): + """List the caller's tickets (scoped). Admins may pass scope='all' + to see every ticket from their deployment.""" + ident = self._identity() + cfg = ident['cfg'] + if not self._config_ready(cfg): + return {'ok': False, 'error': 'config_missing', + 'message': _('Fusion Helpdesk is not configured.')} + view_all = ident['is_admin'] and scope == 'all' + domain = build_scope_domain(ident['label'], ident['email'], view_all) + try: + tickets = self._rpc( + cfg, 'helpdesk.ticket', 'search_read', [domain], + {'fields': ['id', 'name', 'stage_id', 'partner_id', + 'write_date', 'ticket_ref'], + 'order': 'write_date desc', 'limit': 100}) + msgs = self._ticket_messages(cfg, [t['id'] for t in tickets]) + except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError) as e: + return self._remote_failure(cfg, e) + + last_support = self._last_support_map(cfg, tickets, msgs) + ids = [t['id'] for t in tickets] + seen = request.env['fusion.helpdesk.ticket.seen']._seen_map(ids) + rows = [] + for t in tickets: + rid = t['id'] + ls = last_support.get(rid, 0) + rows.append({ + 'id': rid, + 'ref': t.get('ticket_ref') or str(rid), + 'subject': t['name'], + 'stage': t['stage_id'][1] if t['stage_id'] else '', + 'last_update': t['write_date'], + 'last_support_msg_id': ls, + 'has_unread': ls > (seen.get(rid, 0) or 0), + }) + return {'ok': True, 'tickets': rows, 'is_admin': ident['is_admin'], + 'unread': compute_unread_count(rows, seen)} + + @http.route('/fusion_helpdesk/ticket/', + type='jsonrpc', auth='user', methods=['POST']) + def ticket_detail(self, ticket_id, **kw): + """Full thread for one ticket — re-checks scope, hides internal notes, + marks the ticket seen for the badge.""" + ident = self._identity() + cfg = ident['cfg'] + if not self._config_ready(cfg): + return {'ok': False, 'error': 'config_missing', + 'message': _('Fusion Helpdesk is not configured.')} + domain = build_scope_domain( + ident['label'], ident['email'], ident['is_admin'] + ) + [('id', '=', ticket_id)] + try: + found = self._rpc(cfg, 'helpdesk.ticket', 'search_read', [domain], + {'fields': ['id', 'name', 'stage_id', + 'access_token'], 'limit': 1}) + if not found: + return {'ok': False, 'error': 'not_found', + 'message': _('Ticket not found or not accessible.')} + messages = self._public_messages(cfg, ticket_id) + except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError) as e: + return self._remote_failure(cfg, e) + self._mark_ticket_seen(ticket_id, messages) + t = found[0] + # Magic link: the customer's own access-token URL on central, so they + # can open the full ticket (incl. attachments) in the portal if needed. + portal_url = '' + if t.get('access_token'): + portal_url = '%s/my/ticket/%s/%s' % ( + cfg['url'].rstrip('/'), t['id'], t['access_token']) + return {'ok': True, 'ticket': { + 'id': t['id'], 'subject': t['name'], + 'stage': t['stage_id'][1] if t['stage_id'] else '', + 'portal_url': portal_url, + 'messages': messages}} + + @http.route('/fusion_helpdesk/ticket//reply', + type='jsonrpc', auth='user', methods=['POST']) + def ticket_reply(self, ticket_id, body=None, **kw): + """Post a customer reply on a scoped ticket, attributed to the replier.""" + ident = self._identity() + cfg = ident['cfg'] + text = (body or '').strip() + if not text: + return {'ok': False, 'error': 'empty', + 'message': _('Your reply is empty.')} + if not self._config_ready(cfg): + return {'ok': False, 'error': 'config_missing', + 'message': _('Fusion Helpdesk is not configured.')} + domain = build_scope_domain( + ident['label'], ident['email'], ident['is_admin'] + ) + [('id', '=', ticket_id)] + try: + found = self._rpc(cfg, 'helpdesk.ticket', 'search_read', [domain], + {'fields': ['id', 'partner_id'], 'limit': 1}) + if not found: + return {'ok': False, 'error': 'not_found', + 'message': _('Ticket not found or not accessible.')} + author_id = self._resolve_author(cfg, ident, found[0]) + # We escape the user's text ourselves, then mark it up as paragraphs. + # message_post() ESCAPES a plain str body (it expects a Markup for + # HTML) — but Markup can't cross XML-RPC, so we pass body_is_html=True + # which tells the remote message_post to treat our already-escaped + # HTML as Markup. Without this the customer would see literal

tags. + html = '

%s

' % _html_escape(text).replace('\n', '
') + self._rpc(cfg, 'helpdesk.ticket', 'message_post', [[ticket_id]], { + 'body': html, 'body_is_html': True, 'message_type': 'comment', + 'subtype_xmlid': 'mail.mt_comment', 'author_id': author_id, + }) + messages = self._public_messages(cfg, ticket_id) + except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError) as e: + return self._remote_failure(cfg, e) + self._mark_ticket_seen(ticket_id, messages) + return {'ok': True, 'messages': messages} + + @http.route('/fusion_helpdesk/unread_count', + type='jsonrpc', auth='user', methods=['POST']) + def unread_count(self): + """Badge count: tickets with a support reply newer than last-seen. + Always scoped to the caller's OWN tickets (never the admin-all view).""" + ident = self._identity() + cfg = ident['cfg'] + if not self._config_ready(cfg): + return {'ok': True, 'count': 0} + domain = build_scope_domain(ident['label'], ident['email'], False) + try: + tickets = self._rpc(cfg, 'helpdesk.ticket', 'search_read', [domain], + {'fields': ['id', 'partner_id'], 'limit': 100}) + msgs = self._ticket_messages(cfg, [t['id'] for t in tickets]) + except (_RemoteError, xmlrpc.client.Fault, OSError, ssl.SSLError): + return {'ok': True, 'count': 0} # badge must never break the systray + last_support = self._last_support_map(cfg, tickets, msgs) + rows = [{'id': k, 'last_support_msg_id': v} + for k, v in last_support.items()] + seen = request.env['fusion.helpdesk.ticket.seen']._seen_map( + list(last_support.keys())) + return {'ok': True, 'count': compute_unread_count(rows, seen)} + def _html_escape(s): return ( diff --git a/fusion_helpdesk/models/__init__.py b/fusion_helpdesk/models/__init__.py index 6084d2ca..e4c753a3 100644 --- a/fusion_helpdesk/models/__init__.py +++ b/fusion_helpdesk/models/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import res_config_settings +from . import fusion_helpdesk_ticket_seen diff --git a/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py b/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py new file mode 100644 index 00000000..9516623e --- /dev/null +++ b/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +"""Per-user read-tracking for the embedded ticket inbox. + +Stores ONLY metadata — which central ticket a user has seen and up to +which message id. No ticket content is replicated locally; this exists +purely so the systray unread badge can work without re-fetching the +whole inbox on every page load. Tickets themselves remain a live RPC +view of the central Odoo. +""" +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, ondelete='cascade', + default=lambda self: self.env.uid, + ) + central_ticket_id = fields.Integer( + string='Central Ticket ID', required=True, index=True, + help='helpdesk.ticket id on the central Odoo.', + ) + last_seen_message_id = fields.Integer( + string='Last Seen Message ID', default=0, + help='Highest central mail.message id this user has viewed for ' + 'the ticket. Drives the unread badge.', + ) + + _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): + """Upsert the current user's last-seen marker for a ticket. + + Monotonic — never moves the marker backwards (a stale client + reporting an older id can't resurrect an unread badge).""" + rec = self.search([ + ('user_id', '=', self.env.uid), + ('central_ticket_id', '=', central_ticket_id), + ], limit=1) + if rec: + if (last_message_id or 0) > 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 or 0, + }) + return True + + @api.model + def _seen_map(self, central_ticket_ids): + """Return {central_ticket_id: last_seen_message_id} for the + current user across the given ticket ids.""" + rows = self.search([ + ('user_id', '=', self.env.uid), + ('central_ticket_id', 'in', list(central_ticket_ids)), + ]) + return {r.central_ticket_id: r.last_seen_message_id for r in rows} diff --git a/fusion_helpdesk/security/fusion_helpdesk_groups.xml b/fusion_helpdesk/security/fusion_helpdesk_groups.xml new file mode 100644 index 00000000..cf12ed69 --- /dev/null +++ b/fusion_helpdesk/security/fusion_helpdesk_groups.xml @@ -0,0 +1,18 @@ + + + + + + Helpdesk Reporter Admin + Can view all tickets filed from this deployment in the in-app helpdesk inbox. + + diff --git a/fusion_helpdesk/security/ir.model.access.csv b/fusion_helpdesk/security/ir.model.access.csv index 97dd8b91..eea1f417 100644 --- a/fusion_helpdesk/security/ir.model.access.csv +++ b/fusion_helpdesk/security/ir.model.access.csv @@ -1 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1 diff --git a/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js b/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js index 486e5351..fecd5cc1 100644 --- a/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js +++ b/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js @@ -1,13 +1,20 @@ /** @odoo-module **/ -// Fusion Helpdesk — submission dialog. Lets the user pick Bug or -// Feature, fill in subject + description, paste an error code, attach -// files, and capture a screenshot via the browser's getDisplayMedia -// API. On submit, the payload is POSTed to /fusion_helpdesk/submit -// which forwards it (XML-RPC) to a central Odoo Helpdesk. +// Fusion Helpdesk — submission + follow-up dialog. +// +// Two tabs: +// • New — report a bug / request a feature (the original form), +// plus a confirmed "Your email" field so support can reply. +// • My Tickets — a live RPC view of the user's tickets on the central +// Odoo: list → open one → read support's replies → reply +// inline, without ever leaving this Odoo or logging in. +// +// Tickets are NOT copied locally — every list/thread/reply is a live call +// to the central Helpdesk, scoped server-side to the logged-in user. -import { Component, useState } from "@odoo/owl"; +import { Component, useState, onWillStart } from "@odoo/owl"; import { Dialog } from "@web/core/dialog/dialog"; import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; import { useService } from "@web/core/utils/hooks"; import { _t } from "@web/core/l10n/translation"; @@ -18,16 +25,20 @@ export class FusionHelpdeskDialog extends Component { static components = { Dialog }; static props = { close: Function, + initialTab: { type: String, optional: true }, }; setup() { this.notification = useService("notification"); this.state = useState({ - kind: "bug", // 'bug' | 'feature' + tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread' + // ---- New report ---- + kind: "bug", subject: "", description: "", errorCode: "", - attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}] + replyEmail: user.login || "", + attachments: [], capturing: false, submitting: false, error: "", @@ -35,21 +46,146 @@ export class FusionHelpdeskDialog extends Component { ticketId: null, ticketUrl: "", attached: 0, + failed: 0, + // ---- My Tickets ---- + isAdmin: false, + scope: "mine", // 'mine' | 'all' + tickets: [], + loadingList: false, + listError: "", + // ---- Thread ---- + current: null, // {id, subject, stage, portal_url, messages} + loadingThread: false, + threadError: "", + replyBody: "", + sendingReply: false, + }); + + onWillStart(async () => { + if (this.state.tab === "list") { + await this.loadList(); + } }); } get dialogTitle() { - return this.state.kind === "bug" - ? _t("Report a Bug") - : _t("Request a Feature"); + if (this.state.tab === "thread" && this.state.current) { + return this.state.current.subject; + } + if (this.state.tab === "list") { + return _t("My Tickets"); + } + return this.state.kind === "bug" ? _t("Report a Bug") : _t("Request a Feature"); + } + + // ------------------------------------------------------------------ + // Tabs + async setTab(tab) { + this.state.tab = tab; + this.state.error = ""; + if (tab === "list") { + await this.loadList(); + } } setKind(kind) { this.state.kind = kind; } - // ------------------------------------------------------------------ - // File input → b64 + // ================================================================== + // My Tickets — list + // ================================================================== + async loadList() { + if (this.state.loadingList) return; + this.state.loadingList = true; + this.state.listError = ""; + try { + const res = await rpc("/fusion_helpdesk/my_tickets", { + scope: this.state.scope, + }); + if (!res.ok) { + this.state.listError = res.message || _t("Could not load your tickets."); + this.state.tickets = []; + } else { + this.state.tickets = res.tickets || []; + this.state.isAdmin = !!res.is_admin; + } + } catch (err) { + console.error("fusion_helpdesk: my_tickets failed", err); + this.state.listError = (err && err.message) || _t("Network error."); + } finally { + this.state.loadingList = false; + } + } + + async setScope(scope) { + if (this.state.scope === scope) return; + this.state.scope = scope; + await this.loadList(); + } + + // ================================================================== + // My Tickets — thread + // ================================================================== + async openTicket(ticketId) { + this.state.loadingThread = true; + this.state.threadError = ""; + this.state.replyBody = ""; + try { + const res = await rpc(`/fusion_helpdesk/ticket/${ticketId}`, {}); + if (!res.ok) { + this.state.threadError = res.message || _t("Could not open this ticket."); + return; + } + this.state.current = res.ticket; + this.state.tab = "thread"; + // The ticket is now seen server-side; clear its unread flag locally. + const row = this.state.tickets.find((t) => t.id === ticketId); + if (row) { + row.has_unread = false; + } + } catch (err) { + console.error("fusion_helpdesk: open ticket failed", err); + this.state.threadError = (err && err.message) || _t("Network error."); + } finally { + this.state.loadingThread = false; + } + } + + async backToList() { + this.state.current = null; + this.state.tab = "list"; + await this.loadList(); // refresh stages / unread after viewing + } + + async sendReply() { + const body = (this.state.replyBody || "").trim(); + if (!body || this.state.sendingReply || !this.state.current) return; + this.state.sendingReply = true; + this.state.threadError = ""; + try { + const res = await rpc( + `/fusion_helpdesk/ticket/${this.state.current.id}/reply`, + { body } + ); + if (!res.ok) { + this.state.threadError = res.message || _t("Could not send your reply."); + } else { + this.state.current.messages = res.messages || this.state.current.messages; + this.state.replyBody = ""; + this.notification.add(_t("Reply sent."), { type: "success" }); + } + } catch (err) { + console.error("fusion_helpdesk: send reply failed", err); + this.state.threadError = (err && err.message) || _t("Network error."); + } finally { + this.state.sendingReply = false; + } + } + + // ================================================================== + // New report — files / screenshot (unchanged behaviour) + // ================================================================== async onFilesPicked(ev) { const files = Array.from(ev.target.files || []); for (const f of files) { @@ -75,7 +211,6 @@ export class FusionHelpdeskDialog extends Component { ); } } - // Reset the input so picking the same file again re-fires onchange. ev.target.value = ""; } @@ -92,8 +227,6 @@ export class FusionHelpdeskDialog extends Component { }); } - // ------------------------------------------------------------------ - // Screenshot capture via getDisplayMedia async onTakeScreenshot() { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { this.notification.add( @@ -119,7 +252,6 @@ export class FusionHelpdeskDialog extends Component { rawSize: blob.size, }); } catch (err) { - // User cancelled the picker — silently swallow. Other errors → notify. if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") { this.notification.add( _t("Screenshot failed: %s").replace("%s", err.message || err), @@ -138,7 +270,6 @@ export class FusionHelpdeskDialog extends Component { const video = document.createElement("video"); video.srcObject = stream; await video.play(); - // Give the browser one frame to settle the picker chrome. await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; @@ -195,8 +326,9 @@ export class FusionHelpdeskDialog extends Component { return "fa fa-file-o"; } - // ------------------------------------------------------------------ - // Submit + // ================================================================== + // New report — submit + // ================================================================== async onSubmit() { if (this.state.submitting) return; const subject = (this.state.subject || "").trim(); @@ -213,6 +345,7 @@ export class FusionHelpdeskDialog extends Component { subject, description: this.state.description || "", error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "", + reply_email: (this.state.replyEmail || "").trim(), attachments: this.state.attachments.map((a) => ({ name: a.name, mimetype: a.mimetype, @@ -229,13 +362,14 @@ export class FusionHelpdeskDialog extends Component { this.state.ticketId = res.ticket_id; this.state.ticketUrl = res.ticket_url; this.state.attached = res.attached || 0; - // Reset the editable fields so user can file another if they want. + this.state.failed = res.failed || 0; this.state.subject = ""; this.state.description = ""; this.state.errorCode = ""; this.state.attachments = []; } } catch (err) { + console.error("fusion_helpdesk: submit failed", err); this.state.error = (err && err.message) || _t("Network error."); } finally { this.state.submitting = false; diff --git a/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js b/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js index 16761db7..15ca7ccd 100644 --- a/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js +++ b/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js @@ -1,24 +1,52 @@ /** @odoo-module **/ -// Fusion Helpdesk — top systray icon. Sequence chosen so the icon -// appears to the LEFT of the attendance check-in button. Odoo -// systray ordering is by sequence ascending (lower = leftmost in the -// systray bar). hr_attendance ships at sequence 100, so we use 99. +// Fusion Helpdesk — top systray icon with an unread-reply badge. +// Sequence 99 places it just left of the attendance check-in button. -import { Component } from "@odoo/owl"; +import { Component, useState, onWillStart, onWillUnmount } from "@odoo/owl"; import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog"; +const POLL_MS = 120000; // refresh the unread badge every 2 minutes + class FusionHelpdeskSystray extends Component { static template = "fusion_helpdesk.SystrayItem"; static props = {}; setup() { this.dialog = useService("dialog"); + this.state = useState({ unread: 0 }); + + onWillStart(async () => { + await this._refreshUnread(); + }); + + // Poll so a reply that lands while the user is working still + // surfaces without a page reload. Errors are swallowed server-side + // (the endpoint always returns a count) so the badge never breaks. + this._timer = setInterval(() => this._refreshUnread(), POLL_MS); + onWillUnmount(() => clearInterval(this._timer)); + } + + async _refreshUnread() { + try { + const res = await rpc("/fusion_helpdesk/unread_count", {}); + this.state.unread = (res && res.count) || 0; + } catch { + // Network/config hiccup — leave the badge as-is, don't throw. + } } onClick() { - this.dialog.add(FusionHelpdeskDialog, {}); + // If there are unread replies, drop straight into the inbox; + // otherwise open the New report form (the primary action). + const initialTab = this.state.unread > 0 ? "list" : "new"; + this.dialog.add( + FusionHelpdeskDialog, + { initialTab }, + { onClose: () => this._refreshUnread() } + ); } } diff --git a/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss b/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss index 0fa58361..45f1036d 100644 --- a/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss +++ b/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss @@ -170,3 +170,170 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex); &:hover { color: #d32f2f; } } } + +// Systray unread badge +.o_fhd_systray { + .o_fhd_systray_btn { position: relative; } + + .o_fhd_systray_badge { + position: absolute; + top: -2px; + right: 0; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background-color: #d9534f; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + line-height: 16px; + text-align: center; + } +} + +// Inbox additions (tabs, list, thread) — share the dialog tokens above. +.o_fhd_dialog { + .o_fhd_muted { color: $fhd-muted; } + + .o_fhd_tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid $fhd-border; + margin-bottom: 1rem; + } + + .o_fhd_tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 0.5rem 0.9rem; + color: $fhd-muted; + cursor: pointer; + font-weight: 500; + + &:hover { color: $fhd-text; } + + &.o_fhd_tab_active { + color: $fhd-accent; + border-bottom-color: $fhd-accent; + } + } + + .o_fhd_scope_row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.85rem; + } + + // Ticket list + .o_fhd_ticket_list { + display: flex; + flex-direction: column; + border: 1px solid $fhd-border; + border-radius: 6px; + overflow: hidden; + } + + .o_fhd_ticket_row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + background-color: $fhd-bg; + border-bottom: 1px solid $fhd-border; + cursor: pointer; + + &:last-child { border-bottom: none; } + &:hover { background-color: $fhd-hover; } + } + + .o_fhd_unread_dot { + width: 9px; + height: 9px; + border-radius: 50%; + background-color: $fhd-accent; + flex: 0 0 auto; + } + + .o_fhd_unread_spacer { width: 9px; flex: 0 0 auto; } + + .o_fhd_ticket_ref { + color: $fhd-muted; + font-variant-numeric: tabular-nums; + flex: 0 0 auto; + } + + .o_fhd_ticket_subject { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .o_fhd_ticket_stage { + flex: 0 0 auto; + font-size: 0.78rem; + padding: 0.1rem 0.5rem; + border-radius: 10px; + background-color: $fhd-hover; + border: 1px solid $fhd-border; + color: $fhd-muted; + } + + // Thread + .o_fhd_thread_head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.6rem; + } + + .o_fhd_open_portal { + font-size: 0.85rem; + color: $fhd-accent; + text-decoration: none; + &:hover { text-decoration: underline; } + } + + .o_fhd_thread { + display: flex; + flex-direction: column; + gap: 0.6rem; + max-height: 45vh; + overflow-y: auto; + padding: 0.25rem; + } + + .o_fhd_msg { + border: 1px solid $fhd-border; + border-radius: 6px; + padding: 0.6rem 0.75rem; + background-color: $fhd-bg; + } + + .o_fhd_msg_head { + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.3rem; + font-size: 0.82rem; + } + + .o_fhd_msg_author { font-weight: 600; color: $fhd-text; } + .o_fhd_msg_date { color: $fhd-muted; font-variant-numeric: tabular-nums; } + + .o_fhd_msg_body { + color: $fhd-text; + font-size: 0.9rem; + word-break: break-word; + + p:last-child { margin-bottom: 0; } + } + + .o_fhd_msg_attach { + margin-top: 0.4rem; + font-size: 0.8rem; + color: $fhd-muted; + } +} diff --git a/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml b/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml index f53497f8..5a45fec2 100644 --- a/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml +++ b/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml @@ -4,105 +4,225 @@
- -
- -
- -
- - -
- - -
-