diff --git a/CLAUDE.md b/CLAUDE.md index 3c4377f..07c3374 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | |---|---|---|---| | `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. @@ -582,7 +582,7 @@ Management of the underlying routes is otherwise unchanged; **Start** is the **a | Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ | | Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ | | Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only | -| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ | +| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ (both customer roles) | | Support Tickets (manage) | ✅ | ✅ | ❌ | ❌ | own only | | Reports | ✅ | ✅ | ✅ | ✅ | scoped | | 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 | | `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 //start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST //acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/` (**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/` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/` (staff, read-only), `GET /admin/knowledge` + `/new`, `//edit`, `//delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | +| `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/` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/` (staff, read-only), `GET /admin/knowledge` + `/new`, `//edit`, `//delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | | `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/` (detail + office-use fields), `GET /admin/.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) | | `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). | | 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. `
` 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. | -| 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. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues//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. | diff --git a/app/routes/support.py b/app/routes/support.py index edce8dd..c5e1b94 100644 --- a/app/routes/support.py +++ b/app/routes/support.py @@ -12,7 +12,7 @@ from app.models.support import (SupportTicket, SupportTicketReply, from app.models.user import User from app.models.facility import Facility 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.time_utils import now_eastern 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?'}, ] +#: 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. _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): """Best-effort scrub of email/phone/SSN/card-like sequences from outbound text.""" if not text: @@ -190,6 +271,18 @@ def _redact_pii(text): _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(): """Return the base system prompt with all ACTIVE admin knowledge entries (phase38) spliced in, so staff can curate the chatbot's knowledge without @@ -238,13 +331,10 @@ def _system_prompt_with_kb(): @bp.route('/chat') @login_required def chat(): - if current_user.role != 'customer': + if not _is_customer_side(current_user): return redirect(url_for('support.admin_tickets')) - cids = get_customer_scope(current_user) or [] - facilities = (Facility.query - .filter(Facility.id.in_(cids), Facility.active == True) - .order_by(Facility.name).all()) if cids else [] + facilities = _support_facilities(current_user) # Load the customer's most recent conversation so it continues on return. # 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')) + faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS return render_template('support/chat.html', - faqs=FAQS, + faqs=faqs, facilities=facilities, groq_ready=groq_ready, chat_session_id=(session.id if session else None), @@ -276,7 +367,7 @@ def chat(): @bp.route('/chat/message', methods=['POST']) @login_required def chat_message(): - if current_user.role != 'customer': + if not _is_customer_side(current_user): return jsonify({'error': 'Forbidden'}), 403 data = request.get_json(silent=True) or {} @@ -297,7 +388,7 @@ def chat_message(): from groq import Groq 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). # Redact PII-shaped text before it leaves the app for the Groq API — # the unredacted originals stay in support_chat_messages below. @@ -347,7 +438,7 @@ def chat_message(): @bp.route('/my-conversations') @login_required def my_conversations(): - if current_user.role != 'customer': + if not _is_customer_side(current_user): abort(403) sessions = (SupportChatSession.query .filter_by(customer_id=current_user.id) @@ -359,7 +450,7 @@ def my_conversations(): @bp.route('/my-conversations/') @login_required def conversation_detail(session_id): - if current_user.role != 'customer': + if not _is_customer_side(current_user): abort(403) session = db.session.get(SupportChatSession, session_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']) @login_required def submit_ticket(): - if current_user.role != 'customer': + if not _is_customer_side(current_user): abort(403) subject = request.form.get('subject', '').strip() @@ -385,8 +476,9 @@ def submit_ticket(): flash('Please fill in both subject and description.', 'warning') return redirect(url_for('support.chat')) - # Validate facility belongs to this customer - cids = get_customer_scope(current_user) or [] + # Validate facility belongs to this user — by whichever assignment table + # their role is scoped through. + cids = [f.id for f in _support_facilities(current_user)] if facility_id and facility_id not in cids: facility_id = None @@ -416,7 +508,7 @@ def submit_ticket(): @bp.route('/my-tickets') @login_required def my_tickets(): - if current_user.role != 'customer': + if not _is_customer_side(current_user): abort(403) tickets = (SupportTicket.query @@ -431,7 +523,7 @@ def my_tickets(): @bp.route('/my-tickets/', methods=['GET', 'POST']) @login_required def my_ticket_detail(ticket_id): - if current_user.role != 'customer': + if not _is_customer_side(current_user): abort(403) ticket = db.session.get(SupportTicket, ticket_id) diff --git a/app/templates/layouts/classic.html b/app/templates/layouts/classic.html index 6f1550b..02775c5 100644 --- a/app/templates/layouts/classic.html +++ b/app/templates/layouts/classic.html @@ -199,7 +199,11 @@ {% 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 %}