# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 """Public portal routes for the owner-approval magic links. Owner clicks the Approve / Reject button in the email -> GET lands here. Page shows ticket title + AI summary + comment box + Confirm button. POST records the decision via helpdesk.ticket._fc_finalize_engagement. No login required; the UUID4 in the URL is the auth. Tokens are single- use (cleared on finalize), so the second click on the same link shows a friendly "link no longer valid" page instead of double-recording the decision. """ import logging from odoo import _, http from odoo.http import request _logger = logging.getLogger(__name__) class FusionHelpdeskEngagementController(http.Controller): # ------------------------------------------------------------------ # Token resolution — single source of truth for the GET + POST handlers. # ------------------------------------------------------------------ def _resolve(self, token, decision, claim=False): """Return (ticket, decision_state) or (None, None) on any problem. The "no problem" cases: - token is non-empty - decision is one of {'approve', 'reject'} - a single ticket matches the token AND is in state='pending' When `claim=True` (POST path), atomically clear the token via UPDATE ... RETURNING so two near-simultaneous clicks can't both succeed — the second SELECT-then-write would otherwise double-post the decision chatter and let the last writer flip the outcome. The atomic UPDATE makes single-use a property of the SQL, not of the application logic. When `claim=False` (GET path), we just look — no token rotation — so re-loading the confirm page before clicking still works. """ if not token or not isinstance(token, str): return (None, None) if decision not in ('approve', 'reject'): return (None, None) decision_state = 'approved' if decision == 'approve' else 'rejected' if claim: # Atomic claim: clear the token only if it's still pending. The # WHERE clause re-checks state so a re-engagement (which # rotates the token) between GET and POST won't be claimed. request.env.cr.execute( "UPDATE helpdesk_ticket " "SET x_fc_engagement_token = NULL " "WHERE x_fc_engagement_token = %s " " AND x_fc_engagement_state = 'pending' " "RETURNING id", (token,), ) row = request.env.cr.fetchone() if not row: return (None, None) # lost the race or token rotated ticket = request.env['helpdesk.ticket'].sudo().browse(row[0]) # Invalidate the ORM cache for the field we just changed via raw SQL. ticket.invalidate_recordset(['x_fc_engagement_token']) return (ticket, decision_state) # GET path — just look, don't claim. ticket = request.env['helpdesk.ticket'].sudo().search( [('x_fc_engagement_token', '=', token), ('x_fc_engagement_state', '=', 'pending')], limit=1, ) if not ticket: return (None, None) return (ticket, decision_state) # ------------------------------------------------------------------ # GET — render the confirmation page (or invalid-link page). # ------------------------------------------------------------------ @http.route( '/fusion_helpdesk/engagement//', type='http', auth='public', methods=['GET'], csrf=False, sitemap=False, ) def engagement_show(self, token, decision, **kw): ticket, decision_state = self._resolve(token, decision) if not ticket: return request.render( 'fusion_helpdesk_central.engagement_invalid', {}, ) return request.render( 'fusion_helpdesk_central.engagement_confirm', { 'ticket': ticket, 'decision': decision, # url-friendly string 'decision_state': decision_state, # 'approved' / 'rejected' 'token': token, }, ) # ------------------------------------------------------------------ # POST — record the decision, post chatter, clear token. # ------------------------------------------------------------------ @http.route( '/fusion_helpdesk/engagement//', type='http', auth='public', methods=['POST'], csrf=False, sitemap=False, ) def engagement_submit(self, token, decision, **post): # claim=True does an atomic UPDATE that clears the token only if # the row is still pending — wins the race against a second click. ticket, decision_state = self._resolve(token, decision, claim=True) if not ticket: # Could be a second click on the same link, a token rotated # by a re-engagement, or a typo. Same friendly page for all. return request.render( 'fusion_helpdesk_central.engagement_invalid', {}, ) comment = (post.get('comment') or '').strip() owner_partner = self._find_or_create_owner_partner(ticket) try: ticket._fc_finalize_engagement( decision_state, owner_partner, comment=comment or None, ) except Exception: _logger.exception( 'fusion_helpdesk_central: failed to finalize engagement ' 'for ticket %s (token=%s, decision=%s)', ticket.id, token, decision_state, ) return request.render( 'fusion_helpdesk_central.engagement_error', {}, ) return request.render( 'fusion_helpdesk_central.engagement_done', { 'ticket': ticket, 'decision_state': decision_state, }, ) # ------------------------------------------------------------------ def _find_or_create_owner_partner(self, ticket): """Resolve the res.partner used to attribute the chatter message. Find-or-create by snapshotted email — mirrors the customer-reply attribution pattern in fusion_helpdesk/controllers/main.py so the approval chatter shows up under a proper partner name (matters for the employee's My Tickets thread per the "fully visible" UX). Falls back to no author (= bot user) if email is empty or the partner create fails. """ email = (ticket.x_fc_engagement_email or '').strip().lower() name = (ticket.x_fc_engagement_name or '').strip() if not email: return None Partner = request.env['res.partner'].sudo() # Use exact match on lowercased email — the snapshot was already # normalised at engagement time. partner = Partner.search([('email', '=ilike', email)], order='id asc', limit=1) if partner: return partner try: return Partner.create({ 'name': name or email.split('@')[0].title(), 'email': email, }) except Exception: # ERROR not WARNING: a failed owner partner-create means the # approval chatter line will say "Approved by support@nexasystems.ca" # (the bot) instead of the actual owner — a real audit-trail # confusion that someone needs to look at. _logger.exception( 'fusion_helpdesk_central: could not create owner partner ' 'for %s on ticket %s; chatter author will fall back to the ' 'service account. Body text still names the owner correctly ' 'via format_engagement_chatter.', email, ticket.id, ) return None