# -*- 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 bucket_ticket(stage_is_folded, priority): """Bucket key for the "My Tickets" inbox: 'critical' | 'solved' | 'open'. Folded stages (Solved, Cancelled — whatever the central support team has marked as kanban-folded) collapse into the 'solved' bucket regardless of priority, because a closed ticket is not actionable. For everything still open, High and Urgent (priority '2'/'3') promote to 'critical' so the reporter can find blockers without scrolling. Anything else is 'open'. """ if stage_is_folded: return 'solved' if (priority or '0') in ('2', '3'): return 'critical' return 'open' 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