diff --git a/CLAUDE.md b/CLAUDE.md index ef4e5f4..cb085d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 19 complete + post-phase-19 improvements: security hardening, inspection UX, inspector dashboard widget, bulk verification, scheduled issues digest with SLA grouping, customer read-only issue portal, inspector contract scoping, contract visibility in issues/inspections UI, Contract→Facility cascade filters) +> **Last reviewed:** May 2026 (Phase 19 complete + mobile API gap-fill Phases A–E: issue comments, dashboard stats with severity breakdown, area_name/assigned_to_name in issue payload, notification inbox, area_id on issues, CSRF-exempt pattern for all new blueprints) --- @@ -16,7 +16,7 @@ 6. [Role & Permission Matrix](#6-role--permission-matrix) 7. [Blueprint Prefixes & Route Inventory](#7-blueprint-prefixes--route-inventory) 8. [Utility Modules](#8-utility-modules) -9. [Mobile API (Phase 7 / Phase A / Phase B / Phase C)](#9-mobile-api-phase-7--phase-a--phase-b--phase-c) +9. [Mobile API (Phase 7 / Phase A–E)](#9-mobile-api-phase-7--phase-ae) 10. [iPad Native App](#10-ipad-native-app) 11. [Notification System](#11-notification-system) 12. [SLA Engine](#12-sla-engine) @@ -88,8 +88,10 @@ lt_janitorial_quality_control/ │ │ ├── facilities.py # /api/v1/facilities/* (Phase A) │ │ ├── templates.py # /api/v1/templates/* (Phase A) │ │ ├── inspections.py # /api/v1/inspections/* (Phase B) -│ │ ├── issues.py # /api/v1/issues/* (Phase B + Phase 19) +│ │ ├── issues.py # /api/v1/issues/* (Phase B + Phase 19 + Phase E) │ │ ├── photos.py # /api/v1/photos/upload (Phase B) +│ │ ├── stats.py # /api/v1/stats/dashboard (Phase B stats) +│ │ ├── comments.py # /api/v1/issues//comments (Phase D) │ │ ├── decorators.py # @jwt_required │ │ ├── errors.py # JSON error helpers + error handler registration │ │ └── jwt_utils.py # generate_access_token() @@ -310,6 +312,8 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version | `api_issues` | `/api/v1` | `GET /issues`, `POST /issues`, `GET /issues/`, `PATCH /issues//status`, `PATCH /issues//photos` ← Phase 19 | | `api_photos` | `/api/v1` | `POST /photos/upload` | | `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` | +| `api_stats` | `/api/v1` | `GET /stats/dashboard` — inspector-scoped KPIs with severity breakdown (Phase B) | +| `api_comments` | `/api/v1` | `GET /issues//comments`, `POST /issues//comments` (Phase D) | --- @@ -339,7 +343,7 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view --- -## 9. Mobile API (Phase 7 / Phase A / Phase B / Phase C) +## 9. Mobile API (Phase 7 / Phase A–E) ### CSRF Exemption Pattern — Critical @@ -384,7 +388,26 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view | Endpoint | Auth | Description | |---|---|---| -| `PATCH /api/v1/issues//photos` | jwt_required | Attach extra evidence photos to an issue. Accepts `{ "result_photos": ["uploads/..."] }`. Stores in `mobile_photo_paths` (NOT `result_photos`). Idempotent — merges with existing paths, never overwrites. Access: inspector must be `assigned_to` or `reported_by`. | +| `PATCH /api/v1/issues//photos` | jwt_required | Attach extra evidence photos to an issue. Accepts `{ "result_photos": ["uploads/..."] }`. Stores in `mobile_photo_paths` (NOT `result_photos`). Idempotent — merges with existing paths, never overwrites. | + +### Phase B (Stats) Endpoint + +| Endpoint | Auth | Description | +|---|---|---| +| `GET /api/v1/stats/dashboard` | jwt_required | Inspector-scoped KPIs: `today_inspections`, `completed_today`, `open_issues`, `avg_score_30d`, `pending_followups`, `sla_breached`, `sla_at_risk`, `severity_breakdown` (dict: critical/high/medium/low). Inspectors scoped to contracted facilities. Admins/directors/PMs get org-wide numbers. Customers get 403. | + +### Phase D (Comments) Endpoints + +| Endpoint | Auth | Description | +|---|---|---| +| `GET /api/v1/issues//comments` | jwt_required | All comments oldest-first. Returns: `id`, `issue_id`, `author_name`, `author_role`, `status_at_time`, `body`, `created_at`. Inspectors limited to contracted facilities. | +| `POST /api/v1/issues//comments` | jwt_required | Add a comment. Body: `{ "body": "..." }`. Fires `notify_by_matrix('issue_comment')`. Calls `log_action()` after commit. | + +### Phase E Additions to Existing Endpoints + +`_issue_payload()` in `issues.py` now returns `area_name` and `assigned_to_name` (both nullable). These populate `LocalIssue.areaNameCache` and `LocalIssue.assignedToName` on the iPad after every `pullAssignedIssues()`. `refreshStatusFromServer()` also refreshes them on demand. + +`stats.py` now returns `severity_breakdown` dict alongside the existing KPIs. Derived from the already-loaded `open_issues_all` list — zero extra DB queries. ### Issue API — `_issue_payload()` fields @@ -394,8 +417,16 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view 'facility_id', 'facility_name', 'reported_at', 'resolved_at', 'mobile_local_id', 'photo_path', # primary evidence photo (first iPad photo or web upload) - 'mobile_photo_paths', # extra evidence photos from iPad (list) ← Phase 19 + 'mobile_photo_paths', # extra evidence photos from iPad (list) 'result_photos', # resolution photos added via web form (list) + # Phase A additions: + 'result_notes', # resolution notes entered by web staff + 'verified_at', # ISO 8601 datetime when fix was verified (nullable) + 'verification_note', # note from the verifier (nullable) + 'reported_by_name', # display_name of User who filed the issue (nullable) + # Phase E additions: + 'area_name', # name of the Area the issue was flagged in (nullable) + 'assigned_to_name', # display_name of currently assigned User (nullable) } ``` @@ -681,7 +712,7 @@ timeout = 30 | 32 | **f-string fallback strings must use double-quotes inside single-quoted f-strings** | Python 3.11 raises `SyntaxError` on nested same-delimiter quotes | | 33–38 | *(field ID casting, photo sentinel, notify event_type, follow-up, OperationalError)* | See prior rule entries | | 39 | **Inspector issue scope: assigned OR reported — web and API must match** | `issues.index()`, `issues.view()`, and all API issue endpoints (`GET /issues`, `GET /issues/`, `PATCH /issues//status`, `PATCH /issues//photos`) enforce `assigned_to == user.id OR reported_by == user.id` for the inspector role | -| 40 | **`_issue_payload()` must return `photo_path`, `mobile_photo_paths`, and `result_photos`** | iPad reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`; omitting `mobile_photo_paths` means extra evidence photos are invisible on the iPad after sync | +| 40 | **`_issue_payload()` must return all documented fields** | iPad reads `photo_path` + `mobile_photo_paths` into `photoServerPaths`. Phase A–E added `result_notes`, `verified_at`, `verification_note`, `reported_by_name`, `area_name`, `assigned_to_name`. Omitting any field silently breaks the corresponding iPad display. | | 41 | **`log_action()` commits internally — always call after `db.session.commit()`** | audit.py calls `db.session.commit()` to write the AuditLog row | | 42 | **`~Inspection.follow_ups.any()` not `== None` for dynamic relationships** | `follow_ups` is `lazy='dynamic'`; use `~.any()` which emits `NOT EXISTS` | | 43 | **`issues.index()` outerjoin must precede all filters** | Both customer-scope and facility_filter blocks reference `Area.facility_id` | diff --git a/app/__init__.py b/app/__init__.py index 6f7350b..19644c5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -161,6 +161,8 @@ def create_app(config_name='default'): 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 + from app.api.stats import bp as _api_stats_bp + from app.api.comments import bp as _api_comments_bp csrf.exempt(_api_auth_bp) csrf.exempt(_api_facilities_bp) csrf.exempt(_api_templates_bp) @@ -168,6 +170,8 @@ def create_app(config_name='default'): csrf.exempt(_api_issues_bp) csrf.exempt(_api_photos_bp) csrf.exempt(_api_notifications_bp) + csrf.exempt(_api_stats_bp) + csrf.exempt(_api_comments_bp) register_api(app) # ── Security response headers ───────────────────────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index d8469c2..178697c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -42,4 +42,12 @@ def register_api(app): from app.api.notifications import bp as notifications_bp api_bp.register_blueprint(notifications_bp) + # Phase B (stats): Dashboard KPI endpoint + from app.api.stats import bp as stats_bp + api_bp.register_blueprint(stats_bp) + + # Phase D: Issue comments + from app.api.comments import bp as comments_bp + api_bp.register_blueprint(comments_bp) + app.register_blueprint(api_bp) \ No newline at end of file diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 0000000..dd6b631 --- /dev/null +++ b/app/api/comments.py @@ -0,0 +1,187 @@ +""" +app/api/comments.py +------------------- +Mobile API endpoints for issue comments. + +GET /api/v1/issues//comments + Returns all comments for an issue, ordered oldest-first. + Inspectors may only access issues within their contracted facilities. + +POST /api/v1/issues//comments + Adds a comment to an issue. + Inspectors may only comment on issues within their contracted facilities. + Fires notify_by_matrix('issue_comment') so the relevant staff are notified. +""" + +import logging + +from flask import Blueprint, request, g +from app import db +from app.models.issue import Issue, IssueComment +from app.api.errors import api_ok, api_error +from app.api.decorators import jwt_required +from app.utils.audit import log_action, ACTION_CREATE +from app.utils.notifications import notify_by_matrix +from app.utils.scope import get_inspector_scope +from app.utils.time_utils import now_eastern + +logger = logging.getLogger(__name__) + +bp = Blueprint('api_comments', __name__) + +_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} + + +def _comment_payload(comment: IssueComment) -> dict: + """Serialise an IssueComment to the dict returned in API responses.""" + return { + 'id': comment.id, + 'issue_id': comment.issue_id, + 'author_name': comment.author.display_name if comment.author else 'Unknown', + 'author_role': comment.author.role if comment.author else '', + 'status_at_time': comment.status_at_time or '', + 'body': comment.body, + 'created_at': comment.created_at.isoformat() if comment.created_at else None, + } + + +def _check_issue_access(issue: Issue, user) -> bool: + """Return True if user may read/write this issue. False = 403.""" + if user.role == 'inspector': + fids = get_inspector_scope(user) + facility = issue.resolved_facility + if not fids or not facility or facility.id not in fids: + return False + return True + + +# ── GET comments ───────────────────────────────────────────────────────────── + +@bp.route('/issues//comments', methods=['GET']) +@jwt_required +def list_comments(issue_id): + """ + Return all comments for the given issue, oldest-first. + + Response 200 + ------------ + { + "ok": true, + "data": { + "issue_id": 42, + "comments": [ + { + "id": 1, + "issue_id": 42, + "author_name": "Jane Smith", + "author_role": "director", + "status_at_time": "in_progress", + "body": "Cleaning crew has been notified.", + "created_at": "2026-05-10T09:15:00" + } + ], + "count": 1 + } + } + """ + user = g.api_user + if user.role not in _ALLOWED_ROLES: + return api_error('Access denied', 403) + + issue = db.session.get(Issue, issue_id) + if issue is None: + return api_error('Issue not found', 404) + + if not _check_issue_access(issue, user): + return api_error('Access denied', 403) + + comments = ( + issue.comments + .order_by(IssueComment.created_at.asc()) + .all() + ) + payload = [_comment_payload(c) for c in comments] + + logger.info('API COMMENTS | list | issue_id=%d | count=%d | user=%s', + issue_id, len(payload), user.username) + + return api_ok({'issue_id': issue_id, 'comments': payload, 'count': len(payload)}) + + +# ── POST comment ────────────────────────────────────────────────────────────── + +@bp.route('/issues//comments', methods=['POST']) +@jwt_required +def add_comment(issue_id): + """ + Add a comment to an issue. + + Request JSON + ------------ + { "body": "The spill has been cleaned up." } + + Response 200 + ------------ + { "ok": true, "data": { "comment_id": 7 } } + """ + user = g.api_user + if user.role not in _ALLOWED_ROLES: + return api_error('Access denied', 403) + + issue = db.session.get(Issue, issue_id) + if issue is None: + return api_error('Issue not found', 404) + + if not _check_issue_access(issue, user): + return api_error('Access denied', 403) + + data = request.get_json(silent=True) or {} + body = (data.get('body') or '').strip() + if not body: + return api_error('body is required', 400) + + comment = IssueComment( + issue_id = issue_id, + user_id = user.id, + status_at_time = issue.status, + body = body, + ) + db.session.add(comment) + db.session.flush() # get comment.id + + # Notify via matrix — same event type as web-originated comments + facility = issue.resolved_facility + area_name = issue.area.name if issue.area else (facility.name if facility else '—') + facility_id = facility.id if facility else None + + try: + from flask import url_for + issue_link = url_for('issues.view', issue_id=issue.id, _external=False) + except RuntimeError: + issue_link = f'/issues/{issue.id}' + + if facility_id: + notify_by_matrix( + event_type = 'issue_comment', + title = f'New Comment on Issue #{issue.id}', + body = ( + f'{user.display_name} commented on Issue #{issue.id} ' + f'at {area_name}: ' + f'"{body[:120]}{"…" if len(body) > 120 else ""}"' + ), + link = issue_link, + issue_id = issue.id, + facility_id = facility_id, + exclude_user_ids = {user.id}, + ) + + db.session.commit() + + log_action(ACTION_CREATE, 'IssueComment', comment.id, + f'comment on Issue #{issue_id}', + f'source=mobile; author={user.username}; issue_status={issue.status}') + + logger.info('API COMMENTS | created | comment_id=%d | issue_id=%d | user=%s', + comment.id, issue_id, user.username) + + return api_ok({'comment_id': comment.id}) diff --git a/app/api/issues.py b/app/api/issues.py index cd6e796..464d7c4 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -70,6 +70,17 @@ def _issue_payload(issue): 'photo_path': issue.photo_path or None, 'mobile_photo_paths': issue.mobile_photo_paths or [], 'result_photos': issue.result_photos or [], + # Resolution details — set by web staff after fixing the issue. + 'result_notes': issue.result_notes or None, + # Verification fields — set after a director/admin confirms fix. + 'verified_at': issue.verified_at.isoformat() if issue.verified_at else None, + 'verification_note': issue.verification_note or None, + # Reporter display name — shows who filed the issue. + 'reported_by_name': issue.reporter.display_name if issue.reporter else None, + # Area name — set when the issue was flagged during an area-specific inspection. + 'area_name': issue.area.name if issue.area else None, + # Assigned-to display name — set when a director assigns the issue to a user. + 'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None, } diff --git a/app/api/stats.py b/app/api/stats.py new file mode 100644 index 0000000..2762f5d --- /dev/null +++ b/app/api/stats.py @@ -0,0 +1,178 @@ +""" +app/api/stats.py +---------------- +Mobile API endpoint for dashboard statistics. + +GET /api/v1/stats/dashboard + Returns inspector-scoped counts used by the iPad dashboard card: + - today_inspections : inspections started or completed today + - completed_today : completed inspections today + - open_issues : open + in_progress issues in contracted facilities + - avg_score_30d : average overall_score (last 30 days, own inspections) + - pending_followups : completed inspections with follow_up_required and no + child re-inspection yet + - sla_breached : open/in-progress issues past their SLA deadline + - sla_at_risk : open/in-progress issues past 75% of SLA window + + Admins and directors receive org-wide numbers (no facility scoping). + Project managers receive unscoped numbers same as admin. + Inspectors receive numbers scoped to their contracted facilities / own work. + Customers are denied (403) — stats are for operational staff only. +""" + +import logging +from datetime import timedelta + +from flask import Blueprint, g +from sqlalchemy import func + +from app import db +from app.models.inspection import Inspection +from app.models.issue import Issue +from app.models.facility import Area +from app.api.errors import api_ok, api_error +from app.api.decorators import jwt_required +from app.utils.scope import get_inspector_scope +from app.utils.sla import sla_status +from app.utils.time_utils import now_eastern + +logger = logging.getLogger(__name__) + +bp = Blueprint('api_stats', __name__) + +_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'} + + +@bp.route('/stats/dashboard', methods=['GET']) +@jwt_required +def dashboard_stats(): + """ + Return dashboard KPI counts for the authenticated user. + + Response 200 + ------------ + { + "ok": true, + "data": { + "today_inspections": 3, + "completed_today": 2, + "open_issues": 7, + "avg_score_30d": 84.5, + "pending_followups": 1, + "sla_breached": 2, + "sla_at_risk": 1 + } + } + """ + user = g.api_user + + if user.role not in _ALLOWED_ROLES: + return api_error('Access denied', 403) + + now = now_eastern() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_end = today_start + timedelta(days=1) + thirty_days_ago = now - timedelta(days=30) + + is_inspector = user.role == 'inspector' + fids = get_inspector_scope(user) if is_inspector else None # None = no scoping + + # ── Today's inspections ─────────────────────────────────────────────── + today_q = Inspection.query.filter( + Inspection.inspection_date >= today_start, + Inspection.inspection_date < today_end, + ) + if is_inspector: + if not fids: + today_q = today_q.filter(False) + else: + today_q = today_q.filter( + Inspection.facility_id.in_(fids), + Inspection.inspector_id == user.id, + ) + + today_inspections = today_q.count() + + completed_today = today_q.filter( + Inspection.status == 'completed' + ).count() + + # ── Open issues ─────────────────────────────────────────────────────── + open_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) + if is_inspector: + if not fids: + open_q = open_q.filter(False) + else: + open_q = open_q.outerjoin(Area, Issue.area_id == Area.id).filter( + db.or_( + Issue.facility_id.in_(fids), + db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)), + ) + ) + + open_issues_all = open_q.all() + open_issues = len(open_issues_all) + + # ── Severity breakdown (derived from the same open_issues_all list) ─── + severity_breakdown = { + 'critical': sum(1 for i in open_issues_all if i.severity == 'critical'), + 'high': sum(1 for i in open_issues_all if i.severity == 'high'), + 'medium': sum(1 for i in open_issues_all if i.severity == 'medium'), + 'low': sum(1 for i in open_issues_all if i.severity == 'low'), + } + + # ── SLA counts (derived from the same open_issues_all list) ────────── + sla_breached = sum(1 for i in open_issues_all if sla_status(i) == 'breached') + sla_at_risk = sum(1 for i in open_issues_all if sla_status(i) == 'at_risk') + + # ── Average score last 30 days ──────────────────────────────────────── + score_q = db.session.query(func.avg(Inspection.overall_score)).filter( + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + Inspection.inspection_date >= thirty_days_ago, + ) + if is_inspector: + if not fids: + score_q = score_q.filter(False) + else: + score_q = score_q.filter( + Inspection.facility_id.in_(fids), + Inspection.inspector_id == user.id, + ) + raw_avg = score_q.scalar() + avg_score = round(float(raw_avg), 1) if raw_avg is not None else None + + # ── Pending follow-ups ──────────────────────────────────────────────── + # Completed inspections that still need a re-inspection and have none yet. + followup_q = Inspection.query.filter_by( + follow_up_required=True, status='completed' + ).filter(~Inspection.follow_ups.any()) + if is_inspector: + if not fids: + followup_q = followup_q.filter(False) + else: + followup_q = followup_q.filter( + Inspection.facility_id.in_(fids), + Inspection.inspector_id == user.id, + ) + pending_followups = followup_q.count() + + logger.info( + 'API STATS | dashboard | user=%s | role=%s | ' + 'today=%d | open_issues=%d | avg=%.1f | followups=%d | sla_b=%d | sla_r=%d', + user.username, user.role, + today_inspections, open_issues, + avg_score or 0.0, + pending_followups, sla_breached, sla_at_risk, + ) + + return api_ok({ + 'today_inspections': today_inspections, + 'completed_today': completed_today, + 'open_issues': open_issues, + 'avg_score_30d': avg_score, + 'pending_followups': pending_followups, + 'sla_breached': sla_breached, + 'sla_at_risk': sla_at_risk, + 'severity_breakdown': severity_breakdown, + })