This commit is contained in:
2026-05-19 11:53:25 -04:00
16 changed files with 596 additions and 67 deletions
+73 -17
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
> **Last reviewed:** May 2026 (Phase B complete — iPad offline inspection app; Critical/Notable hardening pass)
> **Last reviewed:** May 2026 (Phase 18 complete — reported_by on issues, issue_flagged notification prefs, dashboard follow-up query fix, customer join refactor)
---
@@ -44,7 +44,7 @@
- **Reports** — on-demand PDF/CSV scorecards and scheduled email digests
- **Audit trail** — immutable log of every create/update/delete action
- **Mobile API** — JWT-authenticated REST layer for the iPad native app
- **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B complete)
- **iPad native app** — SwiftUI + SwiftData offline-first inspection tool (Phase A + B + C complete)
The application is actively deployed in production and maintained by a single developer/administrator.
@@ -236,22 +236,26 @@ inspections: id, template_id, facility_id, area_id, inspector_id, inspection_dat
### Issue
```
issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critical),
issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity (low/medium/high/critical),
description, photo_path, status (open/in_progress/resolved/pending_verification),
assigned_to, reported_at, resolved_at, result_notes, result_photos (JSON),
assigned_to, reported_by (nullable FK → users, SET NULL on delete),
reported_at, resolved_at, result_notes, result_photos (JSON),
verified_by, verified_at, verification_note, sla_notified,
mobile_local_id VARCHAR(64) nullable indexed ← Phase B
```
**`mobile_local_id`:** Same idempotency pattern as `inspections.mobile_local_id`.
**`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue — on the web (`current_user.id`) and via the mobile API (`g.api_user.id`). Nullable for backward compatibility; pre-phase18 rows have `NULL`. Used by `GET /api/v1/issues` to return issues the inspector created but hasn't been assigned yet.
### Notification / NotificationPreference
```
notifications: id, user_id, title, body, link, is_read, created_at, issue_id, event_type
notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
inspection_id, event_type VARCHAR(50) NULL, digest_pending
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
```
**`event_type`:** Added in phase17. Stored by `notify()` and returned by `GET /api/v1/notifications` so the iPad can categorise alerts. `NULL` for notifications created before the migration.
### NotificationMatrix
```
@@ -328,9 +332,10 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
| `api_auth` | `/api/v1` | `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/devices/register` |
| `api_facilities` | `/api/v1` | `/facilities`, `/facilities/<id>/areas` |
| `api_templates` | `/api/v1` | `/templates`, `/templates/<id>` |
| `api_inspections` | `/api/v1` | `POST /inspections`, `PATCH /inspections/<id>` |
| `api_issues` | `/api/v1` | `POST /issues` |
| `api_inspections` | `/api/v1` | `GET /inspections`, `POST /inspections`, `PATCH /inspections/<id>` |
| `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status` |
| `api_photos` | `/api/v1` | `POST /photos/upload` |
| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` |
---
@@ -340,7 +345,7 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
`now_eastern()` — always use this, never `datetime.utcnow()`.
### `audit.py`
`log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`.
`log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. **This function calls `db.session.commit()` internally.** Calling it before the primary commit will prematurely persist any dirty ORM state in the session.
### `scope.py`
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for staff.
@@ -349,7 +354,7 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
All WTForms classes. `AreaForm.area_type` includes `floor`. `UserForm` excludes `customer` role.
### `notifications.py`
`notify()`, `notify_by_matrix()`, `notify_customers_for_facility()` — all email sent in background thread.
`notify()`, `notify_by_matrix()`, `notify_customers_for_facility()` — all email sent in background thread. `notify()` stores `event_type` on the `Notification` record (phase17+) so the mobile API can return it to the iPad for categorisation. `flag_followup` route calls `notify()` for the original inspector so they receive a follow-up request notification on the iPad.
### `sla.py`
`sla_status(issue)``'ok'` | `'at_risk'` | `'breached'` | `None` (resolved).
@@ -359,7 +364,7 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
---
## 9. Mobile API (Phase 7 / Phase A / Phase B)
## 9. Mobile API (Phase 7 / Phase A / Phase B / Phase C)
### CSRF Exemption Pattern — Critical
@@ -373,16 +378,18 @@ from app.api.templates import bp as _api_templates_bp
from app.api.inspections import bp as _api_inspections_bp
from app.api.issues import bp as _api_issues_bp
from app.api.photos import bp as _api_photos_bp
from app.api.notifications import bp as _api_notifications_bp
csrf.exempt(_api_auth_bp)
csrf.exempt(_api_facilities_bp)
csrf.exempt(_api_templates_bp)
csrf.exempt(_api_inspections_bp)
csrf.exempt(_api_issues_bp)
csrf.exempt(_api_photos_bp)
csrf.exempt(_api_notifications_bp)
register_api(app)
```
**Every new Phase C+ blueprint must add its own `csrf.exempt()` line here before `register_api(app)`.** Failing to do so produces a `"The CSRF token is missing."` error on all POST requests to that blueprint.
**Every new Phase D+ blueprint must add its own `csrf.exempt()` line here before `register_api(app)`.** Failing to do so produces a `"The CSRF token is missing."` error on all POST requests to that blueprint.
### Auth Flow
1. `POST /api/v1/auth/login` → access token (60 min JWT) + refresh token (30 day opaque hex)
@@ -416,6 +423,27 @@ Customer role is blocked from template endpoints (`_ALLOWED_ROLES` check). Facil
| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id` |
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` |
### Phase C Endpoints
| Endpoint | Auth | Description |
|---|---|---|
| `GET /api/v1/inspections` | jwt_required | Inspector's own inspection history (paginated) |
| `GET /api/v1/issues` | jwt_required | Issues assigned to current user (inspectors); all non-resolved (admin/director/PM) |
| `GET /api/v1/issues/<id>` | jwt_required | Single issue detail — inspectors scoped to assigned only |
| `PATCH /api/v1/issues/<id>/status` | jwt_required | Update issue status — inspectors scoped to assigned only |
| `GET /api/v1/notifications` | jwt_required | Unread notifications for current user; accepts `?since=<ISO 8601>` |
| `PATCH /api/v1/notifications/mark-read` | jwt_required | Mark list of notification IDs as read |
### Issue API Scope Rules
- **Inspector:** `GET /issues` returns issues where `assigned_to == current_user.id` **OR** `reported_by == current_user.id`. This ensures issues the inspector created on the iPad appear even before a director assigns them. `GET /issues/<id>` and `PATCH /issues/<id>/status` enforce the same combined check.
- **Admin / Director / Project Manager:** `GET /issues` returns all non-resolved issues (default) or filtered by `?status=`.
- `_issue_payload()` returns: `id`, `status`, `severity`, `description`, `assigned_to`, `facility_id`, `facility_name`, `reported_at`, `resolved_at`, `mobile_local_id`, `photo_path`, `result_photos`.
### Notification API — OperationalError Safety
`GET /api/v1/notifications` wraps the ORM query in `try/except sqlalchemy.exc.OperationalError`. If the `event_type` column does not yet exist (phase17 migration not run), it falls back to a raw-SQL query that omits the column and returns `"event_type": null`. This keeps the endpoint functional before and after the migration.
### Idempotency Pattern
All Phase B write endpoints accept `mobile_local_id` (UUID string from device). On receipt:
@@ -502,6 +530,9 @@ Inspector action → SwiftData write (always succeeds) → SyncQueue entry
- **Sequential reference data fetch:** `pullReferenceData()` uses sequential `await` (not `async let`) to avoid Swift 6 actor-isolation warnings on `Decodable` structs.
- **Photo-before-inspection ordering:** `processPhotoQueue` runs before `processInspectionQueue`. An inspection is only submitted after all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"failed"`.
- **Retry limit:** 5 retries per item before marking `syncStatus = "failed"`.
- **`pullAssignedIssues()`:** Fetches `GET /api/v1/issues` and upserts into SwiftData keyed by `serverId`. Records pulled from server carry `syncStatus = "synced"` and `inspectionLocalId = ""` so `processIssueQueue` never re-submits them. **Deletion pass runs after upsert** — records with `syncStatus == "synced"` AND `inspectionLocalId == ""` whose `serverId` is absent from the server response are deleted. This removes issues that were reassigned to another inspector. The empty-response case is not short-circuited, so unassignment is always handled.
- **Notification polling:** `pollNotifications()` is called at the end of every `triggerSync()` and also on a 60-second `Task.sleep` loop started by `startPollTask()`. Uses `lastNotificationFetch` as a cursor (`?since=` param) so only new notifications are fetched. Marks fetched IDs read on server after local delivery.
- **`Task.sleep` not `Timer.scheduledTimer`:** `Timer.scheduledTimer` requires `RunLoop.main` to be ticking; inside a Swift Concurrency `Task { @MainActor }` block `RunLoop.current` is not `RunLoop.main` and the timer fires never. Always use `Task.sleep` for periodic work in SyncManager.
### APIClient Key Behaviours
@@ -548,12 +579,19 @@ All types from the web app's `INPUT_FIELD_TYPES` set are rendered:
### Event Constants (`app/models/notification.py`)
```
inspection_completed, issue_assigned, issue_reassigned, issue_unassigned,
issue_status, issue_comment, issue_follow_update, issue_flagged, issue_created,
issue_updated_customer, verification_requested, sla_alert,
customer_inspection_completed
EVENT_ISSUE_ASSIGNED = 'issue_assigned'
EVENT_ISSUE_STATUS = 'issue_status'
EVENT_ISSUE_COMMENT = 'issue_comment'
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
EVENT_INSPECTION_DONE = 'inspection_completed'
EVENT_SLA_ALERT = 'sla_alert'
EVENT_ISSUE_FLAGGED = 'issue_flagged' ← added; must be in ALL_EVENT_TYPES
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
```
**`ALL_EVENT_TYPES`** is the authoritative dict for the preferences UI. Every `event_type` string passed to `notify()` or `notify_by_matrix()` must have a matching entry here — missing entries cause that event to be invisible in the preferences form.
### Cron Endpoints (all require `token=DIGEST_SECRET`)
| Endpoint | Purpose | Schedule |
@@ -620,7 +658,9 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase9_user_full_name → phase10_customer_password_setup → phase11_director_role
→ phase12_performance_indexes → phase_b_mobile_local_id
→ phase13_issue_facility → phase14_facility_created_at
→ phase15_audit_log_indexes → phase16_notifications_columns ← HEAD
→ phase15_audit_log_indexes → phase16_notifications_columns
→ phase17_notification_event_type
→ phase18_issue_reported_by ← HEAD
```
### phase_b_mobile_local_id
@@ -647,6 +687,14 @@ Uses `INFORMATION_SCHEMA.STATISTICS` existence checks — safe to re-run.
Ensures `digest_pending TINYINT NOT NULL DEFAULT 0` and `inspection_id INT NULL FK` exist on the `notifications` table. Both columns are defined in the model but were absent from any prior migration because the `notifications` table predates the chain. Uses `INFORMATION_SCHEMA` existence checks — safe to re-run.
### phase17_notification_event_type
Adds `event_type VARCHAR(50) NULL` to the `notifications` table. Required for `GET /api/v1/notifications` to return event type to the iPad so it can categorise alerts. The API endpoint catches `OperationalError` and falls back to raw SQL before this migration runs. Uses `INFORMATION_SCHEMA` existence check — safe to re-run.
### phase18_issue_reported_by
Adds `reported_by INT NULL FK → users.id ON DELETE SET NULL` to the `issues` table. Allows the mobile API to return issues the inspector created (but hasn't been assigned) alongside their assigned issues. Nullable — pre-phase18 rows have `NULL` and surface only via the `assigned_to` path. Uses `INFORMATION_SCHEMA` existence and constraint checks — safe to re-run.
### MySQL ENUM Change Protocol (3 steps — always follow)
```sql
-- 1. Expand
@@ -774,6 +822,14 @@ timeout = 30
| 33 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes; use `"\u2014"` not `'—'` inside `f'...'` |
| 34 | **`computeScore` field ID must be cast explicitly: `String``as? String`, `Int``as? Int` then `String(n)`** | `Optional.map` on `Any?` returns `Optional(value)` not `value`; the old guard-let produced `"Optional(5)"` as the lookup key, so all integer-ID field scores were silently 0 |
| 35 | **Strip `local://` photo paths from `formData` before `submitInspection`** | A failed photo upload leaves `"local://..."` in formData; `JSONSerialization` drops non-serialisable values silently, which is worse than an empty string on the server |
| 36 | **`notify()` must always receive `event_type`** | Without it the mobile API returns `null` for event type and the iPad cannot categorise the alert banner |
| 37 | **`flag_followup` calls `notify()` for the original inspector** | Without this, the inspector receives no follow-up request notification on any channel |
| 38 | **`GET /api/v1/notifications` catches `OperationalError`** | If phase17 migration hasn't run, the ORM query fails at the SQL layer because `event_type` is in the SELECT but not in the DB; `getattr()` does NOT protect against this — only a try/except does |
| 39 | **Issue API scope: inspectors see assigned OR reported issues** | `GET /issues`, `GET /issues/<id>`, `PATCH /issues/<id>/status` all enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role. Pre-phase18 rows with `reported_by = NULL` surface only via `assigned_to`. |
| 40 | **`_issue_payload()` must return `photo_path` and `result_photos`** | iPad `pullAssignedIssues` stores these in `photoServerPaths` for display via `AsyncImage`; omitting them means server-created issues show no photos |
| 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row; calling it mid-transaction prematurely commits dirty session state. Snapshot any label strings needed for the audit call before the main commit if they come from ORM objects that may expire. |
| 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; comparing to `None` does not generate a "has no rows" predicate. Use `~.any()` which emits a proper `NOT EXISTS` subquery. |
| 43 | **`issues.index()` outerjoin must precede all filters** | `outerjoin(Area, Issue.area_id == Area.id)` is unconditional at the top of the query. Both the customer-scope block and the `facility_filter` block reference `Area.facility_id`; without a prior join the `facility_filter` path generates a cartesian product for non-customer users. |
---
+3 -1
View File
@@ -147,7 +147,7 @@ def create_app(config_name='default'):
app.register_blueprint(customers.bp)
app.register_blueprint(scheduled_reports.bp)
# ── Mobile API (Phase 7 / Phase A / Phase B) ─────────────────────────────
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
#
# Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object
@@ -160,12 +160,14 @@ def create_app(config_name='default'):
from app.api.inspections import bp as _api_inspections_bp
from app.api.issues import bp as _api_issues_bp
from app.api.photos import bp as _api_photos_bp
from app.api.notifications import bp as _api_notifications_bp
csrf.exempt(_api_auth_bp)
csrf.exempt(_api_facilities_bp)
csrf.exempt(_api_templates_bp)
csrf.exempt(_api_inspections_bp)
csrf.exempt(_api_issues_bp)
csrf.exempt(_api_photos_bp)
csrf.exempt(_api_notifications_bp)
register_api(app)
# ── Error handler: 413 Request Entity Too Large ───────────────────────
+5
View File
@@ -5,6 +5,7 @@ Registers the /api/v1 blueprint group.
Phase A: /api/v1/facilities/*, /api/v1/templates/*
Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/*
Phase C: /api/v1/notifications/*
"""
from flask import Blueprint
@@ -37,4 +38,8 @@ def register_api(app):
api_bp.register_blueprint(issues_bp)
api_bp.register_blueprint(photos_bp)
# Phase C: Notification polling
from app.api.notifications import bp as notifications_bp
api_bp.register_blueprint(notifications_bp)
app.register_blueprint(api_bp)
+1
View File
@@ -50,6 +50,7 @@ def _user_payload(user: User) -> dict:
return {
'id': user.id,
'username': user.username,
'full_name': user.full_name or '',
'email': user.email,
'role': user.role,
'created_at': user.created_at.isoformat() if user.created_at else None,
+46 -3
View File
@@ -50,6 +50,29 @@ def _parse_datetime(value):
def _inspection_payload(inspection):
"""Serialize an Inspection to the dict returned in API responses."""
# Extract form responses from the notes JSON blob.
# Mobile submissions store form data as {"_form_data": {...}, "_inspector_notes": "..."}.
# Web submissions store form data in the form_data column directly.
form_data = {}
inspector_notes = ''
if inspection.notes:
try:
notes_obj = json.loads(inspection.notes)
if isinstance(notes_obj, dict):
form_data = notes_obj.get('_form_data', {}) or {}
inspector_notes = notes_obj.get('_inspector_notes', '') or ''
except (json.JSONDecodeError, TypeError):
pass
# Fallback: web-created inspections store responses in form_data column
if not form_data and inspection.form_data:
form_data = inspection.form_data if isinstance(inspection.form_data, dict) else {}
# Include the template's form_schema so the iPad can render history
# without needing a locally cached copy of the template.
form_schema = []
if inspection.template:
form_schema = inspection.template.get_form_schema()
return {
'id': inspection.id,
'template_id': inspection.template_id,
@@ -59,12 +82,15 @@ def _inspection_payload(inspection):
'area_id': inspection.area_id,
'area_name': inspection.area.name if inspection.area else None,
'status': inspection.status,
'overall_score': inspection.overall_score,
'overall_score': float(inspection.overall_score) if inspection.overall_score is not None else None,
'inspection_date': inspection.inspection_date.isoformat()
if inspection.inspection_date else None,
'completed_at': inspection.completed_at.isoformat()
if inspection.completed_at else None,
'mobile_local_id': inspection.mobile_local_id,
'form_data': form_data,
'form_schema': form_schema,
'inspector_notes': inspector_notes,
# ── Follow-up / re-inspection fields ──────────────────────────────
'follow_up_required': inspection.follow_up_required,
'follow_up_note': inspection.follow_up_note,
@@ -264,6 +290,12 @@ def create_inspection():
# follow_up_required on the parent automatically. This mirrors the web
# list view's implicit logic (which hides the badge when follow_ups.any())
# and ensures the History API response reflects the resolved state.
# Capture parent label strings before commit while ORM objects are loaded.
# log_action() for the parent update must fire AFTER db.session.commit() to
# avoid audit.py's internal commit() persisting the parent flag change before
# the new inspection row is committed — a partial state that would be incorrect
# if the main commit subsequently failed.
_parent_log_args = None
if parent_inspection_id and status == 'completed':
parent_insp = db.session.get(Inspection, parent_inspection_id)
if parent_insp and parent_insp.follow_up_required:
@@ -273,10 +305,13 @@ def create_inspection():
'by_inspection_id=%d | user=%s',
parent_inspection_id, inspection.id, user.username,
)
log_action(ACTION_UPDATE, 'Inspection', parent_inspection_id,
# Snapshot label strings now — ORM objects may be expired after commit
_parent_log_args = (
parent_inspection_id,
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
f'follow_up_required=False (cleared by re-inspection '
f'#{inspection.id} via mobile API)')
f'#{inspection.id} via mobile API)',
)
# ── Notifications ─────────────────────────────────────────────────────
if status == 'completed':
@@ -304,6 +339,14 @@ def create_inspection():
db.session.commit()
# ── Post-commit audit logging ──────────────────────────────────────────
# All log_action() calls must come AFTER db.session.commit() because
# audit.py calls db.session.commit() internally. Calling it before the
# main commit would persist the audit row (and any dirty ORM state) before
# the primary transaction completes.
if _parent_log_args:
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
log_action(ACTION_CREATE, 'Inspection', inspection.id,
f'{template.name} @ {facility.name}',
f'source=mobile; status={status}; score={overall_score}; '
+104 -19
View File
@@ -3,6 +3,10 @@ app/api/issues.py
-----------------
Mobile API endpoint for submitting issues from the iPad app.
GET /api/v1/issues
Returns issues assigned to the authenticated inspector (or all for admin/director).
Used by the iPad to display assigned issues that were created via the web portal.
POST /api/v1/issues
Creates a new issue record.
Accepts a mobile_local_id for idempotency.
@@ -40,7 +44,100 @@ _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
# ── Create Issue ──────────────────────────────────────────────────────────────
def _issue_payload(issue):
"""Serialise an Issue to the dict returned in list/detail responses."""
facility = issue.resolved_facility
return {
'id': issue.id,
'status': issue.status,
'severity': issue.severity,
'description': issue.description,
'assigned_to': issue.assigned_to,
'facility_id': facility.id if facility else None,
'facility_name': facility.name if facility else None,
'reported_at': issue.reported_at.isoformat() if issue.reported_at else None,
'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None,
'mobile_local_id': issue.mobile_local_id,
# photo_path is the primary issue photo; result_photos are resolution photos.
# Both are relative paths from the server static root.
'photo_path': issue.photo_path or None,
'result_photos': issue.result_photos or [],
}
# ── List Assigned Issues ──────────────────────────────────────────────────────
@bp.route('/issues', methods=['GET'])
@jwt_required
def list_issues():
"""
Return active issues for the authenticated user.
All roles see all non-resolved issues (inspectors included) so the iPad
shows the full picture of open work at their facilities.
A ?status= filter can be used to override the default exclusion.
Query parameters
----------------
status str Filter by status. Omit to get all non-resolved issues.
limit int Default 100, max 200.
offset int Default 0.
Response 200
------------
{
"ok": true,
"data": {
"issues": [...],
"total": 12,
"limit": 100,
"offset": 0
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 100)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
query = Issue.query
if user.role == 'inspector':
# Inspectors see issues assigned to them OR issues they reported.
# The reported_by path covers issues created on the iPad that haven't
# been assigned yet (assigned_to is NULL until a director assigns them).
# Pre-phase18 rows with reported_by = NULL still surface via assigned_to.
query = query.filter(
db.or_(
Issue.assigned_to == user.id,
Issue.reported_by == user.id,
)
)
else:
# Broader roles: exclude resolved by default so the list stays manageable
status_filter = request.args.get('status')
if status_filter:
query = query.filter(Issue.status == status_filter)
else:
query = query.filter(Issue.status != 'resolved')
total = query.count()
issues = (
query
.order_by(Issue.reported_at.desc())
.offset(offset)
.limit(limit)
.all()
)
payload = [_issue_payload(i) for i in issues]
logger.info('API ISSUES | list | user=%s | count=%d | total=%d',
user.username, len(payload), total)
return api_ok({'issues': payload, 'total': total, 'limit': limit, 'offset': offset})
@bp.route('/issues', methods=['POST'])
@jwt_required
@@ -115,6 +212,7 @@ def create_issue():
photo_path = data.get('photo_path') or None,
status = 'open',
reported_at = now_eastern(),
reported_by = user.id,
mobile_local_id = mobile_local_id,
)
db.session.add(issue)
@@ -165,9 +263,7 @@ def get_issue(issue_id):
Return current status, severity, description, assigned_to, and facility
for a single issue.
Access:
- admin / director / project_manager : any issue
- inspector : only issues where assigned_to == current user
Access: all allowed roles may fetch any issue.
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
@@ -177,21 +273,10 @@ def get_issue(issue_id):
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector' and issue.assigned_to != user.id:
if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id:
return api_error('Access denied', 403)
facility = issue.resolved_facility
return api_ok({
'id': issue.id,
'status': issue.status,
'severity': issue.severity,
'description': issue.description,
'assigned_to': issue.assigned_to,
'facility_id': facility.id if facility else None,
'facility_name': facility.name if facility else None,
'reported_at': issue.reported_at.isoformat() if issue.reported_at else None,
'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None,
})
return api_ok(_issue_payload(issue))
# ── Update Issue Status ───────────────────────────────────────────────────────
@@ -218,8 +303,8 @@ def update_issue_status(issue_id):
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector' and issue.assigned_to != user.id:
return api_error('Access denied — you can only update issues assigned to you', 403)
if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id:
return api_error('Access denied — you can only update issues assigned to or reported by you', 403)
data = request.get_json(silent=True) or {}
new_status = (data.get('status') or '').strip().lower()
+165
View File
@@ -0,0 +1,165 @@
"""
app/api/notifications.py
------------------------
Mobile API endpoint for polling in-app notifications.
GET /api/v1/notifications
Returns unread notifications for the authenticated user.
Accepts ?since=<ISO 8601> to fetch only notifications created after
that datetime — used by the iPad poller to avoid re-delivering already-
seen alerts. Returns a maximum of 50 notifications per call.
PATCH /api/v1/notifications/mark-read
Marks a list of notification IDs as read.
Request JSON: { "ids": [1, 2, 3] }
"""
import logging
from datetime import datetime
import sqlalchemy.exc
from flask import Blueprint, request, g
from app import db
from app.models.notification import Notification
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
logger = logging.getLogger(__name__)
bp = Blueprint('api_notifications', __name__)
def _parse_since(value):
"""Parse ?since= ISO 8601 string; return None on failure."""
if not value:
return None
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
try:
return datetime.strptime(value, fmt)
except (ValueError, TypeError):
pass
return None
def _build_payload(notifications, has_event_type):
"""Serialise notification rows to dicts. Works before and after phase17 migration."""
rows = []
for n in notifications:
rows.append({
'id': n.id,
'title': n.title,
'body': n.body,
'event_type': (n.event_type if has_event_type else None),
'issue_id': n.issue_id,
'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'),
})
return rows
@bp.route('/notifications', methods=['GET'])
@jwt_required
def list_notifications():
"""
Return unread notifications for the authenticated user.
Query parameters
----------------
since ISO 8601 datetime Only return notifications created after this time.
limit int (default 50, max 50)
Response 200
------------
{ "ok": true, "data": { "notifications": [...], "count": N } }
"""
user = g.api_user
since = _parse_since(request.args.get('since'))
limit = min(int(request.args.get('limit', 50)), 50)
def _run_orm():
q = Notification.query.filter_by(user_id=user.id, is_read=False)
if since:
q = q.filter(Notification.created_at > since)
return q.order_by(Notification.created_at.asc()).limit(limit).all()
# Attempt the ORM query (works after phase17 migration runs).
# If the event_type column does not yet exist in the DB, MySQL raises
# OperationalError: Unknown column 'notifications.event_type' in SELECT.
# getattr() does NOT protect against this — the failure is at the SQL layer.
# The fallback raw-SQL query selects only the pre-phase17 columns so the
# endpoint stays functional before the migration runs.
try:
notifications = _run_orm()
has_event_type = True
except sqlalchemy.exc.OperationalError:
db.session.rollback()
sql_parts = (
"SELECT id, title, body, issue_id, is_read, created_at "
"FROM notifications "
"WHERE user_id = :uid AND is_read = 0 "
)
params = {'uid': user.id, 'lim': limit}
if since:
sql_parts += "AND created_at > :since "
params['since'] = since
sql_parts += "ORDER BY created_at ASC LIMIT :lim"
rows = db.session.execute(db.text(sql_parts), params).fetchall()
class _Row:
"""Minimal shim so _build_payload works with raw SQL rows."""
__slots__ = ('id', 'title', 'body', 'issue_id', 'created_at')
def __init__(self, r):
self.id = r[0]
self.title = r[1]
self.body = r[2]
self.issue_id = r[3]
self.created_at = r[5]
notifications = [_Row(r) for r in rows]
has_event_type = False
payload = _build_payload(notifications, has_event_type)
logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d | has_event_type=%s',
user.username, since, len(payload), has_event_type)
return api_ok({'notifications': payload, 'count': len(payload)})
@bp.route('/notifications/mark-read', methods=['PATCH'])
@jwt_required
def mark_read():
"""
Mark a list of notification IDs as read.
Request JSON: { "ids": [1, 2, 3] }
Response 200
------------
{ "ok": true, "data": { "marked": 3 } }
"""
user = g.api_user
data = request.get_json(silent=True) or {}
ids = data.get('ids') or []
if not isinstance(ids, list):
return api_error('ids must be a list', 400)
if ids:
updated = (
Notification.query
.filter(
Notification.id.in_(ids),
Notification.user_id == user.id,
)
.all()
)
for n in updated:
n.is_read = True
db.session.commit()
count = len(updated)
else:
count = 0
logger.info('API NOTIFICATIONS | mark_read | user=%s | count=%d', user.username, count)
return api_ok({'marked': count})
+6
View File
@@ -54,6 +54,11 @@ class Issue(db.Model):
photo_path = db.Column(db.String(255))
status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open')
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'))
# Set at creation time to the user who filed the issue (inspector or admin).
# Nullable for backward compatibility — pre-phase18 rows will be NULL.
# Used by the mobile API to return issues the inspector created but hasn't
# been assigned yet (assigned_to is NULL until a director assigns them).
reported_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
reported_at = db.Column(db.DateTime, default=now_eastern)
resolved_at = db.Column(db.DateTime)
result_notes = db.Column(db.Text)
@@ -75,6 +80,7 @@ class Issue(db.Model):
# with that backref at mapper configuration time (CLAUDE.md rule 31 revised).
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues')
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
reporter = db.relationship('User', foreign_keys=[reported_by], backref='reported_issues')
verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues')
comments = db.relationship('IssueComment', backref='issue', lazy='dynamic',
order_by='IssueComment.created_at',
+8
View File
@@ -12,6 +12,9 @@ EVENT_ISSUE_COMMENT = 'issue_comment'
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
EVENT_INSPECTION_DONE = 'inspection_completed'
EVENT_SLA_ALERT = 'sla_alert'
# Fired when an issue is flagged during an inspection (web or mobile).
# Listed here so users can configure email preferences for this event.
EVENT_ISSUE_FLAGGED = 'issue_flagged'
# ── Customer portal events ─────────────────────────────────────────────────
# Fired when an inspection completes or an issue is created/updated at a
@@ -25,6 +28,7 @@ ALL_EVENT_TYPES = {
EVENT_ISSUE_STATUS: 'Issue status changed',
EVENT_ISSUE_COMMENT: 'New comment on issue',
EVENT_ISSUE_FOLLOW: 'Updates on followed issues',
EVENT_ISSUE_FLAGGED: 'Issue flagged (from inspection)',
EVENT_INSPECTION_DONE: 'Inspection completed',
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
# Customer-facing — only relevant for customer role accounts
@@ -53,6 +57,10 @@ class Notification(db.Model):
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True)
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True)
# Event type — stored for mobile API polling so the iPad can categorise alerts.
# Added phase17; NULL for notifications created before the migration.
event_type = db.Column(db.String(50), nullable=True)
# Digest tracking: set to True when created, cleared after digest email sent
digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True)
+3 -3
View File
@@ -23,7 +23,7 @@ issue_assigned : admin ✗ director ✗ inspector ✗ pm ✗ cust
issue_status : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_comment : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_follow_update : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (followers implicit)
issue_flagged : admin director inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_flagged : admin director inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
@@ -113,8 +113,8 @@ MATRIX_DEFAULTS = {
('issue_follow_update', 'customer'): False,
('issue_follow_update', 'custom'): False,
# issue_flagged (from inspection)
('issue_flagged', 'admin'): False,
('issue_flagged', 'director'): False,
('issue_flagged', 'admin'): True,
('issue_flagged', 'director'): True,
('issue_flagged', 'inspector'): False,
('issue_flagged', 'project_manager'): False,
('issue_flagged', 'customer'): True,
+19
View File
@@ -730,6 +730,7 @@ def flag_issue(inspection_id):
status = 'open',
assigned_to = form.assigned_to.data or None,
reported_at = now_eastern(),
reported_by = current_user.id,
)
db.session.add(issue)
@@ -873,6 +874,24 @@ def flag_followup(inspection_id):
inspection.follow_up_note = note
db.session.commit()
# Notify the original inspector so they see it on the iPad
inspector = db.session.get(User, inspection.inspector_id)
if inspector and inspector.id != current_user.id:
note_suffix = f' Note: {note}' if note else ''
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{inspection_id}',
body = (
f'{current_user.username} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
),
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
event_type = EVENT_INSPECTION_DONE,
send_email = True,
)
db.session.commit()
current_app.logger.info(
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
inspection_id, current_user.username, note,
+12 -7
View File
@@ -70,12 +70,18 @@ class _SLAFilteredPage:
@login_required
def index():
page = request.args.get('page', 1, type=int)
q = Issue.query.order_by(Issue.reported_at.desc())
# outerjoin Area once here so both the customer-scope filter and the
# facility_filter block can reference Area.facility_id without a cartesian
# product. Issues with no area_id get NULL for all Area columns (outer join).
q = (
Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.order_by(Issue.reported_at.desc())
)
if current_user.role == 'inspector':
q = q.filter(Issue.assigned_to == current_user.id)
elif current_user.role == 'customer':
from app.models.facility import Area
customer_facility_ids = get_customer_scope(current_user)
if not customer_facility_ids:
q = q.filter(False)
@@ -86,11 +92,10 @@ def index():
Issue.facility_id.in_(customer_facility_ids),
db.and_(
Issue.area_id.isnot(None),
Issue.area_id == Area.id,
Area.facility_id.in_(customer_facility_ids)
Area.facility_id.in_(customer_facility_ids),
)
)
)
).outerjoin(Area, Issue.area_id == Area.id)
severity_filter = request.args.get('severity', '')
status_filter = request.args.get('status', '')
@@ -106,8 +111,7 @@ def index():
q = q.filter(
db.or_(
Issue.facility_id == fid,
db.and_(Issue.area_id.isnot(None),
Area.facility_id == fid)
Area.facility_id == fid,
)
)
# SLA filter — SLA status is computed in Python (not a DB column).
@@ -447,6 +451,7 @@ def create():
status = 'open',
assigned_to = form.assigned_to.data or None,
reported_at = now_eastern(),
reported_by = current_user.id,
)
db.session.add(issue)
db.session.commit()
+5
View File
@@ -44,6 +44,11 @@ def log_action(action: str,
"""
Write a single AuditLog row. Safe to call from any request context.
This function calls db.session.commit() internally.
Always call it AFTER the primary db.session.commit() for the business
transaction never before. Calling it mid-transaction will commit any
dirty ORM state accumulated in the session up to that point.
Parameters
----------
action : One of the ACTION_* constants (or a custom string 50 chars).
+1
View File
@@ -207,6 +207,7 @@ def notify(
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
event_type = event_type,
is_read = False,
digest_pending = hold_for_digest,
)
@@ -0,0 +1,53 @@
"""phase17 — add event_type column to notifications table
Background
----------
The `notifications` table has no `event_type` column, but the mobile API
endpoint GET /api/v1/notifications references `n.event_type`, causing an
AttributeError (500) on every poll silently breaking iPad notifications.
This migration adds `event_type VARCHAR(50) NULL` so the column is stored
at creation time and returned correctly to the mobile poller.
The `notify()` utility is updated separately to pass event_type when creating
Notification records.
Uses INFORMATION_SCHEMA existence check safe to re-run (CLAUDE.md rules 16, 17).
Revision ID: phase17_notification_event_type
Revises: phase16_notifications_columns
"""
revision = 'phase17_notification_event_type'
down_revision = 'phase16_notifications_columns'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(bind, table, column):
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {'t': table, 'c': column})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'notifications', 'event_type'):
op.execute(sa.text(
"ALTER TABLE notifications "
"ADD COLUMN event_type VARCHAR(50) NULL"
))
def downgrade():
bind = op.get_bind()
if _column_exists(bind, 'notifications', 'event_type'):
op.execute(sa.text(
"ALTER TABLE notifications DROP COLUMN event_type"
))
@@ -0,0 +1,75 @@
"""phase18 — add reported_by column to issues table
Background
----------
Issues created on the iPad by an inspector have no `assigned_to` value until
a director assigns them via the web portal. The mobile API's list_issues
endpoint filtered inspectors to `assigned_to == user.id`, so their own
newly-submitted issues were invisible on the iPad until assigned.
This migration adds `reported_by INT NULL FK users.id` so the API can
return issues the inspector either created OR was assigned to, without
a join to the inspections table.
The column is nullable for backward compatibility: existing issues created
before this migration will have reported_by = NULL and continue to surface
only via the assigned_to path.
Uses INFORMATION_SCHEMA existence check safe to re-run (CLAUDE.md rules 16, 17).
Revision ID: phase18_issue_reported_by
Revises: phase17_notification_event_type
"""
revision = 'phase18_issue_reported_by'
down_revision = 'phase17_notification_event_type'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(conn, table, column):
result = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :table AND COLUMN_NAME = :col"
), {"table": table, "col": column})
return result.scalar() > 0
def _fk_exists(conn, table, constraint_name):
result = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :table AND CONSTRAINT_NAME = :name"
), {"table": table, "name": constraint_name})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'issues', 'reported_by'):
op.execute(sa.text(
"ALTER TABLE issues "
"ADD COLUMN reported_by INT NULL, "
"ADD CONSTRAINT fk_issues_reported_by "
" FOREIGN KEY (reported_by) REFERENCES users(id) "
" ON DELETE SET NULL"
))
def downgrade():
bind = op.get_bind()
if _fk_exists(bind, 'issues', 'fk_issues_reported_by'):
op.execute(sa.text(
"ALTER TABLE issues DROP FOREIGN KEY fk_issues_reported_by"
))
if _column_exists(bind, 'issues', 'reported_by'):
op.execute(sa.text(
"ALTER TABLE issues DROP COLUMN reported_by"
))