Aug 19 - Update: customer Inspector AI support chat

This commit is contained in:
2026-08-19 11:16:47 -04:00
parent 5df105a9d5
commit 070e9be993
4 changed files with 120 additions and 24 deletions
+4 -4
View File
@@ -206,7 +206,7 @@ Both roles below belong to the **customer**, not to us. They are the two seats t
| Stored ENUM value | Display label | Scoped by | Capabilities | | Stored ENUM value | Display label | Scoped by | Capabilities |
|---|---|---|---| |---|---|---|---|
| `customer` | **Customer Director** | `CustomerAssignment` (contract **or** single facility) | The portal, unchanged — read-mostly, own-facility issues/comments/follow-up requests | | `customer` | **Customer Director** | `CustomerAssignment` (contract **or** single facility) | The portal, unchanged — read-mostly, own-facility issues/comments/follow-up requests |
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` (whole contracts only) | Identical to the internal `inspector`, limited to their contracts | | `external_inspector` | **Customer Inspector** | `InspectorAssignment` (whole contracts only) | Identical to the internal `inspector`, limited to their contracts, **plus the customer support surface** (AI chat + tickets) — see §7 `support` |
**This is a LABEL-only rename** — the same posture as rule 19 ("Project" → "Contract"). The ENUM values are unchanged, so phase51 needed **no user migration** and moved none of the ~63 `external_inspector` call sites or the many `role == 'customer'` checks. `User.ROLE_LABELS` is the one place the names live. **This is a LABEL-only rename** — the same posture as rule 19 ("Project" → "Contract"). The ENUM values are unchanged, so phase51 needed **no user migration** and moved none of the ~63 `external_inspector` call sites or the many `role == 'customer'` checks. `User.ROLE_LABELS` is the one place the names live.
@@ -582,7 +582,7 @@ Management of the underlying routes is otherwise unchanged; **Start** is the **a
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ | | Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ | | Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only | | Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only |
| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ | | Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ (both customer roles) |
| Support Tickets (manage) | ✅ | ✅ | ❌ | ❌ | own only | | Support Tickets (manage) | ✅ | ✅ | ❌ | ❌ | own only |
| Reports | ✅ | ✅ | ✅ | ✅ | scoped | | Reports | ✅ | ✅ | ✅ | ✅ | scoped |
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ | | Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
@@ -623,7 +623,7 @@ Management of the underlying routes is otherwise unchanged; **Start** is the **a
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
| `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (`@schedule_manager_required` — admin/director/PM/auditor **plus Customer Directors, scoped to their own contracts**), `GET /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | | `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (`@schedule_manager_required` — admin/director/PM/auditor **plus Customer Directors, scoped to their own contracts**), `GET /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
| `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` | | `support` | `/support` | **Customer-facing routes serve BOTH customer roles** (`_is_customer_side()`, Aug 2026) — a Customer Inspector gets the same chat, conversations and tickets, with facilities resolved through `InspectorAssignment` and a role-specific addendum on the AI system prompt. `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/<id>` (detail + office-use fields), `GET /admin/<id>.json`, `GET /admin/export.csv`. Lives in `app/enrollment/` with its own templates; touches **no** DB table. | | `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/<id>` (detail + office-use fields), `GET /admin/<id>.json`, `GET /admin/export.csv`. Lives in `app/enrollment/` with its own templates; touches **no** DB table. |
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) | | `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
@@ -1705,7 +1705,7 @@ timeout = 30
| 94 | **A failed flag-issue POST must return a non-2xx** | The offcanvas JS branches on `res.ok`, so a 200 re-render of the invalid form reads as success: the panel closes, the page reloads, and no issue exists — with nothing in the logs and no message to the user. `flag_issue()` returns 400 on a failed POST for exactly this reason. Any future AJAX-submitted form needs the same treatment (rule 60 is the same failure seen from the other end). | | 94 | **A failed flag-issue POST must return a non-2xx** | The offcanvas JS branches on `res.ok`, so a 200 re-render of the invalid form reads as success: the panel closes, the page reloads, and no issue exists — with nothing in the logs and no message to the user. `flag_issue()` returns 400 on a failed POST for exactly this reason. Any future AJAX-submitted form needs the same treatment (rule 60 is the same failure seen from the other end). |
| 91 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table in the bulk form nests the per-row delete/unfollow forms inside it, and browsers **silently discard** nested forms (rule 9) — the row buttons would post nothing, with no console error and no server log. `<form id="issuesBulkForm">` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). | | 91 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table in the bulk form nests the per-row delete/unfollow forms inside it, and browsers **silently discard** nested forms (rule 9) — the row buttons would post nothing, with no console error and no server log. `<form id="issuesBulkForm">` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). |
| 92 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. Deleting files first means a failed/rolled-back commit leaves surviving rows pointing at missing photos. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths precisely so the two cannot drift — a key missed there is an invisible permanent storage leak. | | 92 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. Deleting files first means a failed/rolled-back commit leaves surviving rows pointing at missing photos. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths precisely so the two cannot drift — a key missed there is an invisible permanent storage leak. |
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, the `auth.list_users` exclusion, and **narrowing** uses that WITHHOLD something from an external account (`_assignable_staff_for()` uses it to hide our internal staff — safe direction, and commented as such). Everything else — portal gates, `@customer_required`, `get_customer_scope()`, support chat, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). | | 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, the `auth.list_users` exclusion, **the customer-facing support surface** (`_is_customer_side()` — both roles get the same door, then branch per role for scope and for the AI's system prompt), and **narrowing** uses that WITHHOLD something from an external account (`_assignable_staff_for()` uses it to hide our internal staff — safe direction, and commented as such). Everything else — portal gates, `@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
| 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. | | 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. |
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
+108 -16
View File
@@ -12,7 +12,7 @@ from app.models.support import (SupportTicket, SupportTicketReply,
from app.models.user import User from app.models.user import User
from app.models.facility import Facility from app.models.facility import Facility
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from app.utils.notifications import notify from app.utils.notifications import notify
@@ -150,6 +150,16 @@ FAQS = [
{'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'}, {'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'},
] ]
#: Extra chips shown to a Customer Inspector, whose questions are about doing
#: the work rather than reading the results. Appended to FAQS, not replacing
#: them — they still care about scores and issues.
INSPECTOR_FAQS = [
{'icon': 'bi-clipboard-plus', 'text': 'How do I start an inspection on the iPad?'},
{'icon': 'bi-wifi-off', 'text': 'What happens if I lose signal during an inspection?'},
{'icon': 'bi-flag', 'text': 'How do I flag an issue while inspecting?'},
{'icon': 'bi-search', 'text': "Why can't I see a form for this facility?"},
]
# Soft cap on injected knowledge to keep prompt size (and token cost) reasonable. # Soft cap on injected knowledge to keep prompt size (and token cost) reasonable.
_KB_MAX_CHARS = 6000 _KB_MAX_CHARS = 6000
@@ -172,6 +182,77 @@ _PII_PATTERNS = [
] ]
def _is_customer_side(user):
"""True for both customer-side roles — Director and Customer Inspector.
The AI assistant and the ticket flow are for the CUSTOMER organisation, and
a Customer Inspector is part of it: they work at the customer's facilities
and have the same questions about scores, issues and the app. This is one
of the few places where User.CUSTOMER_ROLES is the right test; every
capability/scoping decision below still branches per role (see
_support_facilities and _role_addendum), which is what keeps rule 89
intact the two roles get the same DOOR, not the same answers.
"""
return getattr(user, 'is_customer_account', False)
def _support_facilities(user):
"""The facilities this user may pick on a support ticket.
Directors are scoped by CustomerAssignment, Customer Inspectors by
InspectorAssignment reusing the customer helper for both would silently
return nothing for an inspector (it returns None for any non-'customer'
role) and the facility dropdown would come up empty.
"""
if getattr(user, 'is_inspector', False):
fids = get_inspector_scope(user) or []
else:
fids = get_customer_scope(user) or []
if not fids:
return []
return (Facility.query
.filter(Facility.id.in_(fids), Facility.active == True)
.order_by(Facility.name).all())
#: Appended to the system prompt for a Customer Inspector. The base prompt is
#: written for the read-mostly portal customer and explicitly tells the model
#: NOT to describe staff actions; without this the assistant would deny a
#: Customer Inspector the very things they are employed to do.
_INSPECTOR_ADDENDUM = """\
=== ABOUT THE PERSON YOU ARE TALKING TO: CUSTOMER INSPECTOR ===
This user works FOR the customer but holds an inspecting role in JQC, limited to \
the contracts they have been assigned. This section OVERRIDES the "only describe \
what a customer can do" restriction above, for this user only.
Everything above about the portal still applies to their assigned facilities. IN \
ADDITION, they can:
- Conduct inspections themselves start one on the web (Inspections New \
Inspection) or in the JQC iPad app, fill in the checklist form, add photos, and \
submit it.
- Use the iPad app OFFLINE: inspections and photos are stored on the device and \
sync automatically when back online.
- Flag an issue during an inspection, and log new issues at their facilities.
- Assign an issue to an inspector working on the SAME contract (their own \
colleagues, or the provider's inspectors) — never to anyone outside it.
- Update an issue's status, add comments, and set "Handled By" \
(Janitorial Staff / Facility Staff / External Vendor) from the iPad.
- Work from Scheduled Inspections assigned to them: confirm receipt of the \
request, then Start it when they are on site.
They CANNOT: verify or close out issues (the provider's admin/director does that), \
manage users, create or edit inspection forms, change the notification matrix, or \
see anything outside their assigned contracts. If they ask for one of those, say \
who to ask instead their own Customer Director, or the provider's team via \
"Submit to Support".
Note on forms: the inspection forms they can choose from are the shared standard \
forms plus any built specifically for their contract. A form built for a different \
customer will never appear.
"""
def _redact_pii(text): def _redact_pii(text):
"""Best-effort scrub of email/phone/SSN/card-like sequences from outbound text.""" """Best-effort scrub of email/phone/SSN/card-like sequences from outbound text."""
if not text: if not text:
@@ -190,6 +271,18 @@ def _redact_pii(text):
_STYLE_MARKER = '=== STYLE & RULES ===' _STYLE_MARKER = '=== STYLE & RULES ==='
def _system_prompt_for(user):
"""Base prompt plus the addendum for this user's role, then the KB.
Kept separate from _system_prompt_with_kb() so the curated knowledge base
still lands at the same marker regardless of role.
"""
prompt = _system_prompt_with_kb()
if getattr(user, 'is_external_inspector', False):
prompt += _INSPECTOR_ADDENDUM
return prompt
def _system_prompt_with_kb(): def _system_prompt_with_kb():
"""Return the base system prompt with all ACTIVE admin knowledge entries """Return the base system prompt with all ACTIVE admin knowledge entries
(phase38) spliced in, so staff can curate the chatbot's knowledge without (phase38) spliced in, so staff can curate the chatbot's knowledge without
@@ -238,13 +331,10 @@ def _system_prompt_with_kb():
@bp.route('/chat') @bp.route('/chat')
@login_required @login_required
def chat(): def chat():
if current_user.role != 'customer': if not _is_customer_side(current_user):
return redirect(url_for('support.admin_tickets')) return redirect(url_for('support.admin_tickets'))
cids = get_customer_scope(current_user) or [] facilities = _support_facilities(current_user)
facilities = (Facility.query
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
# Load the customer's most recent conversation so it continues on return. # Load the customer's most recent conversation so it continues on return.
# A ?new=1 param (New conversation button) starts a fresh, empty window. # A ?new=1 param (New conversation button) starts a fresh, empty window.
@@ -263,8 +353,9 @@ def chat():
] ]
groq_ready = bool(os.environ.get('GROQ_API_KEY')) groq_ready = bool(os.environ.get('GROQ_API_KEY'))
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
return render_template('support/chat.html', return render_template('support/chat.html',
faqs=FAQS, faqs=faqs,
facilities=facilities, facilities=facilities,
groq_ready=groq_ready, groq_ready=groq_ready,
chat_session_id=(session.id if session else None), chat_session_id=(session.id if session else None),
@@ -276,7 +367,7 @@ def chat():
@bp.route('/chat/message', methods=['POST']) @bp.route('/chat/message', methods=['POST'])
@login_required @login_required
def chat_message(): def chat_message():
if current_user.role != 'customer': if not _is_customer_side(current_user):
return jsonify({'error': 'Forbidden'}), 403 return jsonify({'error': 'Forbidden'}), 403
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
@@ -297,7 +388,7 @@ def chat_message():
from groq import Groq from groq import Groq
client = Groq(api_key=api_key) client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}] messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
# Append prior conversation (cap at last 20 turns to control token usage). # Append prior conversation (cap at last 20 turns to control token usage).
# Redact PII-shaped text before it leaves the app for the Groq API — # Redact PII-shaped text before it leaves the app for the Groq API —
# the unredacted originals stay in support_chat_messages below. # the unredacted originals stay in support_chat_messages below.
@@ -347,7 +438,7 @@ def chat_message():
@bp.route('/my-conversations') @bp.route('/my-conversations')
@login_required @login_required
def my_conversations(): def my_conversations():
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
sessions = (SupportChatSession.query sessions = (SupportChatSession.query
.filter_by(customer_id=current_user.id) .filter_by(customer_id=current_user.id)
@@ -359,7 +450,7 @@ def my_conversations():
@bp.route('/my-conversations/<int:session_id>') @bp.route('/my-conversations/<int:session_id>')
@login_required @login_required
def conversation_detail(session_id): def conversation_detail(session_id):
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
session = db.session.get(SupportChatSession, session_id) session = db.session.get(SupportChatSession, session_id)
if session is None or session.customer_id != current_user.id: if session is None or session.customer_id != current_user.id:
@@ -374,7 +465,7 @@ def conversation_detail(session_id):
@bp.route('/tickets', methods=['POST']) @bp.route('/tickets', methods=['POST'])
@login_required @login_required
def submit_ticket(): def submit_ticket():
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
subject = request.form.get('subject', '').strip() subject = request.form.get('subject', '').strip()
@@ -385,8 +476,9 @@ def submit_ticket():
flash('Please fill in both subject and description.', 'warning') flash('Please fill in both subject and description.', 'warning')
return redirect(url_for('support.chat')) return redirect(url_for('support.chat'))
# Validate facility belongs to this customer # Validate facility belongs to this user — by whichever assignment table
cids = get_customer_scope(current_user) or [] # their role is scoped through.
cids = [f.id for f in _support_facilities(current_user)]
if facility_id and facility_id not in cids: if facility_id and facility_id not in cids:
facility_id = None facility_id = None
@@ -416,7 +508,7 @@ def submit_ticket():
@bp.route('/my-tickets') @bp.route('/my-tickets')
@login_required @login_required
def my_tickets(): def my_tickets():
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
tickets = (SupportTicket.query tickets = (SupportTicket.query
@@ -431,7 +523,7 @@ def my_tickets():
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST']) @bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required @login_required
def my_ticket_detail(ticket_id): def my_ticket_detail(ticket_id):
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
ticket = db.session.get(SupportTicket, ticket_id) ticket = db.session.get(SupportTicket, ticket_id)
+5 -1
View File
@@ -199,7 +199,11 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if current_user.role == 'customer' %} {# Both customer-side roles get the Support menu — a Customer
Inspector works at the customer's facilities and has the
same questions. The assistant answers them for their own
role (see _role addendum in routes/support.py). #}
{% if current_user.is_customer_account %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}" <a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
+3 -3
View File
@@ -15,7 +15,7 @@
{# ── Live support routes (role-aware) ──────────────────────────────────── #} {# ── Live support routes (role-aware) ──────────────────────────────────── #}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
{% if current_user.role == 'customer' %} {% if current_user.is_customer_account %}
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}"> <a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center"> <div class="d-flex gap-3 align-items-center">
@@ -115,13 +115,13 @@
</div> </div>
{% endfor %} {% endfor %}
{# The AI chat is customer-only (support.chat redirects staff to the ticket {# The AI chat is for customer-side accounts (support.chat redirects staff to the ticket
queue, which is itself @supervisor_required). So the destination is chosen queue, which is itself @supervisor_required). So the destination is chosen
per role rather than pointed at support.chat for everyone — an inspector per role rather than pointed at support.chat for everyone — an inspector
following that chain would land on the dashboard with an access-denied following that chain would land on the dashboard with an access-denied
flash, and this page is meant never to dead-end. Roles with no support flash, and this page is meant never to dead-end. Roles with no support
destination get no card; the how-to guides below are their support. #} destination get no card; the how-to guides below are their support. #}
{% if current_user.role == 'customer' %} {% if current_user.is_customer_account %}
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-md-6 col-xl-4">
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}"> <a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center"> <div class="d-flex gap-3 align-items-center">