diff --git a/CLAUDE.md b/CLAUDE.md index ac1b55b..fd2c10c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,7 +208,7 @@ areas: id, facility_id (FK), name, area_type, **`public_token`** (Phase 34): unguessable per-facility token encoded in the facility's QR code. The QR points at `/f/` — a **login-free** occupant summary page. `Facility.generate_public_token()` / `ensure_public_token()` mint one on demand; new facilities get one at creation, existing rows were backfilled by phase34. Rotating the token (regenerating it) invalidates any printed QR — intentional, for when a code is compromised. -**`Area.public_token`** (Phase 39): the same pattern applied per area. The QR points at `/f/area/` — a **login-free** occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with `area_id` set. `Area.generate_public_token()` / `ensure_public_token()` mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate + dates only — never checklist names, per-inspection scores, or severity/SLA). Routing: `/f/area/` and `/f/` do not collide (tokens are single-segment; `area` is a literal first segment). +**`Area.public_token`** (Phase 39): the same pattern applied per area. The QR points at `/f/area/` — a **login-free** occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with `area_id` set. `Area.generate_public_token()` / `ensure_public_token()` mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate quality only: rating, counts, trend, and recent inspections with date + quality label — never raw score percentages, checklist/template names, inspector names, per-item scores, or severity/SLA). Routing: `/f/area/` and `/f/` do not collide (tokens are single-segment; `area` is a literal first segment). **`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other` @@ -1344,7 +1344,7 @@ timeout = 30 | 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. | | 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. | | 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. | -| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only a quality rating, last-inspected date, and open-issue COUNT — **never** issue descriptions, inspector names, per-item scores, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, honeypot-guarded, and **idempotency-guarded** (`_recent_duplicate_report()` — an identical public report for the same facility/area within `DUPLICATE_REPORT_WINDOW_SECONDS`=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE `_save_report_photos()` to avoid orphaned uploads). The client also disables the submit button on first tap. Public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. **Photos:** the report form accepts **up to 5 photos** (`PublicIssueReportForm.photos`, a `MultipleFileField`); `_save_report_photos()` in `public.py` saves them via the shared magic-byte-validated `_save_photo()` (cap `MAX_REPORT_PHOTOS=5`) and stores the first in `Issue.photo_path`, the rest in `Issue.mobile_photo_paths` — never `result_photos` (rule 44), so they all render under "Photo Evidence". Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. | +| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only aggregate quality data: the overall rating, inspection/issue COUNTS, the score trend, and a **Recent Inspections** list showing each inspection's date + quality **label** (July 2026 — `_recent_rows()` shapes these). `_recent_rows()` deliberately does **not** put the raw score in the payload, so the percentage cannot leak into the rendered page; `_rating_label(None)` yields "Not yet rated" so unscored inspections render safely. Still **never**: raw per-inspection score percentages, issue descriptions, inspector names, checklist/template names, per-checklist-item scores, severity/SLA detail, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, honeypot-guarded, and **idempotency-guarded** (`_recent_duplicate_report()` — an identical public report for the same facility/area within `DUPLICATE_REPORT_WINDOW_SECONDS`=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE `_save_report_photos()` to avoid orphaned uploads). The client also disables the submit button on first tap. Public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. **Photos:** the report form accepts **up to 5 photos** (`PublicIssueReportForm.photos`, a `MultipleFileField`); `_save_report_photos()` in `public.py` saves them via the shared magic-byte-validated `_save_photo()` (cap `MAX_REPORT_PHOTOS=5`) and stores the first in `Issue.photo_path`, the rest in `Issue.mobile_photo_paths` — never `result_photos` (rule 44), so they all render under "Photo Evidence". Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. | | 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. | | 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. | | 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). | diff --git a/app/routes/public.py b/app/routes/public.py index 763f86b..0f9b951 100644 --- a/app/routes/public.py +++ b/app/routes/public.py @@ -110,12 +110,34 @@ def _rating_label(score): return ('Needs attention', 'danger') +def _recent_rows(inspections): + """Shape recent completed inspections for the occupant page. + + Each row carries the date and the occupant-friendly quality label/colour. + The raw score percentage is deliberately NOT included in the payload — the + occupant sees the quality label only, so the number cannot leak into the + rendered page. Also excludes inspector names, template/checklist names, + and any per-item detail (occupant-safe). + """ + rows = [] + for i in inspections: + score = float(i.overall_score) if i.overall_score is not None else None + label, colour = _rating_label(score) # None -> 'Not yet rated' + rows.append({ + 'date': i.inspection_date, + 'label': label, + 'colour': colour, + }) + return rows + + def _build_summary(facility: Facility) -> dict: """Assemble the occupant-facing summary for a facility. Occupant-safe (rule 74): aggregate rating, counts, a score trend, and - recent inspection DATES only — no checklist/template names, no - per-inspection scores, no issue descriptions, and no severity/SLA detail. + recent inspections showing date + quality LABEL only (no score + percentage) — no checklist/template names, no inspector names, no issue + descriptions, and no per-item or severity/SLA detail. """ fid = facility.id now = now_eastern() @@ -172,7 +194,8 @@ def _build_summary(facility: Facility) -> dict: else: trend_delta = None - # Recent inspection DATES only (no checklist names, no scores) + # Recent inspections: date + score + quality label (no checklist/template + # names, no inspector names, no per-item detail). recent = ( Inspection.query .filter(Inspection.facility_id == fid, @@ -181,7 +204,7 @@ def _build_summary(facility: Facility) -> dict: .limit(5) .all() ) - recent_dates = [i.inspection_date for i in recent] + recent_inspections = _recent_rows(recent) # Open-issue COUNT (linked directly or via an area) — no details exposed open_issue_count = ( @@ -213,9 +236,9 @@ def _build_summary(facility: Facility) -> dict: 'inspections_90': inspections_90, 'open_issue_count': open_issue_count, 'resolved_90': resolved_90, - 'trend_delta': trend_delta, - 'last_inspected': last_insp.inspection_date if last_insp else None, - 'recent_dates': recent_dates, + 'trend_delta': trend_delta, + 'last_inspected': last_insp.inspection_date if last_insp else None, + 'recent_inspections': recent_inspections, } @@ -297,7 +320,7 @@ def _build_area_summary(area, facility) -> dict: .limit(5) .all() ) - recent_dates = [i.inspection_date for i in recent] + recent_inspections = _recent_rows(recent) open_issue_count = ( Issue.query @@ -326,9 +349,9 @@ def _build_area_summary(area, facility) -> dict: 'inspections_90': inspections_90, 'open_issue_count': open_issue_count, 'resolved_90': resolved_90, - 'trend_delta': trend_delta, - 'last_inspected': last_insp.inspection_date if last_insp else None, - 'recent_dates': recent_dates, + 'trend_delta': trend_delta, + 'last_inspected': last_insp.inspection_date if last_insp else None, + 'recent_inspections': recent_inspections, } diff --git a/app/templates/public/area.html b/app/templates/public/area.html index d4c0b4c..1d5f8ab 100644 --- a/app/templates/public/area.html +++ b/app/templates/public/area.html @@ -102,14 +102,14 @@ {{ rating_label }} - {# ── Recent inspections (DATES ONLY — occupant-safe) ── #} - {% if recent_dates %} + {# ── Recent inspections (date + quality label, no % — occupant-safe) ── #} + {% if recent_inspections %}
Recent Inspections
- {% for d in recent_dates %} -
- {{ d.strftime('%b %d, %Y') }} - Completed + {% for r in recent_inspections %} +
+ {{ r.date.strftime('%b %d, %Y') }} + {{ r.label }}
{% endfor %}
This area is inspected regularly by our quality team.
diff --git a/app/templates/public/facility.html b/app/templates/public/facility.html index a4c19bf..50bcff4 100644 --- a/app/templates/public/facility.html +++ b/app/templates/public/facility.html @@ -103,13 +103,13 @@
{# ── Recent inspections (DATES ONLY — occupant-safe) ── #} - {% if recent_dates %} + {% if recent_inspections %}
Recent Inspections
- {% for d in recent_dates %} -
- {{ d.strftime('%b %d, %Y') }} - Completed + {% for r in recent_inspections %} +
+ {{ r.date.strftime('%b %d, %Y') }} + {{ r.label }}
{% endfor %}
This facility is inspected regularly by our quality team.