diff --git a/fusion_helpdesk/__manifest__.py b/fusion_helpdesk/__manifest__.py
index 11f38e4d..9f24d860 100644
--- a/fusion_helpdesk/__manifest__.py
+++ b/fusion_helpdesk/__manifest__.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Helpdesk Reporter',
- 'version': '19.0.1.3.0',
+ 'version': '19.0.1.4.0',
'category': 'Productivity',
'summary': 'One-click in-app bug reporting & feature requesting — '
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
@@ -27,6 +27,7 @@ module bundle. No dependencies on the rest of Fusion Plating.
'license': 'OPL-1',
'depends': ['base', 'web', 'mail'],
'data': [
+ 'security/fusion_helpdesk_groups.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'views/res_config_settings_views.xml',
diff --git a/fusion_helpdesk/models/__init__.py b/fusion_helpdesk/models/__init__.py
index 6084d2ca..e4c753a3 100644
--- a/fusion_helpdesk/models/__init__.py
+++ b/fusion_helpdesk/models/__init__.py
@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import res_config_settings
+from . import fusion_helpdesk_ticket_seen
diff --git a/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py b/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py
new file mode 100644
index 00000000..9516623e
--- /dev/null
+++ b/fusion_helpdesk/models/fusion_helpdesk_ticket_seen.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1
+"""Per-user read-tracking for the embedded ticket inbox.
+
+Stores ONLY metadata — which central ticket a user has seen and up to
+which message id. No ticket content is replicated locally; this exists
+purely so the systray unread badge can work without re-fetching the
+whole inbox on every page load. Tickets themselves remain a live RPC
+view of the central Odoo.
+"""
+from odoo import api, fields, models
+
+
+class FusionHelpdeskTicketSeen(models.Model):
+ _name = 'fusion.helpdesk.ticket.seen'
+ _description = 'Fusion Helpdesk — per-user read tracking (metadata only)'
+
+ user_id = fields.Many2one(
+ 'res.users', required=True, index=True, ondelete='cascade',
+ default=lambda self: self.env.uid,
+ )
+ central_ticket_id = fields.Integer(
+ string='Central Ticket ID', required=True, index=True,
+ help='helpdesk.ticket id on the central Odoo.',
+ )
+ last_seen_message_id = fields.Integer(
+ string='Last Seen Message ID', default=0,
+ help='Highest central mail.message id this user has viewed for '
+ 'the ticket. Drives the unread badge.',
+ )
+
+ _user_ticket_uniq = models.Constraint(
+ 'UNIQUE(user_id, central_ticket_id)',
+ 'One seen-row per user per ticket.',
+ )
+
+ @api.model
+ def _mark_seen(self, central_ticket_id, last_message_id):
+ """Upsert the current user's last-seen marker for a ticket.
+
+ Monotonic — never moves the marker backwards (a stale client
+ reporting an older id can't resurrect an unread badge)."""
+ rec = self.search([
+ ('user_id', '=', self.env.uid),
+ ('central_ticket_id', '=', central_ticket_id),
+ ], limit=1)
+ if rec:
+ if (last_message_id or 0) > rec.last_seen_message_id:
+ rec.last_seen_message_id = last_message_id
+ else:
+ self.create({
+ 'central_ticket_id': central_ticket_id,
+ 'last_seen_message_id': last_message_id or 0,
+ })
+ return True
+
+ @api.model
+ def _seen_map(self, central_ticket_ids):
+ """Return {central_ticket_id: last_seen_message_id} for the
+ current user across the given ticket ids."""
+ rows = self.search([
+ ('user_id', '=', self.env.uid),
+ ('central_ticket_id', 'in', list(central_ticket_ids)),
+ ])
+ return {r.central_ticket_id: r.last_seen_message_id for r in rows}
diff --git a/fusion_helpdesk/security/fusion_helpdesk_groups.xml b/fusion_helpdesk/security/fusion_helpdesk_groups.xml
new file mode 100644
index 00000000..cf12ed69
--- /dev/null
+++ b/fusion_helpdesk/security/fusion_helpdesk_groups.xml
@@ -0,0 +1,18 @@
+
+
+
b
', + team_id=1, client_label='ENTECH', + reporter_name='John Doe', reporter_email='john@entech.com', + company_name='ENTECH Inc', + ) + self.assertEqual(vals['partner_email'], 'john@entech.com') + self.assertEqual(vals['partner_name'], 'John Doe') + self.assertEqual(vals['x_fc_client_label'], 'ENTECH') + self.assertEqual(vals['partner_company_name'], 'ENTECH Inc') + self.assertEqual(vals['team_id'], 1) + self.assertIn('X', vals['name']) + self.assertIn('[ENTECH]', vals['name']) + + def test_no_email_omits_partner_email(self): + vals = build_ticket_vals( + kind='feature', subject='Y', body_html='b
', + team_id=False, client_label='', reporter_name='Jane', + reporter_email='', company_name='', + ) + self.assertNotIn('partner_email', vals) # never send 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], '') + + +@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) diff --git a/fusion_helpdesk/utils.py b/fusion_helpdesk/utils.py new file mode 100644 index 00000000..56a06f9a --- /dev/null +++ b/fusion_helpdesk/utils.py @@ -0,0 +1,83 @@ +# -*- 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 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: + domain.append(('partner_email', '=ilike', 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