diff --git a/.claude/settings.json b/.claude/settings.json index ef27add..7025c55 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,7 +9,8 @@ "Bash(grep -n -A3 dateOnlyFormatter __TRACKED_VAR__/Sync/SyncManager.swift)", "Bash(python -c \"import ast; ast.parse\\(open\\('app/utils/pdf_export.py'\\).read\\(\\)\\); print\\('OK'\\)\")", "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/utils/pdf_export.py',encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")", - "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/issues.py',encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")" + "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/issues.py',encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")", + "Bash(python -c \"print\\(len\\('phase47_sched_acknowledged'\\)\\)\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 4a359ad..5a201f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -422,7 +422,8 @@ scheduled_inspections: month_mode VARCHAR(20) nullable, -- Phase 43: 'day_of_month' | 'nth_weekday' day_of_month SMALLINT nullable, -- Phase 43 nth_week SMALLINT nullable, -- Phase 43: 1–5, or -1 = last - nth_weekday SMALLINT nullable -- Phase 43: 0–6, Mon=0 + nth_weekday SMALLINT nullable, -- Phase 43: 0–6, Mon=0 + acknowledged_at DATETIME nullable -- Phase 47: inspector confirmed receipt inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36 ``` @@ -452,6 +453,7 @@ All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_i - **Form validation.** `ScheduledInspectionForm.validate()` rejects an end date on a one-time schedule and one earlier than the due date. That is not sufficient alone: `align_due_date()` can push the picked date forward onto the rule (a Tuesday pick on a Mon/Wed/Fri schedule becomes Wednesday), so `_reject_if_past_end_date()` re-checks after `_apply_recurrence()` in both create and edit. Edit rolls back first — `sched` is persistent and already mutated at that point. - **Three status states** in the list: Active, **Ended** (`is_expired` — ran its course), Inactive (a manager switched it off). - **Assignment notification** (immediate): on **create**, the assigned inspector gets an in-app + email "assigned to you" notification; on **edit**, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via `_notify_assignee()` in the blueprint using `event_type=EVENT_SCHEDULED_INSPECTION`. +- **Receipt acknowledgement (phase47).** `acknowledged_at` records when the assigned inspector confirms they received the request — a way for the manager to see the assignment was seen, distinct from starting it. `POST //acknowledge` is **assignee-only** (like Start; a manager cannot confirm on someone's behalf) and idempotent; on first confirm the schedule's **creator** is notified via `_notify_creator_acknowledged()` (`event_type=EVENT_SCHEDULED_INSPECTION`, skipped when creator is inactive or is the inspector). **Once per assignment, not per occurrence:** it is NOT reset when a recurring schedule rolls forward (`fulfill()` leaves it), but the **edit route resets it to NULL when the inspector changes** so a new assignee must re-confirm. Surfaced with a Confirmed/Awaiting badge + a "Confirm receipt" button (assignee only) on both the scheduled-inspections **list** and the **dashboard** panel. `ScheduledInspection.is_acknowledged` is the boolean helper. Exposed **read-only** in the API payload (`acknowledged_at`) — confirming stays on the web per rule 77; an iPad confirm action would be a follow-up. - **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`. - Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own). - **Instructions (July 2026).** `ScheduledInspectionForm.notes` is labelled **"Instructions"** and `scheduled_inspections/form.html` explains that the text reaches the inspector. The *field name*, `ScheduledInspection.notes`, the `scheduled_inspections.notes` column and the API key `notes` are all unchanged — the rename is a label only (rule 84). The text is surfaced to the inspector in two places: `inspections/execute.html` renders an indigo panel between the header and the form grid, guarded on `inspection.scheduled_inspection and .notes` (NULL for ad-hoc work and for schedules deleted after the start); the iPad shows it on the scheduled row, on the start screen and above the form. @@ -519,7 +521,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi | `audit` | `/audit` | list (admin only), view, purge | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | -| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET //start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | +| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET //start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST //acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | | `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/` (staff, read-only), `GET /admin/knowledge` + `/new`, `//edit`, `//delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) | | `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) | @@ -883,7 +885,18 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase43_sched_recurrence → phase44_sched_end_date → phase45_sched_parent_insp - → phase46_followup_req_by ← HEAD + → phase46_followup_req_by + → phase47_sched_acknowledged ← HEAD +``` + +#### phase47 — scheduled inspection receipt acknowledgement + +Revision id `phase47_sched_acknowledged` (file `phase47_sched_acknowledged.py`, down_revision `phase46_followup_req_by`). Adds `scheduled_inspections.acknowledged_at DATETIME NULL` — when the assigned inspector confirms they received the scheduled request. Backs the **receipt acknowledgement** feature — see §5 `ScheduledInspection` "Receipt acknowledgement". **No backfill**: legacy rows keep NULL and render as "Awaiting" confirmation, the correct initial state. `INFORMATION_SCHEMA` column check — safe to re-run. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn ``` #### phase46 — follow-up request attribution diff --git a/app/api/scheduled.py b/app/api/scheduled.py index 97adf01..a47e41d 100644 --- a/app/api/scheduled.py +++ b/app/api/scheduled.py @@ -65,6 +65,11 @@ def _scheduled_payload(s): # run lands as a linked re-inspection. Additive — older builds decode # explicit CodingKeys and ignore it. 'parent_inspection_id': s.parent_inspection_id, + # phase47 — receipt acknowledgement. Read-only here: this collection is + # read-only per CLAUDE.md rule 77, so confirming happens on the web. The + # iPad can display "confirmed" state from this timestamp (NULL = the + # assigned inspector has not confirmed receipt yet). + 'acknowledged_at': s.acknowledged_at.isoformat() if s.acknowledged_at else None, } diff --git a/app/models/scheduled_inspection.py b/app/models/scheduled_inspection.py index b477b7b..4eef50b 100644 --- a/app/models/scheduled_inspection.py +++ b/app/models/scheduled_inspection.py @@ -130,6 +130,16 @@ class ScheduledInspection(db.Model): created_at = db.Column(db.DateTime, nullable=False, default=now_eastern) last_completed_at = db.Column(db.DateTime, nullable=True) + # ── Receipt acknowledgement (phase47) ──────────────────────────────────── + # When the assigned inspector confirms they have received/seen this + # assignment. Once per assignment: NULL means "awaiting confirmation"; it is + # reset to NULL when the schedule is reassigned to a different inspector so + # the new assignee must confirm afresh. It is NOT reset when a recurring + # schedule rolls forward — the acknowledgement is of the assignment, not of + # each occurrence. The acknowledger is always `inspector` (the only person + # allowed to confirm), so no separate acknowledged_by column is needed. + acknowledged_at = db.Column(db.DateTime, nullable=True) + # Per-occurrence reminder de-dup flags (reset when a recurring one rolls forward) advance_notified = db.Column(db.Boolean, nullable=False, default=False) due_notified = db.Column(db.Boolean, nullable=False, default=False) @@ -269,6 +279,11 @@ class ScheduledInspection(db.Model): """ return self.end_date is None or d <= self.end_date + @property + def is_acknowledged(self): + """True once the assigned inspector has confirmed receipt (phase47).""" + return self.acknowledged_at is not None + @property def is_expired(self): """True once the end date has passed. diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index b225039..7bee8bc 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -80,6 +80,28 @@ def _notify_assignee(sched, reassigned=False): ) +def _notify_creator_acknowledged(sched): + """Notify the schedule's creator that the assigned inspector has confirmed + receipt of the request. No-op when there is no creator, the creator is + inactive, or the creator IS the inspector (self-assigned). Caller commits.""" + creator = sched.creator + if not creator or not creator.active or creator.id == sched.inspector_id: + return + fac = sched.facility.name if sched.facility else '—' + tpl = sched.template.name if sched.template else '—' + who = sched.inspector.display_name if sched.inspector else 'The inspector' + notify( + recipient = creator, + title = f'Inspector confirmed receipt — {fac}', + body = (f'{who} confirmed receipt of the "{tpl}" scheduled ' + f'inspection at {fac} ({sched.frequency_label.lower()}), ' + f'due {sched.next_due_date:%b %d, %Y}.'), + link = url_for('scheduled_inspections.index'), + event_type = EVENT_SCHEDULED_INSPECTION, + send_email = True, + ) + + def _apply_recurrence(sched, form): """Copy the recurrence block for the chosen frequency onto *sched* and clear the blocks that no longer apply, then snap next_due_date onto the @@ -260,6 +282,10 @@ def edit(schedule_id): sched.inspector_id = form.inspector_id.data sched.notes = (form.notes.data or '').strip() or None sched.active = form.active.data + # Reassigning to a different inspector invalidates any prior confirmation + # — the new assignee has not yet acknowledged the request (phase47). + if sched.inspector_id != old_inspector_id: + sched.acknowledged_at = None _apply_recurrence(sched, form) if _reject_if_past_end_date(sched, form): # sched is a persistent object and has already been mutated — discard @@ -374,6 +400,39 @@ def start(schedule_id): return redirect(url_for('inspections.execute', inspection_id=inspection.id)) +# ── Acknowledge (inspector confirms receipt of the request) ─────────────────── + +@bp.route('//acknowledge', methods=['POST']) +@login_required +def acknowledge(schedule_id): + """The assigned inspector confirms they have received the scheduled request. + + Assignee-only (like Start): a manager cannot confirm on someone's behalf. + Idempotent — confirming an already-confirmed schedule is a no-op. On the + first confirmation the schedule's creator is notified.""" + sched = db.session.get(ScheduledInspection, schedule_id) + if sched is None: + abort(404) + if not sched.inspector_id or sched.inspector_id != current_user.id: + abort(403) + + if sched.acknowledged_at is None: + sched.acknowledged_at = now_eastern() + db.session.commit() + log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id, + f'{sched.template.name if sched.template else "?"} @ ' + f'{sched.facility.name if sched.facility else "?"}', + 'inspector confirmed receipt') + logger.info('SCHED INSP | acknowledge | schedule=%s | by=%s', + sched.id, current_user.username) + _notify_creator_acknowledged(sched) + db.session.commit() + flash('You have confirmed receipt of this scheduled inspection.', 'success') + else: + flash('You have already confirmed this scheduled inspection.', 'info') + return redirect(url_for('scheduled_inspections.index')) + + # ── Cron: reminders (advance / due / overdue) ───────────────────────────────── @bp.route('/run', methods=['POST']) diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 2a79d73..73db548 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -61,6 +61,16 @@ {# Start is shown only to the assignee — the inspection is theirs to do. #} {% if s.inspector_id and s.inspector_id == current_user.id %} + {# Receipt confirmation (phase47) — assignee confirms, or shows confirmed. #} + {% if s.is_acknowledged %} + Confirmed + {% else %} +
+ + +
+ {% endif %} {% set open_id = sched_open_inspections.get(s.id) %} {% if open_id %} Next Due Ends Status + Confirmation Created By @@ -74,6 +75,31 @@ Inactive {% endif %} + + {# Receipt confirmation by the assigned inspector (phase47). #} + {% if not s.inspector_id %} + + {% elif s.is_acknowledged %} + + Confirmed + +
{{ s.acknowledged_at.strftime('%b %d, %Y') }}
+ {% else %} + + Awaiting + + {# The assignee can confirm right here. #} + {% if s.inspector_id == current_user.id %} +
+ + +
+ {% endif %} + {% endif %} + {{ s.creator.display_name if s.creator else '—' }}
{{ s.created_at.strftime('%b %d, %Y') if s.created_at else '' }}
diff --git a/migrations/versions/phase47_sched_acknowledged.py b/migrations/versions/phase47_sched_acknowledged.py new file mode 100644 index 0000000..dcedd8b --- /dev/null +++ b/migrations/versions/phase47_sched_acknowledged.py @@ -0,0 +1,49 @@ +"""phase47 — scheduled inspection receipt acknowledgement + +Adds to `scheduled_inspections`: + + acknowledged_at DATETIME NULL + +When the assigned inspector confirms they have received/seen a scheduled +inspection request. NULL = awaiting confirmation. Reset to NULL by the edit +route when the schedule is reassigned to a different inspector (once per +assignment, not per occurrence). The acknowledger is always the assigned +inspector, so no separate acknowledged_by column is stored. + +No backfill: legacy rows keep NULL and render as "awaiting confirmation", which +is the correct initial state for an assignment nobody has confirmed yet. + +Uses INFORMATION_SCHEMA check — safe to re-run. +""" + +revision = 'phase47_sched_acknowledged' +down_revision = 'phase46_followup_req_by' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(conn, table, column): + return conn.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}).scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _column_exists(bind, 'scheduled_inspections', 'acknowledged_at'): + op.execute(sa.text( + "ALTER TABLE scheduled_inspections ADD COLUMN acknowledged_at DATETIME NULL" + )) + + +def downgrade(): + bind = op.get_bind() + if _column_exists(bind, 'scheduled_inspections', 'acknowledged_at'): + op.execute(sa.text( + "ALTER TABLE scheduled_inspections DROP COLUMN acknowledged_at" + ))