Compare commits

...

10 Commits

Author SHA1 Message Date
gsinghpal
fb345be375 fix(fusion_helpdesk): close IDOR + reliability gaps from code review
- P0 IDOR: escape LIKE wildcards in build_scope_domain + normalise emails (email_normalize) on both write and scope sides, so a self-set email of '%' can no longer match every ticket in a deployment (+2 regression tests).
- Convert XML-RPC ProtocolError (central 502/503/429) to _RemoteError instead of a raw 500.
- _resolve_author: log + post as service account on failure instead of silently impersonating the ticket customer.
- _mark_seen now best-effort (runs after the remote post) so a bookkeeping failure can't report a successful reply as failed (no duplicate replies).
- Attachment loop catches network errors + reports failed count (no duplicate-ticket trap); dialog surfaces failures.
- console.error in JS catches for diagnosability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:53:37 -04:00
gsinghpal
6ce49573ef fix(fusion_helpdesk): reply body_is_html=True so HTML isn't escaped over XML-RPC
Remote message_post escapes a plain str body (expects Markup, which can't cross XML-RPC). Without body_is_html=True the customer's reply rendered literal <p>/<br> tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:41:04 -04:00
gsinghpal
9dceff5baf feat(fusion_helpdesk_central): client-label field, views, branded ack email
x_fc_client_label on helpdesk.ticket (+ list/search exposure), create() override sends a branded acknowledgement (magic link via get_portal_url) for in-app-channel tickets only. 3 Enterprise tests (run on nexa).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:38:39 -04:00
gsinghpal
b6e7ad3485 feat(fusion_helpdesk): embedded ticket inbox UI + unread badge
Dialog gains New/My Tickets tabs, ticket list (with admin Mine/All toggle + unread dots), thread view with inline reply, confirmed reply-email field, and an Open-full-ticket magic link. Systray shows an unread-reply badge (polled). Controller returns portal_url + per-message attachment counts. Assets compile clean (light+dark).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:32:48 -04:00
gsinghpal
304e19ce45 feat(fusion_helpdesk): identity keystone in submit + scoped inbox endpoints
submit() now sends partner_email/partner_name/x_fc_client_label. New jsonrpc endpoints my_tickets/ticket_detail/ticket_reply/unread_count, all auth=user with server-side scoping (build_scope_domain), internal-note filtering, and graceful remote-failure handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:28:15 -04:00
gsinghpal
96b3441997 fix(billing): resolve code-review findings (authz, cross-billing, validation, webhook integrity)
- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
       controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
       caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints

Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:27:34 -04:00
gsinghpal
33fc046fa2 feat(fusion_helpdesk): identity keystone helpers, seen model, admin group
Phase 1-2: build_ticket_vals/scope/unread helpers (utils.py), fusion.helpdesk.ticket.seen read-tracking model, group_reporter_admin. 10 unit tests pass on local Community.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:23:31 -04:00
gsinghpal
960fe67966 docs(helpdesk): TDD implementation plan (16 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:19:24 -04:00
gsinghpal
2899e43d84 docs(helpdesk): customer follow-up + embedded ticket inbox design
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:11:36 -04:00
gsinghpal
e71cae6dc9 feat(billing): usage-rating + webhook-dispatch crons
- SaleOrder._fc_rate_usage: aggregates usage, computes overage via
  charge._compute_billable, upserts sale.order.line for the overage product
- FusionBillingUsage._cron_rate_open_periods: hourly cron iterates active
  charges × in-progress subscriptions, calls _fc_rate_usage
- data/ir_cron.xml: two crons — rate usage (hourly), dispatch webhooks (2 min)
- __manifest__.py: registers data/ir_cron.xml in data list
- test_usage.py: test_rate_open_period_creates_overage_line (TDD, FCB_EXIT=0)

Reference: _create_recurring_invoice / _get_invoiceable_lines confirmed in
Enterprise sale_subscription/models/sale_order.py — overage line goes onto
sale.order so native invoicing picks it up via _get_invoiceable_lines.
2026-05-27 03:08:45 -04:00
38 changed files with 2791 additions and 158 deletions

View File

@@ -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. ✓

View File

@@ -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` ≈ L564572).
- **`create()` subscribes the customer as a follower** (the "make customer follower" loop, ≈ L600620),
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 (≈L564572) + follower subscription
(≈L600620).
- `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`.

View File

@@ -47,6 +47,7 @@ reference files from the container before implementing subscription/account inte
], ],
"data": [ "data": [
"security/ir.model.access.csv", "security/ir.model.access.csv",
"data/ir_cron.xml",
], ],
"installable": True, "installable": True,
"application": False, "application": False,

View File

@@ -56,7 +56,10 @@ class FusionBillingApi(http.Controller):
payload = self._read_json() payload = self._read_json()
if payload is None: if payload is None:
return self._json({"error": "invalid json"}, status=400) return self._json({"error": "invalid json"}, status=400)
return self._json(service._api_upsert_customer(payload)) result = service._api_upsert_customer(payload)
if result.get("status") == "error":
return self._json(result, status=400)
return self._json(result)
@http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False) @http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False)
def post_usage(self, **kw): def post_usage(self, **kw):
@@ -66,7 +69,10 @@ class FusionBillingApi(http.Controller):
payload = self._read_json() payload = self._read_json()
if payload is None: if payload is None:
return self._json({"error": "invalid json"}, status=400) return self._json({"error": "invalid json"}, status=400)
return self._json(service._api_record_usage(payload), status=202) result = service._api_record_usage(payload)
if result.get("status") == "error":
return self._json(result, status=400)
return self._json(result, status=202)
@http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False) @http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False)
def get_plans(self, **kw): def get_plans(self, **kw):
@@ -83,4 +89,7 @@ class FusionBillingApi(http.Controller):
payload = self._read_json() payload = self._read_json()
if payload is None: if payload is None:
return self._json({"error": "invalid json"}, status=400) return self._json({"error": "invalid json"}, status=400)
return self._json(service._api_create_subscription(payload)) result = service._api_create_subscription(payload)
if result.get("status") == "error":
return self._json(result, status=400)
return self._json(result)

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fc_rate_usage" model="ir.cron">
<field name="name">Fusion Billing: Rate usage before invoicing</field>
<field name="model_id" ref="model_fusion_billing_usage"/>
<field name="state">code</field>
<field name="code">model._cron_rate_open_periods()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
</record>
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
<field name="model_id" ref="model_fusion_billing_webhook"/>
<field name="state">code</field>
<field name="code">model._cron_dispatch()</field>
<field name="interval_number">2</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -5,3 +5,4 @@ from . import charge
from . import usage from . import usage
from . import webhook from . import webhook
from . import reconciliation from . import reconciliation
from . import sale_order

View File

@@ -43,23 +43,30 @@ class FusionBillingCharge(models.Model):
charge_model = fields.Selection( charge_model = fields.Selection(
[ [
("standard", "Standard (per unit)"), ("standard", "Standard (per unit)"),
("graduated", "Graduated"),
("package", "Package"), ("package", "Package"),
("volume", "Volume"),
], ],
default="standard", required=True, default="standard", required=True,
) )
currency_id = fields.Many2one( currency_id = fields.Many2one(
"res.currency", default=lambda self: self.env.company.currency_id, "res.currency", required=True,
default=lambda self: self.env.company.currency_id,
) )
active = fields.Boolean(default=True) active = fields.Boolean(default=True)
_price_non_negative = models.Constraint(
"CHECK (price_per_unit >= 0)", "Overage price per unit cannot be negative.",
)
_unit_batch_positive = models.Constraint(
"CHECK (unit_batch > 0)", "Unit batch must be greater than zero.",
)
def _compute_billable(self, total_quantity): def _compute_billable(self, total_quantity):
"""Return (overage_units, amount) for total period usage under this charge. """Return (overage_units, amount) for total period usage under this charge.
- overage_units = usage above included_quota (never negative) - overage_units = usage above included_quota (never negative)
- 'standard'/'package'/'volume': priced per `unit_batch` block, partial block rounds up. - 'standard': price the overage in (rounded-up) `unit_batch` blocks.
(graduated tiers are out of scope for the core; treated as 'standard'.) - 'package': price whole packages over the RAW quantity (quota ignored for
package counting); a partial package rounds up.
""" """
self.ensure_one() self.ensure_one()
overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0)) overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0))
@@ -68,6 +75,6 @@ class FusionBillingCharge(models.Model):
# whole packages over the RAW quantity (quota ignored for package counting) # whole packages over the RAW quantity (quota ignored for package counting)
blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0 blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0
return overage, round(blocks * (self.price_per_unit or 0.0), 2) return overage, round(blocks * (self.price_per_unit or 0.0), 2)
# standard / volume / graduated-fallback: price the overage in (rounded-up) batches # standard: price the overage in (rounded-up) batches
blocks = math.ceil(overage / batch) if overage > 0 else 0 blocks = math.ceil(overage / batch) if overage > 0 else 0
return overage, round(blocks * (self.price_per_unit or 0.0), 2) return overage, round(blocks * (self.price_per_unit or 0.0), 2)

View File

@@ -25,7 +25,8 @@ class FusionBillingReconciliation(models.Model):
external_amount = fields.Monetary(string="App-actual Amount") external_amount = fields.Monetary(string="App-actual Amount")
delta = fields.Monetary(help="odoo_amount - external_amount.") delta = fields.Monetary(help="odoo_amount - external_amount.")
currency_id = fields.Many2one( currency_id = fields.Many2one(
"res.currency", default=lambda self: self.env.company.currency_id, "res.currency", required=True,
default=lambda self: self.env.company.currency_id,
) )
status = fields.Selection( status = fields.Selection(
[ [

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = "sale.order"
def _fc_rate_usage(self, charge, period_start, period_end):
"""Aggregate this subscription's usage for `charge`'s metric in the period,
compute the overage amount, and upsert a matching overage order line.
Returns the amount.
A zero amount never *creates* a new line (no $0.00 overage clutter); if a
line already exists it is still updated so a dropped-to-zero overage clears.
"""
self.ensure_one()
Usage = self.env['fusion.billing.usage']
total = Usage._aggregate(self, charge.metric_id, period_start, period_end)
_overage, amount = charge._compute_billable(total)
if charge.product_id:
line = self.order_line.filtered(lambda l: l.product_id == charge.product_id)
if not line and amount == 0:
return amount
vals = {'product_uom_qty': 1, 'price_unit': amount}
if line:
line.write(vals)
else:
self.env['sale.order.line'].create(
{'order_id': self.id, 'product_id': charge.product_id.id, **vals})
return amount

View File

@@ -2,9 +2,12 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 # License OPL-1
import hashlib import hashlib
import ipaddress
import secrets import secrets
from urllib.parse import urlparse
from odoo import api, fields, models from odoo import api, fields, models
from odoo.exceptions import ValidationError
class FusionBillingService(models.Model): class FusionBillingService(models.Model):
@@ -45,6 +48,33 @@ class FusionBillingService(models.Model):
for rec in self: for rec in self:
rec.account_link_count = len(rec.account_link_ids) rec.account_link_count = len(rec.account_link_ids)
@api.constrains("webhook_url")
def _check_webhook_url(self):
"""Reject SSRF-prone webhook targets: a non-empty URL must be https and must
not point at localhost or a private / link-local / loopback IP literal. Empty
is allowed (no webhook configured)."""
for rec in self:
url = (rec.webhook_url or "").strip()
if not url:
continue
parsed = urlparse(url)
if parsed.scheme != "https":
raise ValidationError("Webhook URL must use https.")
host = parsed.hostname or ""
if not host or host.lower() in ("localhost", "ip6-localhost", "ip6-loopback"):
raise ValidationError(
"Webhook URL must not target localhost or a private address.")
try:
ip = ipaddress.ip_address(host)
except ValueError:
ip = None
if ip is not None and (
ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_unspecified or ip.is_multicast
):
raise ValidationError(
"Webhook URL must not target a private or loopback address.")
def action_generate_api_key(self): def action_generate_api_key(self):
"""Generate a fresh bearer key, store only its hash, return the raw key. """Generate a fresh bearer key, store only its hash, return the raw key.
@@ -64,7 +94,14 @@ class FusionBillingService(models.Model):
return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1) return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1)
def _api_upsert_customer(self, payload): def _api_upsert_customer(self, payload):
"""Resolve/create the partner link for an external account.
Defensive: a non-dict payload or a missing/empty ``external_id`` returns a
4xx-shaped error instead of raising (C3).
"""
self.ensure_one() self.ensure_one()
if not isinstance(payload, dict):
return {'status': 'error', 'error': 'invalid payload'}
ext = payload.get('external_id') ext = payload.get('external_id')
if not ext: if not ext:
return {'status': 'error', 'error': 'external_id required'} return {'status': 'error', 'error': 'external_id required'}
@@ -73,15 +110,53 @@ class FusionBillingService(models.Model):
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext} return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
def _api_record_usage(self, payload): def _api_record_usage(self, payload):
"""Ingest a batch of usage events.
Authorization (C2/C4): each event must target a subscription sale.order that
(a) exists, (b) is actually a subscription, and (c) belongs to a customer THIS
service is linked to. Any failing event is rejected and stops processing for
that event without writing a usage row.
Validation (C3): a non-dict payload, a non-list ``events``, missing required
keys, or non-numeric ``quantity``/ids return a 4xx-shaped error instead of
raising (no 500s).
"""
self.ensure_one() self.ensure_one()
events = payload.get('events') or [] if not isinstance(payload, dict):
return {'status': 'error', 'error': 'invalid payload'}
events = payload.get('events')
if events is None:
events = []
if not isinstance(events, list):
return {'status': 'error', 'error': 'events must be a list'}
Usage = self.env['fusion.billing.usage'] Usage = self.env['fusion.billing.usage']
linked_partners = self.account_link_ids.mapped('partner_id')
accepted = 0 accepted = 0
for ev in events: for ev in events:
sub = self.env['sale.order'].browse(int(ev['subscription_external_id'])) if not isinstance(ev, dict):
return {'status': 'error', 'error': 'invalid event'}
for key in ('subscription_external_id', 'metric_code', 'quantity',
'period_start', 'period_end'):
if ev.get(key) in (None, ''):
return {'status': 'error', 'error': 'missing %s' % key}
try:
sub_id = int(ev['subscription_external_id'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid subscription_external_id'}
try:
quantity = float(ev['quantity'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid quantity'}
sub = self.env['sale.order'].browse(sub_id)
if not sub.exists() or not sub.is_subscription \
or sub.partner_id not in linked_partners:
return {'status': 'error', 'error': 'unknown subscription'}
try:
Usage._record_usage( Usage._record_usage(
sub, ev['metric_code'], float(ev['quantity']), sub, ev['metric_code'], quantity,
ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key')) ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key'))
except ValueError as e:
return {'status': 'error', 'error': str(e)}
accepted += 1 accepted += 1
return {'status': 'ok', 'accepted': accepted} return {'status': 'ok', 'accepted': accepted}
@@ -100,21 +175,49 @@ class FusionBillingService(models.Model):
The product on each line must have recurring_invoice=True so that The product on each line must have recurring_invoice=True so that
Odoo recognises the order as a subscription with has_recurring_line and Odoo recognises the order as a subscription with has_recurring_line and
action_confirm() reaches subscription_state='3_progress'. action_confirm() reaches subscription_state='3_progress'.
Validation (C3): a non-dict payload, a missing/unknown customer, a missing
``plan_id``, a non-list ``lines``, or a non-numeric product id/quantity
return a 4xx-shaped error instead of raising (no 500s).
""" """
self.ensure_one() self.ensure_one()
if not isinstance(payload, dict):
return {'status': 'error', 'error': 'invalid payload'}
if not payload.get('external_customer_id'):
return {'status': 'error', 'error': 'external_customer_id required'}
if not payload.get('plan_id'):
return {'status': 'error', 'error': 'plan_id required'}
try:
plan_id = int(payload['plan_id'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid plan_id'}
link = self.env['fusion.billing.account.link'].search([ link = self.env['fusion.billing.account.link'].search([
('service_id', '=', self.id), ('service_id', '=', self.id),
('external_id', '=', payload.get('external_customer_id')), ('external_id', '=', payload.get('external_customer_id')),
], limit=1) ], limit=1)
if not link: if not link:
return {'status': 'error', 'error': 'unknown customer'} return {'status': 'error', 'error': 'unknown customer'}
order_lines = [(0, 0, { lines = payload.get('lines')
'product_id': line['product_id'], if lines is None:
'product_uom_qty': line.get('quantity', 1), lines = []
}) for line in payload.get('lines', [])] if not isinstance(lines, list):
return {'status': 'error', 'error': 'lines must be a list'}
order_lines = []
for line in lines:
if not isinstance(line, dict) or line.get('product_id') in (None, ''):
return {'status': 'error', 'error': 'invalid line'}
try:
product_id = int(line['product_id'])
quantity = float(line.get('quantity', 1))
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid line'}
order_lines.append((0, 0, {
'product_id': product_id,
'product_uom_qty': quantity,
}))
sub = self.env['sale.order'].sudo().create({ sub = self.env['sale.order'].sudo().create({
'partner_id': link.partner_id.id, 'partner_id': link.partner_id.id,
'plan_id': payload['plan_id'], 'plan_id': plan_id,
'order_line': order_lines, 'order_line': order_lines,
}) })
sub.action_confirm() sub.action_confirm()

View File

@@ -35,12 +35,14 @@ class FusionBillingUsage(models.Model):
) )
_idempotency_uniq = models.Constraint( _idempotency_uniq = models.Constraint(
"unique(idempotency_key)", "Usage idempotency key must be unique.", "unique(subscription_id, metric_id, idempotency_key)",
"Usage idempotency key must be unique per subscription and metric.",
) )
@api.model @api.model
def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None): def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None):
"""Upsert one aggregated usage row. Same idempotency key updates in place (no double-count).""" """Upsert one aggregated usage row. Same idempotency key (scoped to the same
subscription + metric) updates in place (no double-count)."""
metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1) metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1)
if not metric: if not metric:
raise ValueError("Unknown metric code: %s" % metric_code) raise ValueError("Unknown metric code: %s" % metric_code)
@@ -53,21 +55,55 @@ class FusionBillingUsage(models.Model):
'idempotency_key': idem, 'idempotency_key': idem,
} }
if idem: if idem:
existing = self.search([('idempotency_key', '=', idem)], limit=1) existing = self.search([
('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id),
('idempotency_key', '=', idem),
], limit=1)
if existing: if existing:
existing.write({'quantity': quantity}) existing.write({'quantity': quantity})
return existing return existing
return self.create(vals) return self.create(vals)
@api.model
def _cron_rate_open_periods(self):
"""Hourly cron: for every active charge, aggregate usage and upsert overage lines
on the in-progress subscriptions that are on the charge's own plan.
A charge only rates subscriptions whose ``plan_id`` matches the charge's
``plan_id`` — never every subscription against every charge (C1/H4). The
billing-period window is the subscription's real open period
``[last_invoice_date or start_date, next_invoice_date)`` (H1)."""
Charge = self.env['fusion.billing.charge'].search([('active', '=', True)])
SaleOrder = self.env['sale.order']
for charge in Charge:
if not charge.plan_id:
continue
subs = SaleOrder.search([
('is_subscription', '=', True),
('subscription_state', '=', '3_progress'),
('plan_id', '=', charge.plan_id.id),
])
for sub in subs:
if not sub.next_invoice_date:
continue
period_end = fields.Datetime.to_datetime(sub.next_invoice_date)
period_start = fields.Datetime.to_datetime(
sub.last_invoice_date or sub.start_date)
if not period_start:
continue
sub._fc_rate_usage(charge, period_start, period_end)
@api.model @api.model
def _aggregate(self, subscription, metric, period_start, period_end): def _aggregate(self, subscription, metric, period_start, period_end):
"""Aggregate stored usage for a subscription+metric within [period_start, period_end) """Aggregate stored usage for a subscription+metric over the half-open window
``[period_start, period_end)``, anchored on each rollup's ``period_start``,
using the metric's aggregation function.""" using the metric's aggregation function."""
rows = self.search([ rows = self.search([
('subscription_id', '=', subscription.id), ('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id), ('metric_id', '=', metric.id),
('period_start', '>=', period_start), ('period_start', '>=', period_start),
('period_end', '<=', period_end), ('period_start', '<', period_end),
]) ])
qtys = rows.mapped('quantity') qtys = rows.mapped('quantity')
if not qtys: if not qtys:

View File

@@ -39,6 +39,10 @@ class FusionBillingWebhook(models.Model):
"subscription.terminated / subscription.reactivated / usage.threshold_reached", "subscription.terminated / subscription.reactivated / usage.threshold_reached",
) )
payload = fields.Json() payload = fields.Json()
body = fields.Text(
help="Canonical JSON body that was signed and is POSTed verbatim "
"(so the signature always matches the bytes on the wire).",
)
state = fields.Selection( state = fields.Selection(
[ [
("pending", "Pending"), ("pending", "Pending"),
@@ -59,11 +63,14 @@ class FusionBillingWebhook(models.Model):
@api.model @api.model
def _enqueue(self, service, event_type, payload): def _enqueue(self, service, event_type, payload):
# Serialize the canonical body ONCE, store it, and sign that exact string so
# the dispatched bytes always match the signature (no re-serialization drift).
body = json.dumps(payload, sort_keys=True, separators=(',', ':')) body = json.dumps(payload, sort_keys=True, separators=(',', ':'))
return self.create({ return self.create({
'service_id': service.id, 'service_id': service.id,
'event_type': event_type, 'event_type': event_type,
'payload': payload, 'payload': payload,
'body': body,
'signature': self._sign(service.webhook_secret, body), 'signature': self._sign(service.webhook_secret, body),
'state': 'pending', 'state': 'pending',
'next_retry_at': fields.Datetime.now(), 'next_retry_at': fields.Datetime.now(),
@@ -77,14 +84,18 @@ class FusionBillingWebhook(models.Model):
('next_retry_at', '<=', now), ('next_retry_at', '<=', now),
], limit=100) ], limit=100)
for wh in due: for wh in due:
body = json.dumps(wh.payload, sort_keys=True, separators=(',', ':')) # POST the exact bytes that were signed at enqueue time. Fall back to
# re-serializing the payload only for legacy rows enqueued before `body`
# existed (the signature was computed over the same canonical form).
body = wh.body or json.dumps(wh.payload, sort_keys=True, separators=(',', ':'))
try: try:
resp = requests.post( resp = requests.post(
wh.service_id.webhook_url, wh.service_id.webhook_url,
data=body, data=body,
headers={'Content-Type': 'application/json', headers={'Content-Type': 'application/json',
'X-Fusion-Signature': wh.signature, 'X-Fusion-Signature': wh.signature,
'X-Fusion-Event': wh.event_type}, 'X-Fusion-Event': wh.event_type,
'X-Fusion-Event-Id': str(wh.id)},
timeout=10, timeout=10,
) )
ok = 200 <= resp.status_code < 300 ok = 200 <= resp.status_code < 300
@@ -98,4 +109,5 @@ class FusionBillingWebhook(models.Model):
wh.state = 'dead' wh.state = 'dead'
else: else:
wh.state = 'failed' wh.state = 'failed'
wh.next_retry_at = now + timedelta(minutes=2 ** wh.attempts) # Cap the exponential backoff so the interval can't overflow.
wh.next_retry_at = now + timedelta(minutes=2 ** min(wh.attempts, 10))

View File

@@ -61,3 +61,79 @@ class TestApiHandlers(TransactionCase):
self.assertTrue(sub.is_subscription) self.assertTrue(sub.is_subscription)
self.assertEqual(sub.plan_id, self.plan) self.assertEqual(sub.plan_id, self.plan)
self.assertEqual(sub.subscription_state, '3_progress') self.assertEqual(sub.subscription_state, '3_progress')
# ── item 4 (C3): malformed input returns a 4xx-shaped error, never raises ──
def test_record_usage_missing_metric_code_returns_error(self):
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
partner = self.env['fusion.billing.account.link'].search(
[('external_id', '=', 'client-9')]).partner_id
sub = self.env['sale.order'].sudo().create(
{'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id})
# metric_code intentionally omitted — must return an error dict, not raise
res = self.service._api_record_usage({'events': [{
'subscription_external_id': str(sub.id),
'quantity': 10.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
}]})
self.assertEqual(res['status'], 'error')
# no usage row written
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
self.assertFalse(usage)
@tagged('post_install', '-at_install')
class TestUsageAuthorization(TransactionCase):
"""/usage must only accept events for subscriptions the calling service is linked
to, and reject unknown / non-subscription targets (items 3/C2/C4)."""
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.service_a = self.env['fusion.billing.service'].sudo().create(
{'name': 'Service A', 'code': 'svc_a'})
self.service_b = self.env['fusion.billing.service'].sudo().create(
{'name': 'Service B', 'code': 'svc_b'})
# Service A owns customer + subscription
self.service_a._api_upsert_customer({'external_id': 'cust-a', 'name': 'Cust A'})
self.partner_a = self.env['fusion.billing.account.link'].search(
[('service_id', '=', self.service_a.id), ('external_id', '=', 'cust-a')]).partner_id
self.sub_a = self.env['sale.order'].sudo().create(
{'partner_id': self.partner_a.id, 'is_subscription': True, 'plan_id': self.plan.id})
self.Usage = self.env['fusion.billing.usage'].sudo()
def _event(self, sub_id, idem):
return {'events': [{
'subscription_external_id': str(sub_id), 'metric_code': 'api_calls',
'quantity': 42.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
'idempotency_key': idem,
}]}
def test_cross_service_usage_rejected(self):
"""Service B pushing usage onto Service A's subscription is rejected, no row."""
res = self.service_b._api_record_usage(self._event(self.sub_a.id, 'cross-1'))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
self.assertFalse(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
def test_same_service_usage_accepted(self):
"""Positive control: Service A pushing onto its own subscription is accepted."""
res = self.service_a._api_record_usage(self._event(self.sub_a.id, 'ok-1'))
self.assertEqual(res['status'], 'ok')
self.assertEqual(res['accepted'], 1)
self.assertTrue(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
def test_nonexistent_subscription_rejected(self):
res = self.service_a._api_record_usage(self._event(999_999_999, 'ghost-1'))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
def test_non_subscription_order_rejected(self):
"""A plain (non-subscription) sale.order owned by the linked customer is rejected."""
plain = self.env['sale.order'].sudo().create({'partner_id': self.partner_a.id})
self.assertFalse(plain.is_subscription)
res = self.service_a._api_record_usage(self._event(plain.id, 'plain-1'))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
self.assertFalse(self.Usage.search([('subscription_id', '=', plain.id)]))

View File

@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from psycopg2 import IntegrityError
from odoo.tests.common import TransactionCase, tagged from odoo.tests.common import TransactionCase, tagged
from odoo.tools.misc import mute_logger
@tagged('post_install', '-at_install') @tagged('post_install', '-at_install')
@@ -43,3 +46,29 @@ class TestChargeMath(TransactionCase):
# 2,001 units -> 3 packages -> $6.00 # 2,001 units -> 3 packages -> $6.00
_, amount = charge._compute_billable(2_001.0) _, amount = charge._compute_billable(2_001.0)
self.assertAlmostEqual(amount, 6.0, places=2) self.assertAlmostEqual(amount, 6.0, places=2)
# ── item 10 (M7): only the two implemented charge models remain ──
def test_charge_model_selection_limited(self):
field = self.env['fusion.billing.charge']._fields['charge_model']
keys = [k for k, _label in field.selection]
self.assertEqual(sorted(keys), ['package', 'standard'])
self.assertNotIn('graduated', keys)
self.assertNotIn('volume', keys)
# ── item 11 (L1): currency is required and defaults to company currency ──
def test_currency_required_and_defaulted(self):
field = self.env['fusion.billing.charge']._fields['currency_id']
self.assertTrue(field.required)
charge = self._charge()
self.assertEqual(charge.currency_id, self.env.company.currency_id)
# ── item 12 (L2): non-negative price + positive batch DB constraints ──
def test_negative_price_rejected(self):
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self._charge(price_per_unit=-1.0)
def test_zero_batch_rejected(self):
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self._charge(unit_batch=0.0)

View File

@@ -2,6 +2,66 @@
from odoo.tests.common import TransactionCase, tagged from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestRatingCron(TransactionCase):
"""The rating cron must only rate a subscription against charges on its OWN plan
(items 1/C1/H4) and over the subscription's real open billing period (item 5/H1)."""
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.plan_b = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Plan B', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
self.recurring_product = self.env['product.product'].sudo().create(
{'name': 'Plan seat', 'type': 'service', 'recurring_invoice': True,
'list_price': 10.0})
self.overage_product = self.env['product.product'].sudo().create(
{'name': 'CPU overage', 'type': 'service', 'list_price': 0.0})
self.Usage = self.env['fusion.billing.usage'].sudo()
def _confirmed_sub(self, plan):
sub = self.env['sale.order'].sudo().create({
'partner_id': self.partner.id, 'plan_id': plan.id,
'order_line': [(0, 0, {'product_id': self.recurring_product.id,
'product_uom_qty': 1})],
})
sub.action_confirm()
# widen the computed billing window so usage in May is in-period
sub.write({'start_date': '2026-05-01', 'next_invoice_date': '2026-06-01'})
return sub
def test_cron_rates_only_matching_plan(self):
sub_a = self._confirmed_sub(self.plan_a)
sub_b = self._confirmed_sub(self.plan_b)
self.assertEqual(sub_a.subscription_state, '3_progress')
self.assertEqual(sub_b.subscription_state, '3_progress')
# one charge, linked to Plan A only
charge = self.env['fusion.billing.charge'].sudo().create({
'name': 'CPU overage', 'plan_code': 'plan-a', 'plan_id': self.plan_a.id,
'metric_id': self.metric.id, 'product_id': self.overage_product.id,
'included_quota': 100.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
'charge_model': 'standard'})
# usage recorded on BOTH subs, in the open period
self.Usage._record_usage(sub_a, 'cpu_seconds', 1100.0,
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='a1')
self.Usage._record_usage(sub_b, 'cpu_seconds', 1100.0,
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='b1')
self.Usage._cron_rate_open_periods()
# Plan A sub IS rated (window captured the usage → overage line present)
line_a = sub_a.order_line.filtered(lambda l: l.product_id == self.overage_product)
self.assertTrue(line_a, "Plan A subscription should be rated by the Plan A charge")
self.assertAlmostEqual(line_a.price_unit, 0.10, places=2)
# Plan B sub is NOT rated by the Plan A charge
line_b = sub_b.order_line.filtered(lambda l: l.product_id == self.overage_product)
self.assertFalse(line_b, "Plan B subscription must NOT be rated by the Plan A charge")
@tagged('post_install', '-at_install') @tagged('post_install', '-at_install')
class TestUsageIngestion(TransactionCase): class TestUsageIngestion(TransactionCase):
@@ -50,3 +110,62 @@ class TestUsageIngestion(TransactionCase):
self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr') self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr')
self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may') self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may')
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0) self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0)
def test_rate_open_period_creates_overage_line(self):
product = self.env['product.product'].sudo().create(
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
charge = self.env['fusion.billing.charge'].sudo().create({
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
'product_id': product.id, 'included_quota': 100.0,
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0,
'2026-05-01', '2026-06-01', idem='r1')
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
# 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10
self.assertAlmostEqual(amount, 0.10, places=2)
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
self.assertTrue(line)
# ── item 6 (H2): half-open aggregation window anchored on period_start ──
def test_aggregate_daily_rollups_in_window(self):
"""Three DAILY rollups (period_start 05-01/-08/-15, each period_end +1 day)
sum correctly for the half-open window ['2026-05-01', '2026-06-01')."""
rollups = [
('2026-05-01 00:00:00', '2026-05-02 00:00:00', 3.0),
('2026-05-08 00:00:00', '2026-05-09 00:00:00', 5.0),
('2026-05-15 00:00:00', '2026-05-16 00:00:00', 7.0),
]
for i, (ps, pe, q) in enumerate(rollups):
self.Usage._record_usage(self.sub, 'cpu_seconds', q, ps, pe, idem='daily-%d' % i)
total = self.Usage._aggregate(
self.sub, self.metric, '2026-05-01 00:00:00', '2026-06-01 00:00:00')
self.assertEqual(total, 15.0) # 3 + 5 + 7
# ── item 7 (H3): idempotency key is scoped per (subscription, metric) ──
def test_same_idempotency_key_distinct_subscriptions(self):
"""The SAME idempotency key on two DIFFERENT subscriptions creates TWO rows."""
sub2 = self.env['sale.order'].sudo().create({
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
})
key = 'shared-idem-key'
a = self.Usage._record_usage(self.sub, 'cpu_seconds', 10.0, '2026-05-01', '2026-06-01', idem=key)
b = self.Usage._record_usage(sub2, 'cpu_seconds', 20.0, '2026-05-01', '2026-06-01', idem=key)
self.assertNotEqual(a, b) # distinct rows, no collision
rows = self.Usage.search([('idempotency_key', '=', key)])
self.assertEqual(len(rows), 2)
self.assertEqual(a.quantity, 10.0)
self.assertEqual(b.quantity, 20.0)
# ── item 2 (C1): zero aggregated usage creates no overage line ──
def test_zero_usage_creates_no_line(self):
product = self.env['product.product'].sudo().create(
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
charge = self.env['fusion.billing.charge'].sudo().create({
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
'product_id': product.id, 'included_quota': 100.0,
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
# no usage recorded → aggregate is 0 → amount 0 → no line created
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
self.assertEqual(amount, 0.0)
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
self.assertFalse(line)

View File

@@ -4,6 +4,7 @@ import hmac
import json import json
from unittest.mock import patch from unittest.mock import patch
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase, tagged from odoo.tests.common import TransactionCase, tagged
@@ -51,3 +52,48 @@ class TestWebhookEngine(TransactionCase):
return_value=_Resp()): return_value=_Resp()):
self.Webhook._cron_dispatch() self.Webhook._cron_dispatch()
self.assertEqual(wh.state, 'dead') self.assertEqual(wh.state, 'dead')
# ── item 8 (H5): dispatch POSTs the stored body verbatim + event-id header ──
def test_dispatch_posts_stored_body_and_event_id(self):
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-9'})
class _Resp:
status_code = 200
text = 'ok'
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
return_value=_Resp()) as mock_post:
self.Webhook._cron_dispatch()
self.assertTrue(mock_post.called)
_args, kwargs = mock_post.call_args
# the exact stored body is POSTed (not a re-serialized payload)
self.assertEqual(kwargs['data'], wh.body)
self.assertEqual(wh.body, json.dumps(
{'invoice': 'INV-9'}, sort_keys=True, separators=(',', ':')))
# signature matches the bytes on the wire
expected = hmac.new(b'whsec_test', wh.body.encode(), hashlib.sha256).hexdigest()
self.assertEqual(kwargs['headers']['X-Fusion-Signature'], expected)
# event id header present and correct
self.assertEqual(kwargs['headers']['X-Fusion-Event-Id'], str(wh.id))
# ── item 9 (H6): SSRF guard on webhook_url ──
def test_webhook_url_rejects_loopback(self):
with self.assertRaises(ValidationError):
self.env['fusion.billing.service'].sudo().create({
'name': 'Evil', 'code': 'evil', 'webhook_url': 'http://127.0.0.1/x'})
def test_webhook_url_rejects_private_and_http(self):
for bad in ('http://10.0.0.5/hook', # private + non-https
'https://192.168.1.10/hook', # private
'https://localhost/hook', # localhost host
'https://169.254.169.254/latest', # link-local metadata
'http://api.example.com/hook'): # non-https
with self.assertRaises(ValidationError):
self.env['fusion.billing.service'].sudo().create({
'name': 'Bad', 'code': 'bad-%s' % bad, 'webhook_url': bad})
def test_webhook_url_allows_public_https(self):
svc = self.env['fusion.billing.service'].sudo().create({
'name': 'Good', 'code': 'good',
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook'})
self.assertTrue(svc.id)

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Helpdesk Reporter', 'name': 'Fusion Helpdesk Reporter',
'version': '19.0.1.3.0', 'version': '19.0.1.4.0',
'category': 'Productivity', 'category': 'Productivity',
'summary': 'One-click in-app bug reporting & feature requesting — ' 'summary': 'One-click in-app bug reporting & feature requesting — '
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.', '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', 'license': 'OPL-1',
'depends': ['base', 'web', 'mail'], 'depends': ['base', 'web', 'mail'],
'data': [ 'data': [
'security/fusion_helpdesk_groups.xml',
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml', 'data/ir_config_parameter_data.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',

View File

@@ -22,6 +22,15 @@ from urllib.parse import urljoin, urlparse
from odoo import _, http from odoo import _, http
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.http import request from odoo.http import request
from odoo.tools import email_normalize
from odoo.addons.fusion_helpdesk.utils import (
build_ticket_vals,
build_scope_domain,
is_public_message,
compute_unread_count,
escape_like,
)
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -34,7 +43,7 @@ class FusionHelpdeskController(http.Controller):
) )
def submit(self, kind, subject, description, def submit(self, kind, subject, description,
error_code=None, attachments=None, 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 """Forward a bug report or feature request to the central Odoo
Helpdesk and return {ok, ticket_id, ticket_url, error}. Helpdesk and return {ok, ticket_id, ticket_url, error}.
@@ -60,10 +69,6 @@ class FusionHelpdeskController(http.Controller):
} }
# ---- Build the ticket payload --------------------------------- # ---- 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 = [] body_parts = []
if description: if description:
body_parts.append( body_parts.append(
@@ -77,12 +82,22 @@ class FusionHelpdeskController(http.Controller):
) )
body_parts.append(self._build_diag_block(page_url, user_agent)) body_parts.append(self._build_diag_block(page_url, user_agent))
ticket_vals = { # Identity keystone: send the reporter's name + email so the central
'name': full_subject, # helpdesk find-or-creates the customer partner and subscribes them as
'description': '\n'.join(body_parts), # a follower — which is what enables reply emails, the magic link, and
} # the scoped "My Tickets" inbox. reply_email is the (editable) value the
if cfg['team_id']: # user confirmed in the dialog; fall back to their Odoo email/login.
ticket_vals['team_id'] = cfg['team_id'] 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 -------------------------------------- # ---- Talk to remote Odoo --------------------------------------
try: try:
@@ -133,7 +148,12 @@ class FusionHelpdeskController(http.Controller):
return _network_error_response(cfg['url'], e) return _network_error_response(cfg['url'], e)
# ---- Push attachments ----------------------------------------- # ---- 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 attached = 0
failed = 0
for att in attachments or []: for att in attachments or []:
data_b64 = (att or {}).get('data_b64') data_b64 = (att or {}).get('data_b64')
name = (att or {}).get('name') or 'attachment.bin' name = (att or {}).get('name') or 'attachment.bin'
@@ -152,10 +172,12 @@ class FusionHelpdeskController(http.Controller):
}], }],
) )
attached += 1 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( _logger.warning(
'fusion_helpdesk: attachment "%s" upload failed: %s', 'fusion_helpdesk: attachment "%s" upload failed: %s',
name, e.faultString, name, e,
) )
ticket_url = urljoin( ticket_url = urljoin(
@@ -163,9 +185,9 @@ class FusionHelpdeskController(http.Controller):
'odoo/helpdesk/%s' % ticket_id, 'odoo/helpdesk/%s' % ticket_id,
) )
_logger.info( _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', 'on %s for user %s',
ticket_id, attached, cfg['url'], ticket_id, attached, failed, cfg['url'],
request.env.user.login, request.env.user.login,
) )
return { return {
@@ -173,6 +195,7 @@ class FusionHelpdeskController(http.Controller):
'ticket_id': ticket_id, 'ticket_id': ticket_id,
'ticket_url': ticket_url, 'ticket_url': ticket_url,
'attached': attached, 'attached': attached,
'failed': failed,
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -351,6 +374,318 @@ class FusionHelpdeskController(http.Controller):
body += '</table>' body += '</table>'
return 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/<int:ticket_id>',
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/<int:ticket_id>/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 <p> tags.
html = '<p>%s</p>' % _html_escape(text).replace('\n', '<br/>')
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): def _html_escape(s):
return ( return (

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import res_config_settings from . import res_config_settings
from . import fusion_helpdesk_ticket_seen

View File

@@ -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}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
-->
<odoo>
<!--
Deployment-level admin for the embedded ticket inbox. Members see
ALL tickets filed from this deployment (scoped by x_fc_client_label)
in the "My Tickets" tab; non-members see only their own. The gate is
enforced server-side in the controller via has_group().
Odoo 19: res.groups has NO `users`/`category_id` fields — keep minimal.
-->
<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 helpdesk inbox.</field>
</record>
</odoo>

View File

@@ -1 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fhd_seen_user fusion.helpdesk.ticket.seen.user model_fusion_helpdesk_ticket_seen base.group_user 1 1 1 1

View File

@@ -1,13 +1,20 @@
/** @odoo-module **/ /** @odoo-module **/
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or // Fusion Helpdesk — submission + follow-up dialog.
// Feature, fill in subject + description, paste an error code, attach //
// files, and capture a screenshot via the browser's getDisplayMedia // Two tabs:
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit // • New — report a bug / request a feature (the original form),
// which forwards it (XML-RPC) to a central Odoo Helpdesk. // 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 { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation"; import { _t } from "@web/core/l10n/translation";
@@ -18,16 +25,20 @@ export class FusionHelpdeskDialog extends Component {
static components = { Dialog }; static components = { Dialog };
static props = { static props = {
close: Function, close: Function,
initialTab: { type: String, optional: true },
}; };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.state = useState({ this.state = useState({
kind: "bug", // 'bug' | 'feature' tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread'
// ---- New report ----
kind: "bug",
subject: "", subject: "",
description: "", description: "",
errorCode: "", errorCode: "",
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}] replyEmail: user.login || "",
attachments: [],
capturing: false, capturing: false,
submitting: false, submitting: false,
error: "", error: "",
@@ -35,21 +46,146 @@ export class FusionHelpdeskDialog extends Component {
ticketId: null, ticketId: null,
ticketUrl: "", ticketUrl: "",
attached: 0, 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() { get dialogTitle() {
return this.state.kind === "bug" if (this.state.tab === "thread" && this.state.current) {
? _t("Report a Bug") return this.state.current.subject;
: _t("Request a Feature"); }
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) { setKind(kind) {
this.state.kind = 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) { async onFilesPicked(ev) {
const files = Array.from(ev.target.files || []); const files = Array.from(ev.target.files || []);
for (const f of 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 = ""; ev.target.value = "";
} }
@@ -92,8 +227,6 @@ export class FusionHelpdeskDialog extends Component {
}); });
} }
// ------------------------------------------------------------------
// Screenshot capture via getDisplayMedia
async onTakeScreenshot() { async onTakeScreenshot() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
this.notification.add( this.notification.add(
@@ -119,7 +252,6 @@ export class FusionHelpdeskDialog extends Component {
rawSize: blob.size, rawSize: blob.size,
}); });
} catch (err) { } catch (err) {
// User cancelled the picker — silently swallow. Other errors → notify.
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") { if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
this.notification.add( this.notification.add(
_t("Screenshot failed: %s").replace("%s", err.message || err), _t("Screenshot failed: %s").replace("%s", err.message || err),
@@ -138,7 +270,6 @@ export class FusionHelpdeskDialog extends Component {
const video = document.createElement("video"); const video = document.createElement("video");
video.srcObject = stream; video.srcObject = stream;
await video.play(); await video.play();
// Give the browser one frame to settle the picker chrome.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = video.videoWidth; canvas.width = video.videoWidth;
@@ -195,8 +326,9 @@ export class FusionHelpdeskDialog extends Component {
return "fa fa-file-o"; return "fa fa-file-o";
} }
// ------------------------------------------------------------------ // ==================================================================
// Submit // New report — submit
// ==================================================================
async onSubmit() { async onSubmit() {
if (this.state.submitting) return; if (this.state.submitting) return;
const subject = (this.state.subject || "").trim(); const subject = (this.state.subject || "").trim();
@@ -213,6 +345,7 @@ export class FusionHelpdeskDialog extends Component {
subject, subject,
description: this.state.description || "", description: this.state.description || "",
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "", error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
reply_email: (this.state.replyEmail || "").trim(),
attachments: this.state.attachments.map((a) => ({ attachments: this.state.attachments.map((a) => ({
name: a.name, name: a.name,
mimetype: a.mimetype, mimetype: a.mimetype,
@@ -229,13 +362,14 @@ export class FusionHelpdeskDialog extends Component {
this.state.ticketId = res.ticket_id; this.state.ticketId = res.ticket_id;
this.state.ticketUrl = res.ticket_url; this.state.ticketUrl = res.ticket_url;
this.state.attached = res.attached || 0; 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.subject = "";
this.state.description = ""; this.state.description = "";
this.state.errorCode = ""; this.state.errorCode = "";
this.state.attachments = []; this.state.attachments = [];
} }
} catch (err) { } catch (err) {
console.error("fusion_helpdesk: submit failed", err);
this.state.error = (err && err.message) || _t("Network error."); this.state.error = (err && err.message) || _t("Network error.");
} finally { } finally {
this.state.submitting = false; this.state.submitting = false;

View File

@@ -1,24 +1,52 @@
/** @odoo-module **/ /** @odoo-module **/
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon // Fusion Helpdesk — top systray icon with an unread-reply badge.
// appears to the LEFT of the attendance check-in button. Odoo // Sequence 99 places it just left of the attendance check-in button.
// systray ordering is by sequence ascending (lower = leftmost in the
// systray bar). hr_attendance ships at sequence 100, so we use 99.
import { Component } from "@odoo/owl"; import { Component, useState, onWillStart, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog"; import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
const POLL_MS = 120000; // refresh the unread badge every 2 minutes
class FusionHelpdeskSystray extends Component { class FusionHelpdeskSystray extends Component {
static template = "fusion_helpdesk.SystrayItem"; static template = "fusion_helpdesk.SystrayItem";
static props = {}; static props = {};
setup() { setup() {
this.dialog = useService("dialog"); 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() { 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() }
);
} }
} }

View File

@@ -170,3 +170,170 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
&:hover { color: #d32f2f; } &: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;
}
}

View File

@@ -4,23 +4,36 @@
<t t-name="fusion_helpdesk.Dialog"> <t t-name="fusion_helpdesk.Dialog">
<Dialog title="dialogTitle" size="'lg'"> <Dialog title="dialogTitle" size="'lg'">
<div class="o_fhd_dialog"> <div class="o_fhd_dialog">
<!-- Kind selector -->
<!-- ===== Tabs ===== -->
<div class="o_fhd_tabs">
<button type="button" class="o_fhd_tab"
t-att-class="{ 'o_fhd_tab_active': state.tab === 'new' }"
t-on-click="() => this.setTab('new')">
<i class="fa fa-plus-circle me-1"/> New
</button>
<button type="button" class="o_fhd_tab"
t-att-class="{ 'o_fhd_tab_active': state.tab === 'list' || state.tab === 'thread' }"
t-on-click="() => this.setTab('list')">
<i class="fa fa-ticket me-1"/> My Tickets
</button>
</div>
<!-- ===== NEW report ===== -->
<div t-if="state.tab === 'new'">
<div class="o_fhd_kind_row"> <div class="o_fhd_kind_row">
<button type="button" <button type="button" class="o_fhd_kind_chip"
class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }" t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
t-on-click="() => this.setKind('bug')"> t-on-click="() => this.setKind('bug')">
<i class="fa fa-bug me-1"/> Report a Bug <i class="fa fa-bug me-1"/> Report a Bug
</button> </button>
<button type="button" <button type="button" class="o_fhd_kind_chip"
class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }" t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
t-on-click="() => this.setKind('feature')"> t-on-click="() => this.setKind('feature')">
<i class="fa fa-lightbulb-o me-1"/> Request a Feature <i class="fa fa-lightbulb-o me-1"/> Request a Feature
</button> </button>
</div> </div>
<!-- Subject -->
<div class="o_fhd_field"> <div class="o_fhd_field">
<label>Subject *</label> <label>Subject *</label>
<input type="text" class="form-control" <input type="text" class="form-control"
@@ -29,7 +42,17 @@
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/> t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
</div> </div>
<!-- Description --> <div class="o_fhd_field">
<label>
Your email
<span class="o_fhd_hint">we'll reply here — edit if you'd like replies elsewhere</span>
</label>
<input type="email" class="form-control"
t-att-value="state.replyEmail"
t-on-input="(ev) => state.replyEmail = ev.target.value"
placeholder="you@example.com"/>
</div>
<div class="o_fhd_field"> <div class="o_fhd_field">
<label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/> <label t-esc="state.kind === 'bug' ? 'What were you doing? What did you expect?' : 'Describe the desired behaviour and the use case'"/>
<textarea class="form-control" rows="5" <textarea class="form-control" rows="5"
@@ -38,7 +61,6 @@
placeholder="Steps to reproduce, expected vs. actual, business impact…"/> placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
</div> </div>
<!-- Error code (bug only) -->
<div class="o_fhd_field" t-if="state.kind === 'bug'"> <div class="o_fhd_field" t-if="state.kind === 'bug'">
<label> <label>
Error code / traceback Error code / traceback
@@ -50,7 +72,6 @@
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/> placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
</div> </div>
<!-- Attachments -->
<div class="o_fhd_field"> <div class="o_fhd_field">
<label>Attachments</label> <label>Attachments</label>
<div class="o_fhd_actions_row"> <div class="o_fhd_actions_row">
@@ -79,30 +100,129 @@
</div> </div>
</div> </div>
<!-- Result feedback -->
<div t-if="state.error" class="alert alert-danger mt-2"> <div t-if="state.error" class="alert alert-danger mt-2">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/> <i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
</div> </div>
<div t-if="state.success" class="alert alert-success mt-2"> <div t-if="state.success" class="alert alert-success mt-2">
<i class="fa fa-check-circle me-1"/> <i class="fa fa-check-circle me-1"/>
Thanks — ticket Thanks — ticket
<a t-att-href="state.ticketUrl" target="_blank"> <a t-att-href="state.ticketUrl" target="_blank">#<t t-esc="state.ticketId"/></a>
#<t t-esc="state.ticketId"/> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>. You'll get replies by email, and can follow up under <b>My Tickets</b>.
</div>
<div t-if="state.success and state.failed" class="alert alert-warning mt-2">
<i class="fa fa-exclamation-triangle me-1"/>
<t t-esc="state.failed"/> attachment(s) could not be uploaded.
Open the ticket from <b>My Tickets</b> and add them there.
</div> </div>
</div> </div>
<!-- ===== LIST ===== -->
<div t-if="state.tab === 'list'">
<div t-if="state.isAdmin" class="o_fhd_scope_row">
<button type="button" class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.scope === 'mine' }"
t-on-click="() => this.setScope('mine')">Mine</button>
<button type="button" class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.scope === 'all' }"
t-on-click="() => this.setScope('all')">All (deployment)</button>
</div>
<div t-if="state.loadingList" class="o_fhd_muted text-center p-3">
<i class="fa fa-spinner fa-spin me-1"/> Loading your tickets…
</div>
<div t-elif="state.listError" class="alert alert-danger">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.listError"/>
</div>
<div t-elif="!state.tickets.length" class="o_fhd_muted text-center p-4">
<i class="fa fa-inbox fa-2x d-block mb-2"/>
No tickets yet. Use the <b>New</b> tab to report a bug or request a feature.
</div>
<div t-else="" class="o_fhd_ticket_list">
<div t-foreach="state.tickets" t-as="t" t-key="t.id"
class="o_fhd_ticket_row" t-on-click="() => this.openTicket(t.id)">
<span t-if="t.has_unread" class="o_fhd_unread_dot" title="New reply"/>
<span t-else="" class="o_fhd_unread_spacer"/>
<span class="o_fhd_ticket_ref" t-esc="'#' + t.ref"/>
<span class="o_fhd_ticket_subject" t-esc="t.subject"/>
<span class="o_fhd_ticket_stage" t-esc="t.stage"/>
</div>
</div>
</div>
<!-- ===== THREAD ===== -->
<div t-if="state.tab === 'thread'">
<div t-if="state.loadingThread" class="o_fhd_muted text-center p-3">
<i class="fa fa-spinner fa-spin me-1"/> Loading…
</div>
<t t-elif="state.current">
<div class="o_fhd_thread_head">
<span class="o_fhd_ticket_stage" t-esc="state.current.stage"/>
<a t-if="state.current.portal_url" class="o_fhd_open_portal"
t-att-href="state.current.portal_url" target="_blank">
Open full ticket <i class="fa fa-external-link"/>
</a>
</div>
<div class="o_fhd_thread">
<div t-if="!state.current.messages.length" class="o_fhd_muted p-2">
No messages yet.
</div>
<div t-foreach="state.current.messages" t-as="m" t-key="m.id"
class="o_fhd_msg">
<div class="o_fhd_msg_head">
<span class="o_fhd_msg_author" t-esc="m.author"/>
<span class="o_fhd_msg_date" t-esc="m.date"/>
</div>
<div class="o_fhd_msg_body" t-out="m.body"/>
<div t-if="m.attachment_count" class="o_fhd_msg_attach">
<i class="fa fa-paperclip me-1"/>
<t t-esc="m.attachment_count"/> attachment(s) —
open the full ticket to download.
</div>
</div>
</div>
<div t-if="state.threadError" class="alert alert-danger mt-2">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.threadError"/>
</div>
<div class="o_fhd_field mt-2">
<label>Your reply</label>
<textarea class="form-control" rows="3"
t-att-value="state.replyBody"
t-on-input="(ev) => state.replyBody = ev.target.value"
placeholder="Add a follow-up… support will be notified."/>
</div>
</t>
</div>
</div>
<!-- ===== Footer ===== -->
<t t-set-slot="footer"> <t t-set-slot="footer">
<button class="btn btn-primary" <t t-if="state.tab === 'new'">
t-on-click="onSubmit" <button class="btn btn-primary" t-on-click="onSubmit"
t-att-disabled="state.submitting or !state.subject.trim()"> t-att-disabled="state.submitting or !state.subject.trim()">
<t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t> <t t-if="state.submitting"><i class="fa fa-spinner fa-spin me-1"/></t>
<t t-else=""><i class="fa fa-paper-plane me-1"/></t> <t t-else=""><i class="fa fa-paper-plane me-1"/></t>
Submit Submit
</button> </button>
<button class="btn btn-secondary" t-on-click="props.close"> <button class="btn btn-secondary" t-on-click="props.close">Close</button>
Close </t>
<t t-elif="state.tab === 'thread'">
<button class="btn btn-primary" t-on-click="sendReply"
t-att-disabled="state.sendingReply or !state.replyBody.trim()">
<t t-if="state.sendingReply"><i class="fa fa-spinner fa-spin me-1"/></t>
<t t-else=""><i class="fa fa-reply me-1"/></t>
Send reply
</button> </button>
<button class="btn btn-secondary" t-on-click="backToList">
<i class="fa fa-arrow-left me-1"/> Back
</button>
</t>
<t t-else="">
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
</t>
</t> </t>
</Dialog> </Dialog>
</t> </t>

View File

@@ -5,11 +5,14 @@
<div class="o_fhd_systray dropdown"> <div class="o_fhd_systray dropdown">
<button type="button" <button type="button"
class="o_fhd_systray_btn dropdown-toggle" class="o_fhd_systray_btn dropdown-toggle"
title="Report a bug or request a feature" title="Report a bug, request a feature, or follow up on your tickets"
t-on-click="onClick"> t-on-click="onClick">
<img src="/fusion_helpdesk/static/description/help_icon.png" <img src="/fusion_helpdesk/static/description/help_icon.png"
alt="Help" alt="Help"
class="o_fhd_systray_img"/> class="o_fhd_systray_img"/>
<span t-if="state.unread > 0"
class="o_fhd_systray_badge"
t-esc="state.unread > 99 ? '99+' : state.unread"/>
</button> </button>
</div> </div>
</t> </t>

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_utils
from . import test_seen

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Tests for fusion.helpdesk.ticket.seen read-tracking."""
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fusion_helpdesk')
class TestSeen(TransactionCase):
def test_mark_seen_upserts_and_is_monotonic(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)
Seen._mark_seen(central_ticket_id=42, last_message_id=90) # stale, ignored
rec = Seen.search([
('user_id', '=', self.env.uid),
('central_ticket_id', '=', 42),
])
self.assertEqual(len(rec), 1, "should upsert, not duplicate")
self.assertEqual(rec.last_seen_message_id, 120, "monotonic — never moves back")
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})

View File

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Unit tests for the pure helpers in fusion_helpdesk.utils.
These need no live central Odoo — they pin the identity keystone, the
scoping security boundary, the public-message filter and the unread
maths as plain data transformations.
"""
from odoo.tests import TransactionCase, tagged
from odoo.addons.fusion_helpdesk.utils import (
build_ticket_vals,
build_scope_domain,
is_public_message,
compute_unread_count,
)
@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'])
self.assertIn('[ENTECH]', 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 an empty email
self.assertNotIn('team_id', vals) # omit falsy team
self.assertNotIn('x_fc_client_label', vals) # omit empty label
self.assertEqual(vals['partner_name'], 'Jane')
self.assertIn('Feature Request', vals['name'])
@tagged('post_install', '-at_install', 'fusion_helpdesk')
class TestScopeDomain(TransactionCase):
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_empty_label_never_matches_everything(self):
dom = build_scope_domain(label='', email='', is_admin=True)
# label term must be present and must NOT be an empty string
label_terms = [t for t in dom if t[0] == 'x_fc_client_label']
self.assertEqual(len(label_terms), 1)
self.assertNotEqual(label_terms[0][2], '')
def test_wildcard_email_cannot_widen_scope(self):
# IDOR guard: a self-set email of '%' must NOT become a match-all
# =ilike term — the wildcard has to be escaped to a literal.
dom = build_scope_domain(label='ENTECH', email='%', is_admin=False)
email_terms = [t for t in dom if t[0] == 'partner_email']
self.assertEqual(len(email_terms), 1)
self.assertEqual(email_terms[0][2], '\\%',
"'%' must be escaped so ILIKE matches it literally")
def test_underscore_in_real_email_is_escaped_but_preserved(self):
dom = build_scope_domain(label='ENTECH', email='john_doe@x.com', is_admin=False)
email_terms = [t for t in dom if t[0] == 'partner_email']
self.assertEqual(email_terms[0][2], 'john\\_doe@x.com')
@tagged('post_install', '-at_install', 'fusion_helpdesk')
class TestMessageFilterAndUnread(TransactionCase):
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}))
self.assertTrue(is_public_message({})) # default visible
def test_unread_count(self):
tickets = [
{'id': 1, 'last_support_msg_id': 10}, # seen 10 -> read
{'id': 2, 'last_support_msg_id': 5}, # seen 3 -> unread
{'id': 3, 'last_support_msg_id': 0}, # no support msg
]
seen = {1: 10, 2: 3}
self.assertEqual(compute_unread_count(tickets, seen), 1)
def test_unread_count_unseen_ticket_counts(self):
tickets = [{'id': 9, 'last_support_msg_id': 4}]
self.assertEqual(compute_unread_count(tickets, {}), 1)

98
fusion_helpdesk/utils.py Normal file
View File

@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Pure helpers for fusion_helpdesk.
No Odoo environment, no `request` — just data in, data out. Everything
here is unit-testable in isolation, which is what lets us validate the
identity keystone, the server-side scoping boundary, the public-message
filter and the unread maths without a live central Odoo to talk to.
"""
# Sentinel used so a missing label/email can never widen a domain to
# "match everything". An empty string in `=`/`=ilike` would match rows
# whose field is also empty; '__none__' will simply match nothing.
_NO_MATCH = '__none__'
def escape_like(value):
"""Escape SQL LIKE/ILIKE wildcards so a user-supplied value can never
widen an `=ilike` match to other rows.
`res.users.email` is self-writeable and unvalidated, so without this a
user could set their email to ``%`` and have ``partner_email =ilike '%'``
match EVERY ticket in their deployment (a cross-user IDOR). Escaping the
backslash first, then ``%`` and ``_``, makes those characters match
literally. Real emails containing ``_`` (e.g. ``john_doe@x.com``) keep
working — the underscore is matched as a literal, which is what we want.
"""
return (value or '').replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
reporter_name, reporter_email, company_name):
"""Construct the `helpdesk.ticket` create vals for a forwarded report.
The identity fields (`partner_email`, `partner_name`,
`partner_company_name`) drive native helpdesk find-or-create of the
customer partner + follower subscription on the central Odoo, and
`x_fc_client_label` tags the deployment for the scoped inbox.
"""
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
def build_scope_domain(label, email, is_admin):
"""Server-side ticket scope for the embedded inbox.
`x_fc_client_label` is ALWAYS bound (defense in depth) so neither a
regular user nor a deployment admin can ever read another
deployment's tickets — even though the shared bot can technically see
every ticket on the central Odoo. Regular users are additionally
bound to their own `partner_email`.
"""
domain = [('x_fc_client_label', '=', label or _NO_MATCH)]
if not is_admin:
safe_email = escape_like(email)
domain.append(('partner_email', '=ilike', safe_email or _NO_MATCH))
return domain
def is_public_message(msg):
"""True when a message is customer-visible (not an internal note).
`msg` is a plain dict carrying a `subtype_is_internal` flag resolved
from the central `mail.message.subtype`. Internal notes must never be
shown to a client in the embedded inbox.
"""
return not msg.get('subtype_is_internal', False)
def compute_unread_count(tickets, seen_by_id):
"""Number of tickets with a support reply the user hasn't seen.
`tickets` is a list of dicts each carrying `id` and
`last_support_msg_id` (id of the latest customer-visible support
message, 0 if none). `seen_by_id` maps central ticket id -> last
message id the user has seen (absent => 0 baseline).
"""
count = 0
for ticket in tickets:
last = ticket.get('last_support_msg_id') or 0
if last and last > (seen_by_id.get(ticket['id']) or 0):
count += 1
return count

View File

@@ -3,7 +3,7 @@
# License OPL-1 # License OPL-1
{ {
'name': 'Fusion Helpdesk Central — Client API Keys', 'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.1.0.2', 'version': '19.0.1.1.0',
'category': 'Productivity', 'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API ' 'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.', 'keys used by fusion_helpdesk client deployments.',
@@ -28,7 +28,9 @@ Depends only on `helpdesk`. No client-side install needed.
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml', 'data/ir_config_parameter_data.xml',
'data/mail_template_ack.xml',
'views/fusion_helpdesk_client_key_views.xml', 'views/fusion_helpdesk_client_key_views.xml',
'views/helpdesk_ticket_views.xml',
], ],
'installable': True, 'installable': True,
'auto_install': False, 'auto_install': False,

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Branded acknowledgement sent to the customer when an in-app-channel
ticket is created. Carries the portal magic link (object.get_portal_url()
embeds the access token) so the customer can track + reply without an
account. Button colours follow the company email branding, like Odoo's
own helpdesk templates.
-->
<odoo>
<data noupdate="1">
<record id="mail_template_ticket_ack" model="mail.template">
<field name="name">Helpdesk: Ticket Acknowledgement (Fusion)</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">We received your request [{{ object.ticket_ref or object.id }}]</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin:0; padding:0; font-family:Arial, Helvetica, sans-serif; color:#21252b; font-size:14px;">
<p>Hello <t t-out="object.partner_name or 'there'"/>,</p>
<p>
Thanks for reaching out — we've received your request and our
support team will be in touch. Here are the details:
</p>
<table style="margin:12px 0; font-size:14px;">
<tr>
<td style="padding:2px 12px 2px 0; color:#6c757d;">Reference</td>
<td style="padding:2px 0;"><strong t-out="object.ticket_ref or object.id"/></td>
</tr>
<tr>
<td style="padding:2px 12px 2px 0; color:#6c757d;">Subject</td>
<td style="padding:2px 0;"><t t-out="object.name or ''"/></td>
</tr>
</table>
<p style="margin:18px 0;">
<a t-att-href="object.get_base_url() + object.get_portal_url()"
target="_blank"
t-attf-style="background-color: {{ object.company_id.email_secondary_color or '#2c89e9' }}; padding:10px 18px; text-decoration:none; color: {{ object.company_id.email_primary_color or '#ffffff' }}; border-radius:5px; font-size:14px; display:inline-block;">
View &amp; track your ticket
</a>
</p>
<p style="color:#6c757d; font-size:13px;">
You can reply directly to this email to add information, follow up
from the link above, or sign up for an account from that page to
manage all of your requests in one place.
</p>
<p style="color:#6c757d; font-size:13px;"><t t-out="object.company_id.name or 'Support'"/></p>
</div>
</field>
</record>
</data>
</odoo>

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import fusion_helpdesk_client_key from . import fusion_helpdesk_client_key
from . import helpdesk_ticket

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Central-side helpdesk.ticket extensions for the customer follow-up flow.
Adds the `x_fc_client_label` deployment tag (set by the in-app reporter so
the embedded inbox can scope per client) and sends a branded acknowledgement
email — carrying the portal magic link — when an in-app ticket is created.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
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 fusion_helpdesk in-app '
'reporter. Scopes the embedded "My Tickets" inbox per client and '
'lets support filter tickets by originating deployment.',
)
@api.model_create_multi
def create(self, vals_list):
tickets = super().create(vals_list)
tickets._fc_send_ack_email()
return tickets
def _fc_send_ack_email(self):
"""Send the branded acknowledgement (with magic link) to the customer.
Only fires for in-app-channel tickets (those tagged with a client
label) that have a customer email — external web-form submissions
rely on the native website confirmation, so this won't double-send.
The whole thing is best-effort: a template/mail failure must never
block ticket creation, so we log and move on.
"""
template = self.env.ref(
'fusion_helpdesk_central.mail_template_ticket_ack',
raise_if_not_found=False,
)
if not template:
return
for ticket in self:
if not (ticket.x_fc_client_label and ticket.partner_email):
continue
try:
template.send_mail(ticket.id, force_send=False)
except Exception: # noqa: BLE001 — ack must never block create
_logger.exception(
'fusion_helpdesk_central: acknowledgement email failed '
'for ticket %s (%s)', ticket.id, ticket.x_fc_client_label,
)

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_identity

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Identity-keystone tests for the central helpdesk extensions.
Runs on an Enterprise environment (helpdesk installed) — e.g. odoo-nexa or
odoo-trial. Validates that passing partner_email resolves the customer +
follower (native), that the client label is stored, and that the branded
acknowledgement only fires for in-app-channel tickets.
"""
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestTicketIdentity(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.team = cls.env['helpdesk.team'].search([], limit=1)
def _ack_mails(self, ticket):
return self.env['mail.mail'].search([
('model', '=', 'helpdesk.ticket'),
('res_id', '=', ticket.id),
]).filtered(lambda m: 'received your request' in (m.subject or ''))
def test_partner_resolution_follower_and_label(self):
ticket = self.env['helpdesk.ticket'].create({
'name': 'Keystone test',
'team_id': self.team.id,
'partner_email': 'keystone.newperson@example.com',
'partner_name': 'Key Stone',
'x_fc_client_label': 'ENTECH',
})
self.assertEqual(ticket.x_fc_client_label, 'ENTECH')
self.assertTrue(
ticket.partner_id,
"native create() should find-or-create a partner from partner_email")
self.assertEqual(ticket.partner_id.email, 'keystone.newperson@example.com')
self.assertIn(
ticket.partner_id, ticket.message_partner_ids,
"the customer must be subscribed as a follower")
def test_ack_email_for_inapp_ticket(self):
ticket = self.env['helpdesk.ticket'].create({
'name': 'Ack test',
'team_id': self.team.id,
'partner_email': 'ack.person@example.com',
'partner_name': 'Ack Person',
'x_fc_client_label': 'ENTECH',
})
self.assertTrue(
self._ack_mails(ticket),
"an in-app ticket with a customer email should get our ack email")
def test_no_ack_without_client_label(self):
# Simulates an external web-form ticket — no client label, so our
# acknowledgement must NOT fire (avoids double-acknowledgement).
ticket = self.env['helpdesk.ticket'].create({
'name': 'External web ticket',
'team_id': self.team.id,
'partner_email': 'external.web@example.com',
'partner_name': 'External Web',
})
self.assertFalse(
self._ack_mails(ticket),
"no client label => our acknowledgement should not send")

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Surface the client-deployment tag in the agent backend so support can
see + filter tickets by the deployment they came from.
-->
<odoo>
<record id="fhc_ticket_list_label" model="ir.ui.view">
<field name="name">fhc.helpdesk.ticket.list.client_label</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="x_fc_client_label" optional="show"/>
</xpath>
</field>
</record>
<record id="fhc_ticket_search_label" model="ir.ui.view">
<field name="name">fhc.helpdesk.ticket.search.client_label</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_search"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<field name="x_fc_client_label"/>
<filter string="Client Deployment" name="group_client_label"
context="{'group_by': 'x_fc_client_label'}"/>
</xpath>
</field>
</record>
</odoo>