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

@@ -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() }
);
}
}