Jul 10 - Update documents
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Audience:** AI assistants and developers working on this codebase.
|
> **Audience:** AI assistants and developers working on this codebase.
|
||||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||||
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); MT-9 iOS pending**)
|
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); Reports R1+R2 contract cascade filter; billing emails branded From address; MT-9 iOS pending**)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ lt_janitorial_quality_control/
|
|||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
|
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
|
||||||
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
|
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
|
||||||
│ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23)
|
│ │ ├── support.py # SupportChatSession, SupportChatMessage, SupportKnowledge (phase40) + SupportTicket, SupportTicketReply (Phase 23)
|
||||||
│ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB)
|
│ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB)
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
@@ -1106,11 +1106,13 @@ Always use `user.display_name` in templates — never `.username` for display pu
|
|||||||
|
|
||||||
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
|
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
|
||||||
|
|
||||||
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar).
|
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar), `reports/issues_aging.html` (filter bar), `reports/sla_compliance.html` (filter bar).
|
||||||
|
|
||||||
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
|
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
|
||||||
|
|
||||||
**Customer role — contract filter scoping:** In `inspections.index()` and `issues.index()`, the `projects` list passed to the template is scoped to contracts the customer is assigned to via `CustomerAssignment`. Non-customer roles still receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
|
The **Reports R1 (Issues Aging) and R2 (SLA Compliance)** filter bars include a Contract cascade dropdown that is **client-side only** — selecting a contract calls `GET /inspections/facilities_for_project/<id>` to narrow the facility list in the browser; the actual DB filter still uses only `facility_id`. The route passes `projects` to the template (all active projects, scoped to the customer's assigned facilities when the role is `customer`). The cascade JS is guarded by `{% if projects %}` so it is omitted for empty lists (e.g. a customer with no facility assignments).
|
||||||
|
|
||||||
|
**Customer role — contract filter scoping:** In `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()`, the `projects` list passed to the template is scoped to contracts whose facilities overlap the customer's assigned facility set. Non-customer roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
|
||||||
|
|
||||||
### Customer Dashboard — "Your Facilities" Panel
|
### Customer Dashboard — "Your Facilities" Panel
|
||||||
|
|
||||||
@@ -1196,6 +1198,8 @@ All report pages include `{% include 'reports/_subnav.html' %}` as the first ele
|
|||||||
|
|
||||||
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `1–3 days`, `3–7 days`, `1–4 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
|
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `1–3 days`, `3–7 days`, `1–4 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
|
||||||
|
|
||||||
|
**Contract cascade filter:** A client-side Contract `<select>` (no name attribute — not submitted) appears above the Facility dropdown. On change, JS calls `GET /inspections/facilities_for_project/<id>` to narrow the Facility list in-browser; clearing the contract restores all options. The route passes `projects` (all active, or scoped to customer facility set). The actual DB filter uses only `facility_id`.
|
||||||
|
|
||||||
Excel export: `GET /reports/export/issues-aging` — one sheet, color-coded severity and SLA columns.
|
Excel export: `GET /reports/export/issues-aging` — one sheet, color-coded severity and SLA columns.
|
||||||
|
|
||||||
Helper: `_load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter)` — extracted so both the HTML route and the Excel export share identical query logic.
|
Helper: `_load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter)` — extracted so both the HTML route and the Excel export share identical query logic.
|
||||||
@@ -1207,6 +1211,8 @@ Loads resolved issues in the date range, computes `within_sla()` per issue (comp
|
|||||||
- `by_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
|
- `by_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
|
||||||
- `by_facility` — list sorted by compliance % descending
|
- `by_facility` — list sorted by compliance % descending
|
||||||
|
|
||||||
|
**Contract cascade filter:** Same client-side Contract → Facility cascade as R1. Route passes `projects`; DB filter uses only `facility_id`.
|
||||||
|
|
||||||
Helper: `_sla_within(issue)` — used by both the HTML route and the Excel export.
|
Helper: `_sla_within(issue)` — used by both the HTML route and the Excel export.
|
||||||
|
|
||||||
Excel export: `GET /reports/export/sla-compliance` — 2 sheets: **By Severity** (with totals row) and **By Facility**.
|
Excel export: `GET /reports/export/sla-compliance` — 2 sheets: **By Severity** (with totals row) and **By Facility**.
|
||||||
@@ -1227,12 +1233,15 @@ A **PDF Summary** button was added to `reports/scorecard.html` alongside the exi
|
|||||||
|
|
||||||
### Support Chat — Customer UX
|
### Support Chat — Customer UX
|
||||||
|
|
||||||
`GET /support/chat` — customer only. Renders:
|
`GET /support/chat` — customer only. Accepts optional `?session_id=N` to reload a prior conversation.
|
||||||
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks).
|
|
||||||
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
|
Renders:
|
||||||
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
|
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks). Greeting is hidden when loading a prior session (`{% if not db_history %}`).
|
||||||
|
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. Hidden when a prior session is loaded. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
|
||||||
|
- **Chat history is DB-backed (phase40).** Prior turns are rendered server-side on page load from `db_history` (list of `SupportChatMessage`). The JS variable `let session_id` is seeded from `chat_session.id` (null for new chats). AJAX sends only `{ message, session_id }` — **no history array** (rule 95). Server returns `{ reply, session_id }` and the JS stores/reuses `session_id` across subsequent messages.
|
||||||
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
|
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
|
||||||
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
|
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from `last_user_msg` JS variable (last message typed, not scanned from history array).
|
||||||
|
- "History" button links to `support.my_conversations` (list of all past sessions). "New Chat" link starts a fresh session (`/support/chat` with no `session_id`).
|
||||||
|
|
||||||
### Inspection Execute Page — UX Patterns
|
### Inspection Execute Page — UX Patterns
|
||||||
|
|
||||||
@@ -1381,7 +1390,7 @@ set -a; . /etc/jqc/control.env; set +a
|
|||||||
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
|
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
|
||||||
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
|
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
|
||||||
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
|
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
|
||||||
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()` and `issues.index()` build the `projects` list differently for `customer` role: query `CustomerAssignment.query.filter_by(user_id=current_user.id)` to get assigned `project_id` values, then filter `Project` to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in `inspections.start()`. |
|
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()` build the `projects` list differently for `customer` role. Inspections/issues use `CustomerAssignment` to get assigned `project_id` values. Reports use a join: `Project.query.join(Facility).filter(Facility.id.in_(customer_facility_ids))`. All other roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in any Contract filter dropdown. |
|
||||||
| 64 | **Invitation email sender and link domain are derived from `request.host_url`** | `_send_invite_email(user, token, base_url=None)` in `customers.py` accepts an optional `base_url`. Both call sites (`invite` and `resend_invite`) pass `request.host_url`. Inside the function, `effective_base` is built from that value (falling back to `APP_BASE_URL`); `setup_link` uses `effective_base`; `sender` is `noreply@<netloc>` parsed from `effective_base`. The SMTP server and credentials are unchanged — only the `From` address and link URL vary per domain. |
|
| 64 | **Invitation email sender and link domain are derived from `request.host_url`** | `_send_invite_email(user, token, base_url=None)` in `customers.py` accepts an optional `base_url`. Both call sites (`invite` and `resend_invite`) pass `request.host_url`. Inside the function, `effective_base` is built from that value (falling back to `APP_BASE_URL`); `setup_link` uses `effective_base`; `sender` is `noreply@<netloc>` parsed from `effective_base`. The SMTP server and credentials are unchanged — only the `From` address and link URL vary per domain. |
|
||||||
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
|
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
|
||||||
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
|
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
|
||||||
@@ -1408,6 +1417,8 @@ set -a; . /etc/jqc/control.env; set +a
|
|||||||
| 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. |
|
| 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. |
|
||||||
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
|
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
|
||||||
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
|
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
|
||||||
|
| 97 | **`send_billing_email()` derives the From address from `APP_BASE_URL` — same pattern as rule 64** | `urlparse(app.config['APP_BASE_URL']).netloc` is extracted before the background thread starts and passed as `sender=f'noreply@{netloc}'` to `Message()`. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is absent or yields an empty netloc (`sender=None` triggers Flask-Mail's default). Do not hardcode a sender string or duplicate the derivation logic — extend via `send_billing_email()` only. |
|
||||||
|
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1616,7 +1627,9 @@ Stripe-backed subscription billing. Controlled by `BILLING_ENABLED` env var (def
|
|||||||
|
|
||||||
### Billing emails (`app/billing/emails.py`)
|
### Billing emails (`app/billing/emails.py`)
|
||||||
|
|
||||||
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text.
|
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text in a background thread.
|
||||||
|
|
||||||
|
**Sender derivation:** Before launching the thread, `urlparse(app.config['APP_BASE_URL']).netloc` is extracted and the From address is set to `noreply@<netloc>`. This mirrors rule 64 (invitation emails) so billing emails carry the correct tenant domain in the From header rather than a hardcoded address. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is unset or unparseable (`sender=None` passes `None` to `Message()`, triggering Flask-Mail's default).
|
||||||
|
|
||||||
| `event_type` | Trigger | Required context keys |
|
| `event_type` | Trigger | Required context keys |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
Reference in New Issue
Block a user