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

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