# HCP documents: person-level design

**Part A built (26 Oct 2026)**: person-level reads and uploads, items 1–6 and 8 below. **Part B
built (27 Oct 2026)**: `hcp:document-compliance` (`HcpDocumentComplianceService`), daily at 9am
agency time (hourly tick, `--due`; `--dry-run` lists the impact). Mandatory + active documents
only. It re-derives every profession — general + that profession's documents, expiry included
(`HcpDocumentService::professionStatus`): only expired → `expired`, anything missing / pending /
rejected → `pending-approval`; the account is never touched. HCPs are told once when a profession
is paused (`hcp.professions-paused`), 14 days before an expiry (`hcp.document-expiring`), and every
Monday while an active HCP's profession stays paused — missing / rejected / expired, not awaiting
approval (`hcp.documents-reminder`; `--weekly` forces it, e.g. with `--dry-run`). The
agency gets one digest of upcoming scheduled shifts whose HCP's documents are invalid
(`admin.document-compliance-digest`: activity mailbox + Teams whenever connected). A requirement
change re-checks the affected HCPs at once (`RecheckDocumentComplianceJob`).

Effective copy rule (shipped, refines "most recent copy" below): of an HCP's copies of a
document, an **approved copy valid today** wins (no expiry first, else the latest expiry);
otherwise the **most recently updated** copy. `DocumentHcpRepository::effectiveForHcp()` /
`bestCopy()`, `DocumentHcp::isValidOn()`. Removing a profession keeps any copy another held
profession still requires.

## Where we are now (shipped)

- **Two-level compliance.** `HcpDocumentService::applyStatusSync()` runs on every document event:
  - `hcp.status` follows **general** documents;
  - `hcp_profession.status` follows **that profession's** documents.

  A missing APHRA blocks Nursing shifts, not AIN. Only `active` ↔ `pending-approval` is
  automatic. Admin-set states (account blocked/archived/rejected, profession `inactive`) are
  never auto-changed. Every automatic change is logged as an HCP activity ("by System").
- **Missing / pending / rejected drive the flips; expiry doesn't.** Expiry is enforced **live**
  at every accept/assign/transfer (`HcpEligibilityService` → `expiredMandatoryDocumentNames()`,
  checked against the shift's date).
- **Assignment is never blocked; the documents decide the status.** Admin "Add profession" and
  new-HCP creation call `HcpDocumentService::initialProfessionStatus()`: `active` if the
  profession's documents are all in, else `pending-approval`. An explicit `inactive` is
  respected; `active` is never an override.
- **Admin "Activate profession" is strict.** `PATCH /hcps/{id}/professions/{profId}/status` is
  refused (422) unless every required document is uploaded, approved **and in date**. The
  message lists each problem.
- **Uploads are still per profession** (legacy): a profession document is stored with
  `document_hcp.profession_id`, and each profession only sees its own copy. An HCP holding AIN
  and Disability Support Worker, both needing First Aid, must upload First Aid twice. Legacy
  data has **1,993** HCP+document pairs uploaded under 2+ professions, and **695 HCPs** hold two
  professions sharing a required document.

## Target model

1. The system has one **document list** (Workforce → Documents: APHRA, First Aid, CPR, Manual
   Handling, …).
2. **Professions require documents** from that list (profession → *Required documents* tab;
   `document_configurations`).
3. **HCPs hold one or more professions.**
4. **HCPs upload documents once, as a person.** An upload references the system document, not
   a profession. One First Aid satisfies every profession that requires it.
5. **Assigning a profession** checks the HCP's documents and sets its status (`active` /
   `pending-approval`). It is **never blocked**: the app only shows which documents to upload
   once a profession is assigned (see "The assignment collision").
6. **Document-expiry cron** (daily, per tenant via `tenants:run`): when a document has expired,
   every profession that requires it goes to **`expired`**. Not `inactive`: that's the
   admin's/HCP's deliberate choice, and the system must never override it. It also sends the
   existing `document-expiry-soon` / `document-expired` notifications.
7. **Re-upload + approval** of a document re-evaluates **every** profession the HCP holds and
   turns `pending-approval` / `expired` ones back to `active` when they're now satisfied.
8. **Shift eligibility** = HCP status active + the shift's profession active + required
   documents (general + that profession's) present and in date on the shift's date. The live
   expiry check stays as a safety net between cron runs.

### The assignment collision (resolved)

If adding a profession *required* the documents first, an HCP could never start: the app lists
documents to upload per **assigned** profession. So assignment always succeeds; the document
check only decides the **status**:

assign → pending-approval → (app shows its documents) → HCP uploads → admin approves →
re-evaluation → active.

The app's profession and document lists don't filter by profession status, so a pending
profession shows normally.

## Changes needed (blast radius)

No database migration. Existing rows keep `profession_id`; the code stops filtering on it and
uses each HCP's **most recent** copy of a document (latest `updated_at`). Legacy duplicate copies
remain as history.

| # | Where | Change |
|---|---|---|
| 1 | Upload slot `DocumentHcpRepository::findForReplace` | One row per HCP per document. A re-upload updates that copy, whichever profession the app sends. |
| 2 | Status sync `HcpDocumentService::documentProblems` / `applyStatusSync` | Profession scope reads the HCP's latest copy of each required document (any profession). Add `expired` (approved but lapsed) as its own status outcome. |
| 3 | Live expiry check `expiredMandatoryDocumentNames` | Use the latest copy per document, not per profession. |
| 4 | Admin assign picker counts (`HcpRepository` `hcp_expire_profession_document` / pending) and `countExpired*` | Same rule as #3. |
| 5 | Required/missing snapshot `evaluate()` / `approvedProfessionDocumentIds` | Per-person copies. |
| 6 | Mobile: `ProfessionRepository::withDocumentsForHcp` (profession documents screen), `HcpProfessionRepository::forHcpWithDocumentCounts` (dashboard pending) | Show the person's copy under every profession that needs it. **Response shape unchanged:** the app contract must stay byte-compatible. |
| 7 | New `hcps:document-expiry` command + schedule | Expire professions (and the account for general documents, see "Decision"), send notifications, log activities. Has a `--dry-run` that lists who/what would change. |
| 8 | SPA HCP Documents tab | Present profession documents per person (one copy, "required by: AIN, DSW"). |

About 6 backend files, 1 command, small SPA changes. The mobile app needs no update.

## Catalogue cleanup (agency, before switching)

Person-level uploads mean generic documents must become specific. One "Qualification" upload
would otherwise satisfy Chef **and** Registered Nurse. Split *Qualification* / *Other
Qualification* into specific documents (e.g. "Nursing Degree", "Cert III Individual Support")
on the Documents page, and remap each profession's *Required documents*.

## Rollout: the first cron run is the real impact

Measured on the production copy (Sept 2026), of **1,602 active professions** held by active
HCPs:

- **687 professions (435 HCPs)** would go `expired` on the first run from **profession**
  documents alone;
- **1,079 professions (533 HCPs)** if expired **general** documents also expire professions.

Mostly Influenza vaccine, Manual Handling, CPR and Driver's Licence expiries that legacy never
enforced. Note: the live accept check already blocks these HCPs from new shifts; the cron makes
it visible as a status.

Before enabling the cron:

1. On the Documents page, untick **Expiry date required** / **Mandatory** for anything the
   agency doesn't really enforce (e.g. Influenza vaccine). This is the biggest lever.
2. Run the cron with `--dry-run` and review the list.
3. Plan HCP communication. Consider sending expiry-soon reminders a week ahead of the first
   live run, so notifications don't all land at once.

## Decision still open

Should an expired **general** document (police clearance, licence) expire **all** of the HCP's
professions (one rule, the 1,079 figure), or keep blocking at the account level only (the 687
figure)? Recommendation: expire all professions. A lapsed police clearance should stop every
profession, and it keeps a single rule for the app to show.

## One copy per worker (November 2026)

The legacy per-profession duplicates are gone: `HcpDocumentDeduplicator` kept each worker's copy that counts
(`DocumentHcpRepository::bestCopy`) and soft-deleted the rest (files kept), refusing if any worker's compliance would
change. On Smart Healthcare's data: 689 workers, 2,070 certifications held more than once, 2,366 older copies,
compliance identical. The `2026_11_21` migration runs it, adds a unique key on live rows (`live_key`) and drops
`profession_id`, `level_id`, `send_notification_date`, `show_in_document_approval` and `show_in_notification`.
The app's document payload now carries `required_by` (the worker's professions that require it) instead of
`profession`; uploads still send `profession_id` (the certification must be configured for it), which isn't stored.
