diff --git a/fusion_repairs/__init__.py b/fusion_repairs/__init__.py index 866992e6..31007106 100644 --- a/fusion_repairs/__init__.py +++ b/fusion_repairs/__init__.py @@ -4,3 +4,4 @@ from . import models from . import wizard +from . import controllers diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index bb20e0a0..c6450219 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -80,6 +80,9 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'views/res_partner_views.xml', 'views/res_users_views.xml', 'views/res_config_settings_views.xml', + # Portal templates + 'views/portal_sales_rep_templates.xml', + 'views/portal_client_repair_templates.xml', # Wizard 'wizard/repair_intake_wizard_views.xml', # Menus (last, after all referenced actions exist) @@ -90,7 +93,9 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. # Phase 2+: history_sidebar.js, signature_pad.js, etc. ], 'web.assets_frontend': [ - # Phase 1+: portal_client_repair.js etc. + 'fusion_repairs/static/src/scss/portal_repair_mobile.scss', + 'fusion_repairs/static/src/scss/portal_client_repair.scss', + 'fusion_repairs/static/src/js/portal_repair_intake.js', ], }, 'images': ['static/description/icon.png'], diff --git a/fusion_repairs/controllers/__init__.py b/fusion_repairs/controllers/__init__.py new file mode 100644 index 00000000..cd1bd59e --- /dev/null +++ b/fusion_repairs/controllers/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import portal_sales_rep_repair +from . import portal_client_repair diff --git a/fusion_repairs/controllers/portal_client_repair.py b/fusion_repairs/controllers/portal_client_repair.py new file mode 100644 index 00000000..96ddd9c3 --- /dev/null +++ b/fusion_repairs/controllers/portal_client_repair.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Public client self-service portal at /repair. + +Phase 1 scope (no AI yet): +- /repair Landing page with "Start" CTA +- /repair/new Multi-step form +- /repair/submit POST -> creates repair.order via shared intake service +- /repair/thanks Confirmation with reference +- /repair/lookup_phone jsonrpc safe partner match (masked PII) + +Security: +- Public auth (no login) - the voicemail prompts mention this URL +- Per-IP rate limit on submit (configurable) +- Honeypot + CSRF +- Phone lookup returns ONLY masked name + address slice (never other PII) +- Records created via sudo in the controller; record rules don't apply + because anonymous users don't have a session + +Phase 2+ will add: AI self-check, upsell engine, smart SMS verify, +safety on-call paging, reCAPTCHA v3. +""" + +import base64 +import hashlib +import logging +import re +import time + +from odoo import http, fields +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +# In-memory rate-limit window per worker. Good enough for Phase 1 +# and matches the project's "no extra infra" goal. Resets on restart. +_RATE_LIMIT_BUCKET = {} + + +def _now_hour_bucket(): + return int(time.time() // 3600) + + +def _mask_partner_for_lookup(partner): + """Return ONLY safe summary fields - never the full partner record.""" + name = partner.name or "" + # First name + last initial; never reveal full surname. + if " " in name: + first, last = name.split(" ", 1) + safe_name = f"{first} {(last or ' ')[:1]}." + else: + safe_name = name + return { + "matched": True, + "name": safe_name, + "city": partner.city or "", + } + + +def _e164_clean(phone): + if not phone: + return "" + return re.sub(r"[^\d+]", "", phone)[-12:] + + +class ClientRepairPortal(http.Controller): + + # ------------------------------------------------------------------ + # RATE LIMIT + # ------------------------------------------------------------------ + def _check_rate_limit(self): + ICP = request.env["ir.config_parameter"].sudo() + try: + limit = int(ICP.get_param( + "fusion_repairs.client_portal_rate_limit_per_hour", "10" + )) + except (ValueError, TypeError): + limit = 10 + # Use remote_addr from the proxy header if present. + ip = ( + request.httprequest.headers.get("X-Forwarded-For") + or request.httprequest.remote_addr + or "unknown" + ) + ip = ip.split(",")[0].strip() + bucket = _now_hour_bucket() + key = f"{ip}:{bucket}" + # Prune old buckets (cheap - dict is small). + for k in list(_RATE_LIMIT_BUCKET.keys()): + if not k.endswith(f":{bucket}"): + _RATE_LIMIT_BUCKET.pop(k, None) + _RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1 + if _RATE_LIMIT_BUCKET[key] > limit: + return True # blocked + return False + + # ------------------------------------------------------------------ + # LANDING + # ------------------------------------------------------------------ + @http.route("/repair", type="http", auth="public", website=True, sitemap=True) + def repair_landing(self, **kw): + return request.render("fusion_repairs.portal_client_repair_landing", { + "page_name": "client_repair_landing", + }) + + @http.route("/repair/new", type="http", auth="public", website=True, + sitemap=False) + def repair_new(self, sn=None, **kw): + categories = request.env["fusion.repair.product.category"].sudo().search([ + ("active", "=", True), + ], order="sequence, name") + prefilled_serial = (sn or "").strip() + return request.render("fusion_repairs.portal_client_repair_form", { + "page_name": "client_repair_new", + "categories": categories, + "prefilled_serial": prefilled_serial, + "error": kw.get("error"), + }) + + # ------------------------------------------------------------------ + # SAFE PARTNER LOOKUP (anti-leak) + # ------------------------------------------------------------------ + @http.route("/repair/lookup_phone", type="jsonrpc", auth="public", + website=True) + def repair_lookup_phone(self, phone=None, **kw): + if self._check_rate_limit(): + return {"error": "rate_limited"} + cleaned = _e164_clean(phone) + if len(cleaned) < 7: + return {"matched": False} + matches = request.env["res.partner"].sudo().search([ + "|", + ("phone", "ilike", cleaned[-7:]), + ("phone_sanitized", "ilike", cleaned[-7:]), + ], limit=1) + if matches: + return _mask_partner_for_lookup(matches[0]) + return {"matched": False} + + # ------------------------------------------------------------------ + # SUBMIT + # ------------------------------------------------------------------ + @http.route("/repair/submit", type="http", auth="public", methods=["POST"], + csrf=True, website=True) + def repair_submit(self, **post): + # Honeypot - bots tend to fill every visible field. + if (post.get("hp_company") or "").strip(): + _logger.info("Client portal submit blocked by honeypot from IP=%s", + request.httprequest.remote_addr) + return request.redirect("/repair/new?error=spam") + + if self._check_rate_limit(): + return request.redirect("/repair/new?error=rate_limited") + + # Required fields. + partner_name = (post.get("client_name") or "").strip() + phone = (post.get("client_phone") or "").strip() + issue_summary = (post.get("issue_summary") or "").strip() + category_id = int(post.get("category_id") or 0) + + if not (partner_name and phone and issue_summary and category_id): + return request.redirect("/repair/new?error=missing") + + # Find or create partner. Match by phone if known (safe - we already + # have their consent to contact via this form). + cleaned_phone = _e164_clean(phone) + partner = False + if len(cleaned_phone) >= 7: + partner = request.env["res.partner"].sudo().search([ + "|", + ("phone", "ilike", cleaned_phone[-7:]), + ("phone_sanitized", "ilike", cleaned_phone[-7:]), + ], limit=1) + + partner_vals = None + if not partner: + partner_vals = { + "name": partner_name, + "phone": phone, + "email": (post.get("client_email") or "").strip(), + "street": (post.get("client_street") or "").strip(), + "city": (post.get("client_city") or "").strip(), + } + + # Stage uploaded photos. + files = request.httprequest.files.getlist("photos") + attachment_ids = [] + for f in files or []: + if not getattr(f, "filename", None): + continue + data = f.read() + if not data: + continue + attachment_ids.append(request.env["ir.attachment"].sudo().create({ + "name": f.filename, + "datas": base64.b64encode(data), + "res_model": "fusion.repair.intake.session", + "res_id": 0, + }).id) + + equipment = { + "repair_category_id": category_id, + "third_party": post.get("third_party") in ("on", "true", "1"), + "urgency": post.get("urgency") or "normal", + "issue_summary": issue_summary, + "internal_notes": (post.get("internal_notes") or "").strip(), + "photo_attachment_ids": attachment_ids, + } + payload = { + "partner_id": partner.id if partner else None, + "partner_vals": partner_vals, + "intake_user_id": request.env.ref( + "base.user_admin", raise_if_not_found=False).id + if request.env.ref("base.user_admin", + raise_if_not_found=False) else 1, + "equipment_items": [equipment], + } + + try: + repairs = request.env["fusion.repair.intake.service"].sudo() \ + .create_repair_orders(payload, source="client_portal") + except Exception: + _logger.exception("Client portal repair submit failed") + return request.redirect("/repair/new?error=server") + + token = hashlib.sha256( + f"{repairs[0].id}:{repairs[0].create_date}".encode() + ).hexdigest()[:16] + return request.redirect(f"/repair/thanks?ref={repairs[0].name}&t={token}") + + @http.route("/repair/thanks", type="http", auth="public", website=True, + sitemap=False) + def repair_thanks(self, ref=None, t=None, **kw): + return request.render("fusion_repairs.portal_client_repair_thanks", { + "page_name": "client_repair_thanks", + "ref": ref or "", + }) diff --git a/fusion_repairs/controllers/portal_sales_rep_repair.py b/fusion_repairs/controllers/portal_sales_rep_repair.py new file mode 100644 index 00000000..55434216 --- /dev/null +++ b/fusion_repairs/controllers/portal_sales_rep_repair.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Sales rep portal for repair intake. + +Sales reps marked `is_sales_rep_portal` on their partner can: +- /my/repair/new - submit a new service call from their phone +- /my/repairs - list of repairs they have submitted +- /my/repair/ - read-only detail with status timeline + +All routes are gated by the is_sales_rep_portal flag and use the SAME +shared intake service (`fusion.repair.intake.service`) as the backend +wizard - so behaviour stays consistent across surfaces. +""" + +import base64 +import logging + +from odoo import http, fields +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal + +_logger = logging.getLogger(__name__) + + +class SalesRepRepairPortal(CustomerPortal): + + # ------------------------------------------------------------------ + # ACCESS GATE + # ------------------------------------------------------------------ + def _check_sales_rep_access(self): + partner = request.env.user.partner_id + if not getattr(partner, 'is_sales_rep_portal', False): + return request.redirect('/my') + return None + + def _staged_attachment_ids_from_files(self, files): + """Stage uploaded files as ir.attachment records and return their IDs.""" + ids = [] + for f in files or []: + if not getattr(f, 'filename', None): + continue + data = f.read() + if not data: + continue + attachment = request.env['ir.attachment'].sudo().create({ + 'name': f.filename, + 'datas': base64.b64encode(data), + 'res_model': 'fusion.repair.intake.session', + 'res_id': 0, + }) + ids.append(attachment.id) + return ids + + # ------------------------------------------------------------------ + # NEW SERVICE CALL FORM + # ------------------------------------------------------------------ + @http.route('/my/repair/new', type='http', auth='user', website=True, sitemap=False) + def portal_repair_new(self, **kw): + gate = self._check_sales_rep_access() + if gate: + return gate + + categories = request.env['fusion.repair.product.category'].sudo().search([ + ('active', '=', True), + ], order='sequence, name') + + return request.render('fusion_repairs.portal_sales_rep_repair_form', { + 'page_name': 'repair_new', + 'categories': categories, + 'default_partner': False, + 'submitted': False, + }) + + @http.route('/my/repair/lookup_partner', type='jsonrpc', auth='user', website=True) + def portal_repair_lookup_partner(self, query=None, **kw): + gate = self._check_sales_rep_access() + if gate: + return {'error': 'access'} + if not query or len(query) < 3: + return {'matches': []} + Partner = request.env['res.partner'].sudo() + matches = Partner.search([ + '|', '|', + ('name', 'ilike', query), + ('phone', 'ilike', query), + ('email', 'ilike', query), + ], limit=8) + return { + 'matches': [{ + 'id': p.id, + 'name': p.name or '', + 'phone': p.phone or '', + 'email': p.email or '', + 'street': p.street or '', + 'city': p.city or '', + 'repair_count': p.x_fc_repair_count, + } for p in matches], + } + + @http.route('/my/repair/submit', type='http', auth='user', methods=['POST'], + csrf=True, website=True) + def portal_repair_submit(self, **post): + gate = self._check_sales_rep_access() + if gate: + return gate + + partner_id = int(post.get('partner_id') or 0) + if not partner_id: + return request.redirect('/my/repair/new?error=partner') + + # Build single-equipment payload from the form. Multi-equipment loop + # is supported by adding more equipment_* groups in Phase 2. + files = request.httprequest.files.getlist('photos') + attachment_ids = self._staged_attachment_ids_from_files(files) + + equipment = { + 'repair_category_id': int(post.get('category_id') or 0) or False, + 'product_id': int(post.get('product_id') or 0) or False, + 'third_party': post.get('third_party') in ('on', 'true', '1'), + 'urgency': post.get('urgency') or 'normal', + 'issue_summary': (post.get('issue_summary') or '').strip(), + 'issue_category': (post.get('issue_category') or '').strip(), + 'internal_notes': (post.get('internal_notes') or '').strip(), + 'photo_attachment_ids': attachment_ids, + } + + payload = { + 'partner_id': partner_id, + 'intake_user_id': request.env.uid, + 'equipment_items': [equipment], + } + + try: + repairs = request.env['fusion.repair.intake.service'].sudo() \ + .create_repair_orders(payload, source='sales_rep_portal') + except Exception as e: + _logger.exception('Sales rep portal repair submit failed') + return request.redirect('/my/repair/new?error=server') + + return request.redirect('/my/repair/%d?thanks=1' % repairs[0].id) + + # ------------------------------------------------------------------ + # MY REPAIRS LIST + DETAIL + # ------------------------------------------------------------------ + @http.route(['/my/repairs', '/my/repairs/page/'], type='http', + auth='user', website=True) + def portal_repairs_list(self, page=1, **kw): + gate = self._check_sales_rep_access() + if gate: + return gate + + Repair = request.env['repair.order'].sudo() + domain = [('x_fc_intake_user_id', '=', request.env.uid)] + + total = Repair.search_count(domain) + page_size = 20 + offset = (page - 1) * page_size + repairs = Repair.search(domain, order='create_date desc', + limit=page_size, offset=offset) + + return request.render('fusion_repairs.portal_sales_rep_repair_list', { + 'page_name': 'repairs_list', + 'repairs': repairs, + 'total': total, + 'page': page, + 'page_size': page_size, + }) + + @http.route('/my/repair/', type='http', auth='user', + website=True) + def portal_repair_detail(self, repair_id, thanks=None, **kw): + gate = self._check_sales_rep_access() + if gate: + return gate + + repair = request.env['repair.order'].sudo().browse(repair_id).exists() + if not repair or repair.x_fc_intake_user_id.id != request.env.uid: + return request.redirect('/my/repairs') + + return request.render('fusion_repairs.portal_sales_rep_repair_detail', { + 'page_name': 'repair_detail', + 'repair': repair, + 'thanks': bool(thanks), + }) diff --git a/fusion_repairs/security/security.xml b/fusion_repairs/security/security.xml index 79401dba..479b6e5f 100644 --- a/fusion_repairs/security/security.xml +++ b/fusion_repairs/security/security.xml @@ -53,11 +53,12 @@ - + Repair Order: Technician sees own repairs - [('x_fc_technician_task_ids.all_technician_ids', 'in', [user.id])] + ['|', ('x_fc_technician_task_ids.technician_id', '=', user.id), ('x_fc_technician_task_ids.additional_technician_ids', 'in', [user.id])] @@ -73,4 +74,16 @@ + + + Repair Order: Sales Rep Portal - Own Repairs + + [('x_fc_intake_user_id', '=', user.id)] + + + + + + + diff --git a/fusion_repairs/static/src/js/portal_repair_intake.js b/fusion_repairs/static/src/js/portal_repair_intake.js new file mode 100644 index 00000000..7cd6b3ee --- /dev/null +++ b/fusion_repairs/static/src/js/portal_repair_intake.js @@ -0,0 +1,107 @@ +/** @odoo-module **/ +// Sales rep portal - new service call form interactions. +// Uses Odoo 19 public Interaction class per project frontend rules +// (NOT IIFE / DOMContentLoaded). Uses only safe DOM construction +// (textContent + createElement) - no innerHTML, no XSS risk. + +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; + +export class SalesRepRepairIntake extends Interaction { + static selector = ".o_fusion_repairs_portal"; + + dynamicContent = { + "#partner_search": { + "t-on-input": this._onPartnerSearchInput, + }, + }; + + setup() { + this._partnerSearchTimer = null; + } + + _onPartnerSearchInput(ev) { + const query = (ev.target.value || "").trim(); + if (this._partnerSearchTimer) { + clearTimeout(this._partnerSearchTimer); + } + if (query.length < 3) { + this._renderMatches([]); + return; + } + this._partnerSearchTimer = setTimeout(async () => { + try { + const result = await rpc("/my/repair/lookup_partner", { query }); + this._renderMatches(result.matches || []); + } catch (e) { + this._renderMatches([]); + } + }, 250); + } + + _renderMatches(matches) { + const list = document.getElementById("partner_matches"); + if (!list) { + return; + } + while (list.firstChild) { + list.removeChild(list.firstChild); + } + for (const m of matches) { + list.appendChild(this._buildMatchItem(m)); + } + } + + _buildMatchItem(m) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "list-group-item list-group-item-action text-start"; + + const nameStrong = document.createElement("strong"); + nameStrong.textContent = m.name || ""; + item.appendChild(nameStrong); + + if (m.phone) { + const phone = document.createElement("span"); + phone.className = "text-muted ms-2"; + phone.textContent = m.phone; + item.appendChild(phone); + } + + if (m.repair_count) { + const badge = document.createElement("span"); + badge.className = "badge bg-secondary ms-2"; + badge.textContent = `${m.repair_count} repair(s)`; + item.appendChild(badge); + } + + if (m.street) { + const addr = document.createElement("div"); + addr.className = "small text-muted"; + addr.textContent = [m.street, m.city].filter(Boolean).join(", "); + item.appendChild(addr); + } + + item.addEventListener("click", () => this._selectPartner(m)); + return item; + } + + _selectPartner(m) { + document.getElementById("partner_id_input").value = m.id; + document.getElementById("partner_selected_name").textContent = + m.name + (m.phone ? ` (${m.phone})` : ""); + document + .getElementById("partner_selected") + .classList.remove("d-none"); + const list = document.getElementById("partner_matches"); + while (list.firstChild) { + list.removeChild(list.firstChild); + } + document.getElementById("partner_search").value = m.name; + } +} + +registry + .category("public.interactions") + .add("fusion_repairs.sales_rep_intake", SalesRepRepairIntake); diff --git a/fusion_repairs/static/src/scss/portal_client_repair.scss b/fusion_repairs/static/src/scss/portal_client_repair.scss new file mode 100644 index 00000000..7caaf549 --- /dev/null +++ b/fusion_repairs/static/src/scss/portal_client_repair.scss @@ -0,0 +1,39 @@ +/* Public client portal - mobile-first. + * Follows project SCSS rules: no hardcoded theme colours, large tap targets, + * adapts to website light/dark theme automatically. + */ + +.o_fusion_repairs_client { + .form-control, + .form-select, + .btn { + min-height: 44px; + } + + .btn-lg { + min-height: 56px; + font-size: 1.125rem; + } + + .card { + border-radius: 0.75rem; + } + + h1.display-5, + h2 { + line-height: 1.2; + } + + @media (max-width: 575px) { + section { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .card-footer { + position: sticky; + bottom: 0; + background: inherit; + z-index: 2; + } + } +} diff --git a/fusion_repairs/static/src/scss/portal_repair_mobile.scss b/fusion_repairs/static/src/scss/portal_repair_mobile.scss new file mode 100644 index 00000000..3ca83da4 --- /dev/null +++ b/fusion_repairs/static/src/scss/portal_repair_mobile.scss @@ -0,0 +1,39 @@ +/* Sales rep portal - mobile-first additions. + * Follows project CLAUDE.md rules: + * - Tap targets >=44px + * - No hardcoded theme colours + * - Cards float on a slightly grayer page background + */ + +.o_fusion_repairs_portal { + .card { + border-radius: 0.5rem; + } + + .form-control, + .form-select, + .btn { + min-height: 44px; + } + + .btn-lg { + min-height: 52px; + } + + #partner_matches { + .list-group-item { + padding: 0.75rem 1rem; + cursor: pointer; + } + } + + /* Sticky bottom CTA on small screens for the submit form. */ + @media (max-width: 575px) { + .card-footer { + position: sticky; + bottom: 0; + background: inherit; + z-index: 2; + } + } +} diff --git a/fusion_repairs/views/portal_client_repair_templates.xml b/fusion_repairs/views/portal_client_repair_templates.xml new file mode 100644 index 00000000..9bd42347 --- /dev/null +++ b/fusion_repairs/views/portal_client_repair_templates.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fusion_repairs/views/portal_sales_rep_templates.xml b/fusion_repairs/views/portal_sales_rep_templates.xml new file mode 100644 index 00000000..755fc5d4 --- /dev/null +++ b/fusion_repairs/views/portal_sales_rep_templates.xml @@ -0,0 +1,281 @@ + + + + + + + + + + + + + + + + + + +