feat(fusion_helpdesk): customer follow-up + embedded ticket inbox

Squash-merge of feat/helpdesk-customer-followup. The billing and
fusion_login_audit work from that branch is already on main (landed
separately); this lands only the helpdesk feature.

- Identity keystone: submit() forwards partner_email/partner_name/
  x_fc_client_label so the central Helpdesk find-or-creates the customer
  partner and subscribes them as a follower (enables reply emails + magic link).
- Embedded in-app 'My Tickets' inbox: server-side scoped read/reply RPC
  endpoints, per-user seen tracking (fusion.helpdesk.ticket.seen), systray
  unread badge. Defense-in-depth scope domain + _norm_email normalisation
  (wildcard emails cannot widen scope).
- fusion_helpdesk_central: x_fc_client_label field + list/search views +
  branded acknowledgement email template.
- Deployed and smoke-tested live: nexa central 19.0.1.1.0, entech client
  19.0.1.4.1 (requires Contact Creation on the central service account).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 09:23:33 -04:00
parent 45ddb444a7
commit 6c15a7b1cf
24 changed files with 2314 additions and 130 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Helpdesk Reporter',
'version': '19.0.1.3.0',
'version': '19.0.1.4.1',
'category': 'Productivity',
'summary': 'One-click in-app bug reporting & feature requesting — '
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
@@ -27,6 +27,7 @@ module bundle. No dependencies on the rest of Fusion Plating.
'license': 'OPL-1',
'depends': ['base', 'web', 'mail'],
'data': [
'security/fusion_helpdesk_groups.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'views/res_config_settings_views.xml',

View File

@@ -23,6 +23,15 @@ from odoo import _, http
from odoo.exceptions import UserError
from odoo.http import request
from odoo.addons.fusion_helpdesk.utils import (
build_ticket_vals,
build_scope_domain,
is_public_message,
compute_unread_count,
escape_like,
_norm_email,
)
_logger = logging.getLogger(__name__)
@@ -34,7 +43,7 @@ class FusionHelpdeskController(http.Controller):
)
def submit(self, kind, subject, description,
error_code=None, attachments=None,
page_url=None, user_agent=None):
page_url=None, user_agent=None, reply_email=None):
"""Forward a bug report or feature request to the central Odoo
Helpdesk and return {ok, ticket_id, ticket_url, error}.
@@ -60,10 +69,6 @@ class FusionHelpdeskController(http.Controller):
}
# ---- Build the ticket payload ---------------------------------
prefix = ('[%s] ' % cfg['client_label']) if cfg['client_label'] else ''
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
full_subject = '%s%s: %s' % (prefix, kind_label, subject or '(untitled)')
body_parts = []
if description:
body_parts.append(
@@ -77,12 +82,22 @@ class FusionHelpdeskController(http.Controller):
)
body_parts.append(self._build_diag_block(page_url, user_agent))
ticket_vals = {
'name': full_subject,
'description': '\n'.join(body_parts),
}
if cfg['team_id']:
ticket_vals['team_id'] = cfg['team_id']
# Identity keystone: send the reporter's name + email so the central
# helpdesk find-or-creates the customer partner and subscribes them as
# a follower — which is what enables reply emails, the magic link, and
# the scoped "My Tickets" inbox. reply_email is the (editable) value the
# user confirmed in the dialog; fall back to their Odoo email/login.
user = request.env.user
# Normalise the confirmed email (and fall back to the user's own).
# Normalising rejects garbage / wildcard-bearing values so the stored
# partner_email — which is also the inbox scope key — stays clean.
reporter_email = _norm_email(reply_email, user.email, user.login)
ticket_vals = build_ticket_vals(
kind=kind, subject=subject, body_html='\n'.join(body_parts),
team_id=cfg['team_id'], client_label=cfg['client_label'],
reporter_name=user.name, reporter_email=reporter_email,
company_name=request.env.company.name,
)
# ---- Talk to remote Odoo --------------------------------------
try:
@@ -133,7 +148,12 @@ class FusionHelpdeskController(http.Controller):
return _network_error_response(cfg['url'], e)
# ---- Push attachments -----------------------------------------
# The ticket already exists; an attachment failure must NOT bubble up
# as a 500 (the user would think the whole submission failed and file
# a duplicate). Catch network errors too, count failures, and report
# them back so the dialog can tell the user which files didn't make it.
attached = 0
failed = 0
for att in attachments or []:
data_b64 = (att or {}).get('data_b64')
name = (att or {}).get('name') or 'attachment.bin'
@@ -152,10 +172,12 @@ class FusionHelpdeskController(http.Controller):
}],
)
attached += 1
except xmlrpc.client.Fault as e:
except (xmlrpc.client.Fault, xmlrpc.client.ProtocolError,
socket.timeout, OSError, ssl.SSLError) as e:
failed += 1
_logger.warning(
'fusion_helpdesk: attachment "%s" upload failed: %s',
name, e.faultString,
name, e,
)
ticket_url = urljoin(
@@ -163,9 +185,9 @@ class FusionHelpdeskController(http.Controller):
'odoo/helpdesk/%s' % ticket_id,
)
_logger.info(
'fusion_helpdesk: created remote ticket #%s (%s attachments) '
'fusion_helpdesk: created remote ticket #%s (%s attached, %s failed) '
'on %s for user %s',
ticket_id, attached, cfg['url'],
ticket_id, attached, failed, cfg['url'],
request.env.user.login,
)
return {
@@ -173,6 +195,7 @@ class FusionHelpdeskController(http.Controller):
'ticket_id': ticket_id,
'ticket_url': ticket_url,
'attached': attached,
'failed': failed,
}
# ------------------------------------------------------------------
@@ -351,6 +374,318 @@ class FusionHelpdeskController(http.Controller):
body += '</table>'
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):
return (

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
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
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 **/
// Fusion Helpdesk — submission dialog. Lets the user pick Bug or
// Feature, fill in subject + description, paste an error code, attach
// files, and capture a screenshot via the browser's getDisplayMedia
// API. On submit, the payload is POSTed to /fusion_helpdesk/submit
// which forwards it (XML-RPC) to a central Odoo Helpdesk.
// Fusion Helpdesk — submission + follow-up dialog.
//
// Two tabs:
// • New — report a bug / request a feature (the original form),
// plus a confirmed "Your email" field so support can reply.
// • My Tickets — a live RPC view of the user's tickets on the central
// Odoo: list → open one → read support's replies → reply
// inline, without ever leaving this Odoo or logging in.
//
// Tickets are NOT copied locally — every list/thread/reply is a live call
// to the central Helpdesk, scoped server-side to the logged-in user.
import { Component, useState } from "@odoo/owl";
import { Component, useState, onWillStart } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
@@ -18,16 +25,20 @@ export class FusionHelpdeskDialog extends Component {
static components = { Dialog };
static props = {
close: Function,
initialTab: { type: String, optional: true },
};
setup() {
this.notification = useService("notification");
this.state = useState({
kind: "bug", // 'bug' | 'feature'
tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread'
// ---- New report ----
kind: "bug",
subject: "",
description: "",
errorCode: "",
attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}]
replyEmail: user.login || "",
attachments: [],
capturing: false,
submitting: false,
error: "",
@@ -35,21 +46,146 @@ export class FusionHelpdeskDialog extends Component {
ticketId: null,
ticketUrl: "",
attached: 0,
failed: 0,
// ---- My Tickets ----
isAdmin: false,
scope: "mine", // 'mine' | 'all'
tickets: [],
loadingList: false,
listError: "",
// ---- Thread ----
current: null, // {id, subject, stage, portal_url, messages}
loadingThread: false,
threadError: "",
replyBody: "",
sendingReply: false,
});
onWillStart(async () => {
if (this.state.tab === "list") {
await this.loadList();
}
});
}
get dialogTitle() {
return this.state.kind === "bug"
? _t("Report a Bug")
: _t("Request a Feature");
if (this.state.tab === "thread" && this.state.current) {
return this.state.current.subject;
}
if (this.state.tab === "list") {
return _t("My Tickets");
}
return this.state.kind === "bug" ? _t("Report a Bug") : _t("Request a Feature");
}
// ------------------------------------------------------------------
// Tabs
async setTab(tab) {
this.state.tab = tab;
this.state.error = "";
if (tab === "list") {
await this.loadList();
}
}
setKind(kind) {
this.state.kind = kind;
}
// ------------------------------------------------------------------
// File input → b64
// ==================================================================
// My Tickets — list
// ==================================================================
async loadList() {
if (this.state.loadingList) return;
this.state.loadingList = true;
this.state.listError = "";
try {
const res = await rpc("/fusion_helpdesk/my_tickets", {
scope: this.state.scope,
});
if (!res.ok) {
this.state.listError = res.message || _t("Could not load your tickets.");
this.state.tickets = [];
} else {
this.state.tickets = res.tickets || [];
this.state.isAdmin = !!res.is_admin;
}
} catch (err) {
console.error("fusion_helpdesk: my_tickets failed", err);
this.state.listError = (err && err.message) || _t("Network error.");
} finally {
this.state.loadingList = false;
}
}
async setScope(scope) {
if (this.state.scope === scope) return;
this.state.scope = scope;
await this.loadList();
}
// ==================================================================
// My Tickets — thread
// ==================================================================
async openTicket(ticketId) {
this.state.loadingThread = true;
this.state.threadError = "";
this.state.replyBody = "";
try {
const res = await rpc(`/fusion_helpdesk/ticket/${ticketId}`, {});
if (!res.ok) {
this.state.threadError = res.message || _t("Could not open this ticket.");
return;
}
this.state.current = res.ticket;
this.state.tab = "thread";
// The ticket is now seen server-side; clear its unread flag locally.
const row = this.state.tickets.find((t) => t.id === ticketId);
if (row) {
row.has_unread = false;
}
} catch (err) {
console.error("fusion_helpdesk: open ticket failed", err);
this.state.threadError = (err && err.message) || _t("Network error.");
} finally {
this.state.loadingThread = false;
}
}
async backToList() {
this.state.current = null;
this.state.tab = "list";
await this.loadList(); // refresh stages / unread after viewing
}
async sendReply() {
const body = (this.state.replyBody || "").trim();
if (!body || this.state.sendingReply || !this.state.current) return;
this.state.sendingReply = true;
this.state.threadError = "";
try {
const res = await rpc(
`/fusion_helpdesk/ticket/${this.state.current.id}/reply`,
{ body }
);
if (!res.ok) {
this.state.threadError = res.message || _t("Could not send your reply.");
} else {
this.state.current.messages = res.messages || this.state.current.messages;
this.state.replyBody = "";
this.notification.add(_t("Reply sent."), { type: "success" });
}
} catch (err) {
console.error("fusion_helpdesk: send reply failed", err);
this.state.threadError = (err && err.message) || _t("Network error.");
} finally {
this.state.sendingReply = false;
}
}
// ==================================================================
// New report — files / screenshot (unchanged behaviour)
// ==================================================================
async onFilesPicked(ev) {
const files = Array.from(ev.target.files || []);
for (const f of files) {
@@ -75,7 +211,6 @@ export class FusionHelpdeskDialog extends Component {
);
}
}
// Reset the input so picking the same file again re-fires onchange.
ev.target.value = "";
}
@@ -92,8 +227,6 @@ export class FusionHelpdeskDialog extends Component {
});
}
// ------------------------------------------------------------------
// Screenshot capture via getDisplayMedia
async onTakeScreenshot() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
this.notification.add(
@@ -119,7 +252,6 @@ export class FusionHelpdeskDialog extends Component {
rawSize: blob.size,
});
} catch (err) {
// User cancelled the picker — silently swallow. Other errors → notify.
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
this.notification.add(
_t("Screenshot failed: %s").replace("%s", err.message || err),
@@ -138,7 +270,6 @@ export class FusionHelpdeskDialog extends Component {
const video = document.createElement("video");
video.srcObject = stream;
await video.play();
// Give the browser one frame to settle the picker chrome.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
@@ -195,8 +326,9 @@ export class FusionHelpdeskDialog extends Component {
return "fa fa-file-o";
}
// ------------------------------------------------------------------
// Submit
// ==================================================================
// New report — submit
// ==================================================================
async onSubmit() {
if (this.state.submitting) return;
const subject = (this.state.subject || "").trim();
@@ -213,6 +345,7 @@ export class FusionHelpdeskDialog extends Component {
subject,
description: this.state.description || "",
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
reply_email: (this.state.replyEmail || "").trim(),
attachments: this.state.attachments.map((a) => ({
name: a.name,
mimetype: a.mimetype,
@@ -229,13 +362,14 @@ export class FusionHelpdeskDialog extends Component {
this.state.ticketId = res.ticket_id;
this.state.ticketUrl = res.ticket_url;
this.state.attached = res.attached || 0;
// Reset the editable fields so user can file another if they want.
this.state.failed = res.failed || 0;
this.state.subject = "";
this.state.description = "";
this.state.errorCode = "";
this.state.attachments = [];
}
} catch (err) {
console.error("fusion_helpdesk: submit failed", err);
this.state.error = (err && err.message) || _t("Network error.");
} finally {
this.state.submitting = false;

View File

@@ -1,24 +1,52 @@
/** @odoo-module **/
// Fusion Helpdesk — top systray icon. Sequence chosen so the icon
// appears to the LEFT of the attendance check-in button. Odoo
// systray ordering is by sequence ascending (lower = leftmost in the
// systray bar). hr_attendance ships at sequence 100, so we use 99.
// Fusion Helpdesk — top systray icon with an unread-reply badge.
// Sequence 99 places it just left of the attendance check-in button.
import { Component } from "@odoo/owl";
import { Component, useState, onWillStart, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
const POLL_MS = 120000; // refresh the unread badge every 2 minutes
class FusionHelpdeskSystray extends Component {
static template = "fusion_helpdesk.SystrayItem";
static props = {};
setup() {
this.dialog = useService("dialog");
this.state = useState({ unread: 0 });
onWillStart(async () => {
await this._refreshUnread();
});
// Poll so a reply that lands while the user is working still
// surfaces without a page reload. Errors are swallowed server-side
// (the endpoint always returns a count) so the badge never breaks.
this._timer = setInterval(() => this._refreshUnread(), POLL_MS);
onWillUnmount(() => clearInterval(this._timer));
}
async _refreshUnread() {
try {
const res = await rpc("/fusion_helpdesk/unread_count", {});
this.state.unread = (res && res.count) || 0;
} catch {
// Network/config hiccup — leave the badge as-is, don't throw.
}
}
onClick() {
this.dialog.add(FusionHelpdeskDialog, {});
// If there are unread replies, drop straight into the inbox;
// otherwise open the New report form (the primary action).
const initialTab = this.state.unread > 0 ? "list" : "new";
this.dialog.add(
FusionHelpdeskDialog,
{ initialTab },
{ onClose: () => this._refreshUnread() }
);
}
}

View File

@@ -170,3 +170,170 @@ $fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
&:hover { color: #d32f2f; }
}
}
// Systray unread badge
.o_fhd_systray {
.o_fhd_systray_btn { position: relative; }
.o_fhd_systray_badge {
position: absolute;
top: -2px;
right: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background-color: #d9534f;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
}
// Inbox additions (tabs, list, thread) — share the dialog tokens above.
.o_fhd_dialog {
.o_fhd_muted { color: $fhd-muted; }
.o_fhd_tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid $fhd-border;
margin-bottom: 1rem;
}
.o_fhd_tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 0.5rem 0.9rem;
color: $fhd-muted;
cursor: pointer;
font-weight: 500;
&:hover { color: $fhd-text; }
&.o_fhd_tab_active {
color: $fhd-accent;
border-bottom-color: $fhd-accent;
}
}
.o_fhd_scope_row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.85rem;
}
// Ticket list
.o_fhd_ticket_list {
display: flex;
flex-direction: column;
border: 1px solid $fhd-border;
border-radius: 6px;
overflow: hidden;
}
.o_fhd_ticket_row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
background-color: $fhd-bg;
border-bottom: 1px solid $fhd-border;
cursor: pointer;
&:last-child { border-bottom: none; }
&:hover { background-color: $fhd-hover; }
}
.o_fhd_unread_dot {
width: 9px;
height: 9px;
border-radius: 50%;
background-color: $fhd-accent;
flex: 0 0 auto;
}
.o_fhd_unread_spacer { width: 9px; flex: 0 0 auto; }
.o_fhd_ticket_ref {
color: $fhd-muted;
font-variant-numeric: tabular-nums;
flex: 0 0 auto;
}
.o_fhd_ticket_subject {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_fhd_ticket_stage {
flex: 0 0 auto;
font-size: 0.78rem;
padding: 0.1rem 0.5rem;
border-radius: 10px;
background-color: $fhd-hover;
border: 1px solid $fhd-border;
color: $fhd-muted;
}
// Thread
.o_fhd_thread_head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.6rem;
}
.o_fhd_open_portal {
font-size: 0.85rem;
color: $fhd-accent;
text-decoration: none;
&:hover { text-decoration: underline; }
}
.o_fhd_thread {
display: flex;
flex-direction: column;
gap: 0.6rem;
max-height: 45vh;
overflow-y: auto;
padding: 0.25rem;
}
.o_fhd_msg {
border: 1px solid $fhd-border;
border-radius: 6px;
padding: 0.6rem 0.75rem;
background-color: $fhd-bg;
}
.o_fhd_msg_head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.3rem;
font-size: 0.82rem;
}
.o_fhd_msg_author { font-weight: 600; color: $fhd-text; }
.o_fhd_msg_date { color: $fhd-muted; font-variant-numeric: tabular-nums; }
.o_fhd_msg_body {
color: $fhd-text;
font-size: 0.9rem;
word-break: break-word;
p:last-child { margin-bottom: 0; }
}
.o_fhd_msg_attach {
margin-top: 0.4rem;
font-size: 0.8rem;
color: $fhd-muted;
}
}

View File

@@ -4,105 +4,225 @@
<t t-name="fusion_helpdesk.Dialog">
<Dialog title="dialogTitle" size="'lg'">
<div class="o_fhd_dialog">
<!-- Kind selector -->
<div class="o_fhd_kind_row">
<button type="button"
class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
t-on-click="() => this.setKind('bug')">
<i class="fa fa-bug me-1"/> Report a Bug
<!-- ===== 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_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
t-on-click="() => this.setKind('feature')">
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
<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>
<!-- Subject -->
<div class="o_fhd_field">
<label>Subject *</label>
<input type="text" class="form-control"
t-att-value="state.subject"
t-on-input="(ev) => state.subject = ev.target.value"
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
</div>
<!-- Description -->
<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'"/>
<textarea class="form-control" rows="5"
t-att-value="state.description"
t-on-input="(ev) => state.description = ev.target.value"
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
</div>
<!-- Error code (bug only) -->
<div class="o_fhd_field" t-if="state.kind === 'bug'">
<label>
Error code / traceback
<span class="o_fhd_hint">paste any error message or stack trace</span>
</label>
<textarea class="form-control o_fhd_mono" rows="3"
t-att-value="state.errorCode"
t-on-input="(ev) => state.errorCode = ev.target.value"
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
</div>
<!-- Attachments -->
<div class="o_fhd_field">
<label>Attachments</label>
<div class="o_fhd_actions_row">
<label class="o_fhd_btn o_fhd_btn_secondary">
<i class="fa fa-paperclip me-1"/> Attach files
<input type="file" multiple="multiple" class="d-none"
t-on-change="onFilesPicked"/>
</label>
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
t-on-click="onTakeScreenshot"
t-att-disabled="state.capturing">
<i class="fa fa-camera me-1"/>
<t t-if="state.capturing">Capturing…</t>
<t t-else="">Capture screenshot</t>
<!-- ===== NEW report ===== -->
<div t-if="state.tab === 'new'">
<div class="o_fhd_kind_row">
<button type="button" class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'bug' }"
t-on-click="() => this.setKind('bug')">
<i class="fa fa-bug me-1"/> Report a Bug
</button>
<button type="button" class="o_fhd_kind_chip"
t-att-class="{ 'o_fhd_kind_active': state.kind === 'feature' }"
t-on-click="() => this.setKind('feature')">
<i class="fa fa-lightbulb-o me-1"/> Request a Feature
</button>
</div>
<div t-if="state.attachments.length" class="o_fhd_attach_list">
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
class="o_fhd_attach_item">
<i t-att-class="att.iconClass"/>
<span class="o_fhd_attach_name" t-esc="att.name"/>
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
<button type="button" class="o_fhd_attach_remove"
t-on-click="() => this.removeAttachment(att_index)">×</button>
<div class="o_fhd_field">
<label>Subject *</label>
<input type="text" class="form-control"
t-att-value="state.subject"
t-on-input="(ev) => state.subject = ev.target.value"
t-att-placeholder="state.kind === 'bug' ? 'Short summary of what went wrong' : 'Short summary of the feature you want'"/>
</div>
<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">
<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"
t-att-value="state.description"
t-on-input="(ev) => state.description = ev.target.value"
placeholder="Steps to reproduce, expected vs. actual, business impact…"/>
</div>
<div class="o_fhd_field" t-if="state.kind === 'bug'">
<label>
Error code / traceback
<span class="o_fhd_hint">paste any error message or stack trace</span>
</label>
<textarea class="form-control o_fhd_mono" rows="3"
t-att-value="state.errorCode"
t-on-input="(ev) => state.errorCode = ev.target.value"
placeholder="e.g. TypeError: Cannot read property 'foo' of undefined …"/>
</div>
<div class="o_fhd_field">
<label>Attachments</label>
<div class="o_fhd_actions_row">
<label class="o_fhd_btn o_fhd_btn_secondary">
<i class="fa fa-paperclip me-1"/> Attach files
<input type="file" multiple="multiple" class="d-none"
t-on-change="onFilesPicked"/>
</label>
<button type="button" class="o_fhd_btn o_fhd_btn_secondary"
t-on-click="onTakeScreenshot"
t-att-disabled="state.capturing">
<i class="fa fa-camera me-1"/>
<t t-if="state.capturing">Capturing…</t>
<t t-else="">Capture screenshot</t>
</button>
</div>
<div t-if="state.attachments.length" class="o_fhd_attach_list">
<div t-foreach="state.attachments" t-as="att" t-key="att_index"
class="o_fhd_attach_item">
<i t-att-class="att.iconClass"/>
<span class="o_fhd_attach_name" t-esc="att.name"/>
<span class="o_fhd_attach_size" t-esc="att.sizeLabel"/>
<button type="button" class="o_fhd_attach_remove"
t-on-click="() => this.removeAttachment(att_index)">×</button>
</div>
</div>
</div>
<div t-if="state.error" class="alert alert-danger mt-2">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
</div>
<div t-if="state.success" class="alert alert-success mt-2">
<i class="fa fa-check-circle me-1"/>
Thanks — ticket
<a t-att-href="state.ticketUrl" target="_blank">#<t t-esc="state.ticketId"/></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>
<!-- ===== 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>
<!-- Result feedback -->
<div t-if="state.error" class="alert alert-danger mt-2">
<i class="fa fa-exclamation-triangle me-1"/> <t t-esc="state.error"/>
</div>
<div t-if="state.success" class="alert alert-success mt-2">
<i class="fa fa-check-circle me-1"/>
Thanks — ticket
<a t-att-href="state.ticketUrl" target="_blank">
#<t t-esc="state.ticketId"/>
</a> created<t t-if="state.attached"> with <t t-esc="state.attached"/> attachment(s)</t>.
<!-- ===== 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">
<button class="btn btn-primary"
t-on-click="onSubmit"
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-else=""><i class="fa fa-paper-plane me-1"/></t>
Submit
</button>
<button class="btn btn-secondary" t-on-click="props.close">
Close
</button>
<t t-if="state.tab === 'new'">
<button class="btn btn-primary" t-on-click="onSubmit"
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-else=""><i class="fa fa-paper-plane me-1"/></t>
Submit
</button>
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
</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 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>
</Dialog>
</t>

View File

@@ -5,11 +5,14 @@
<div class="o_fhd_systray dropdown">
<button type="button"
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">
<img src="/fusion_helpdesk/static/description/help_icon.png"
alt="Help"
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>
</div>
</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,131 @@
# -*- 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,
_norm_email,
)
@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)
@tagged('post_install', '-at_install', 'fusion_helpdesk')
class TestNormEmail(TransactionCase):
def test_valid_email_is_normalised_lowercase(self):
self.assertEqual(_norm_email('John@Entech.COM'), 'john@entech.com')
def test_first_valid_candidate_wins(self):
# confirmed reply email empty -> fall back to the next valid one
self.assertEqual(_norm_email('', 'not an email', 'jane@x.com'), 'jane@x.com')
def test_wildcard_is_rejected(self):
# IDOR guard: a self-set '%' must not survive as a scope key
self.assertEqual(_norm_email('%'), '')
def test_non_email_login_falls_through_to_empty(self):
self.assertEqual(_norm_email('admin', 'also-not-email', ''), '')
def test_controller_namespace_resolves_norm_email(self):
# Regression: _norm_email was called in controllers/main.py
# (submit + _identity) but never imported/defined -> NameError on
# every inbox endpoint. Guard that the name is resolvable there.
from odoo.addons.fusion_helpdesk.controllers import main
self.assertTrue(hasattr(main, '_norm_email'))

117
fusion_helpdesk/utils.py Normal file
View File

@@ -0,0 +1,117 @@
# -*- 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.
"""
from odoo.tools import email_normalize
# 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 _norm_email(*candidates):
"""Return the first candidate that normalises to a valid email, else ''.
Used to derive the inbox scope key from a chain of fallbacks (the
confirmed reply email -> ``user.email`` -> ``user.login``).
``email_normalize`` lowercases the address and returns a falsy value for
anything that is not exactly one valid email — including a self-set
wildcard like ``%`` — so the value fed into ``build_scope_domain`` can
never widen the scope. Pairs with :func:`escape_like` as defense in depth
against the ``partner_email =ilike`` IDOR.
"""
for candidate in candidates:
normalized = email_normalize(candidate or '')
if normalized:
return normalized
return ''
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