Personnel & Attendance¶
Status: Specified — ready to build Build order: 2 of 10 — depends on Projects & Sites
Purpose¶
Personnel tracks every worker on a project — their identity, qualifications, and certifications. Attendance records who is physically on site at any moment, enforces induction requirements before first entry, and triggers alerts when expired licences are detected on site.
The two parts work together. A worker exists in the Personnel register at the project level. Sign-in and headcount happen at the site level. A worker registered on a project can be signed into any site within that project.
Entities¶
Personnel¶
A worker registered on a project. Contains identity and employment details.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| project_id | UUID | ✓ | FK → projects.id |
| first_name | VARCHAR(100) | ✓ | |
| last_name | VARCHAR(100) | ✓ | |
| date_of_birth | DATE | Used for identity verification | |
| VARCHAR(255) | Unique within project; used for portal access in V2 | ||
| phone | VARCHAR(30) | ||
| employment_type | VARCHAR(20) | ✓ | direct | subcontractor | agency |
| subcontractor_id | UUID | FK → subcontractors.id — required if employment_type = subcontractor |
|
| employer_name | VARCHAR(255) | Free text — for agency type or direct employees of client |
|
| ni_number | VARCHAR(20) | National Insurance — stored encrypted, shown masked | |
| right_to_work_status | VARCHAR(30) | ✓ | Default: not_verified — see values below |
| share_code | VARCHAR(20) | Home Office Share Code — stored encrypted, shown masked; required if right_to_work_status = share_code |
|
| right_to_work_verified_at | TIMESTAMPTZ | When RTW was verified | |
| right_to_work_verified_by | UUID | FK → users.id — who verified | |
| emergency_contact_name | VARCHAR(255) | ||
| emergency_contact_phone | VARCHAR(30) | ||
| photo_key | VARCHAR(500) | S3 object key for profile photo | |
| status | VARCHAR(20) | ✓ | Default: active |
| custom_fields | JSONB | ✓ | Default {} |
| created_at | TIMESTAMPTZ | System | |
| updated_at | TIMESTAMPTZ | System | Trigger-maintained |
| created_by | UUID | System | FK → users.id |
| updated_by | UUID | System | FK → users.id |
| deleted_at | TIMESTAMPTZ | NULL = not deleted |
Right to work status values:
| Value | Meaning |
|---|---|
not_verified |
RTW not yet checked |
uk_citizen |
UK/Irish passport or birth certificate verified |
settled_status |
EU Settlement Scheme — settled or pre-settled status verified |
share_code |
Home Office online service checked — Share Code recorded |
visa |
Time-limited visa or work permit verified |
ni_number and share_code are stored encrypted (AES-256 via SQLAlchemy
TypeDecorator) and never included in API responses in plaintext. Both are
masked in display (e.g. ••••••••A1B). Neither is synced to offline SQLite.
CREATE INDEX idx_personnel_project_id
ON personnel(project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_personnel_status
ON personnel(status, project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_personnel_subcontractor
ON personnel(subcontractor_id) WHERE deleted_at IS NULL AND subcontractor_id IS NOT NULL;
CREATE INDEX idx_personnel_employment_type
ON personnel(project_id, employment_type) WHERE deleted_at IS NULL;
-- Partial unique index: email unique per project (allows NULL)
CREATE UNIQUE INDEX idx_personnel_email_project
ON personnel(project_id, email) WHERE deleted_at IS NULL AND email IS NOT NULL;
PersonnelLicence¶
A qualification, certification, or card held by a worker. A single worker can hold multiple licences. Licences are the source of expiry alerts.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| personnel_id | UUID | ✓ | FK → personnel.id |
| project_id | UUID | ✓ | Denormalised from personnel for query efficiency |
| licence_type | VARCHAR(100) | ✓ | See suggested types below |
| licence_number | VARCHAR(100) | ||
| issuing_body | VARCHAR(255) | e.g. CITB, IPAF, NOCN | |
| issue_date | DATE | ||
| expiry_date | DATE | NULL = does not expire (e.g. permanent PTS category) | |
| verified | BOOLEAN | ✓ | Default: false. True = physical card checked by site manager |
| verified_by | UUID | FK → users.id — who verified it | |
| verified_at | TIMESTAMPTZ | ||
| photo_key | VARCHAR(500) | S3 key for photo of licence card | |
| notes | TEXT | ||
| custom_fields | JSONB | ✓ | Default {} |
| created_at | TIMESTAMPTZ | System | |
| updated_at | TIMESTAMPTZ | System | |
| created_by | UUID | System | |
| updated_by | UUID | System | |
| deleted_at | TIMESTAMPTZ |
CREATE INDEX idx_licences_personnel_id
ON personnel_licences(personnel_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_licences_project_id
ON personnel_licences(project_id) WHERE deleted_at IS NULL;
-- Supports the daily expiry scanner
CREATE INDEX idx_licences_expiry_date
ON personnel_licences(expiry_date) WHERE deleted_at IS NULL AND expiry_date IS NOT NULL;
Suggested licence types (tenant adds their own via custom fields or future label config):
| Type | Common context |
|---|---|
| CSCS — Skilled Worker | All construction |
| CSCS — Supervisor | All construction |
| CSCS — Manager | All construction |
| CPCS — [category] | Plant operators |
| NPORS — [category] | Plant operators |
| PTS (Personal Track Safety) | Railway |
| COSS (Controller of Site Safety) | Railway |
| IPAF (MEWPs) | Working at height |
| PASMA (Mobile Tower Scaffold) | Working at height |
| SMSTS | Site management |
| SSSTS | Site supervision |
| First Aid at Work | All sites |
| Emergency First Aid | All sites |
| Asbestos Awareness | Refurbishment/demolition |
| Manual Handling | All sites |
| Confined Space | Utilities/civil |
| Harness / Working at Height | All construction |
| Abrasive Wheels | General |
| Other | Free text |
licence_type is stored as VARCHAR — not a FK. New types are added as workers are
registered; no central type table in V1.
AttendanceRecord¶
A single sign-in/sign-out event for a worker at a site. One open record at a time per worker per site (sign_out_at IS NULL = currently on site).
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| site_id | UUID | ✓ | FK → sites.id |
| project_id | UUID | ✓ | Denormalised for query efficiency |
| personnel_id | UUID | ✓ | FK → personnel.id |
| sign_in_at | TIMESTAMPTZ | ✓ | |
| sign_out_at | TIMESTAMPTZ | NULL = currently on site | |
| sign_in_method | VARCHAR(20) | ✓ | qr_code | manual | nfc |
| sign_in_by | UUID | FK → users.id — who manually signed in the worker (if manual) | |
| sign_out_method | VARCHAR(20) | self | manual | auto_eod |
|
| sign_out_by | UUID | FK → users.id — who signed them out (if manual or auto) | |
| induction_check_passed | BOOLEAN | ✓ | Default: false. Set to true at sign-in if induction complete |
| licence_check_passed | BOOLEAN | ✓ | Default: false. Set to true at sign-in if no expired licences |
| notes | TEXT | Site manager notes on this attendance event | |
| created_at | TIMESTAMPTZ | System | |
| updated_at | TIMESTAMPTZ | System |
CREATE INDEX idx_attendance_site_id
ON attendance_records(site_id, sign_in_at DESC);
CREATE INDEX idx_attendance_personnel_id
ON attendance_records(personnel_id, sign_in_at DESC);
CREATE INDEX idx_attendance_project_id
ON attendance_records(project_id, sign_in_at DESC);
-- Headcount query: currently on site
CREATE INDEX idx_attendance_open
ON attendance_records(site_id) WHERE sign_out_at IS NULL;
-- Prevent duplicate sign-in per worker per site
CREATE UNIQUE INDEX idx_attendance_open_unique
ON attendance_records(personnel_id, site_id) WHERE sign_out_at IS NULL;
SiteQRCode¶
Each site has a unique QR code used for self-service sign-in. Regenerating the QR invalidates the old one.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK |
| site_id | UUID | ✓ | FK → sites.id, UNIQUE |
| token | VARCHAR(64) | ✓ | Cryptographically random, UNIQUE across tenant |
| generated_at | TIMESTAMPTZ | ✓ | |
| generated_by | UUID | ✓ | FK → users.id |
| expires_at | TIMESTAMPTZ | ✓ | Default: 90 days from generated_at. Tenant-configurable via settings. |
| last_used_at | TIMESTAMPTZ | Updated on each successful sign-in scan. |
CREATE UNIQUE INDEX idx_site_qr_site ON site_qr_codes(site_id);
CREATE UNIQUE INDEX idx_site_qr_token ON site_qr_codes(token);
A QR code encodes a URL: https://{tenant-slug}.construo.build/sign-in?t={token}
The sign-in page at that URL presents the worker with a simple form (name lookup) and completes the sign-in without requiring the worker to have a platform account.
Status Lifecycle — Personnel¶
| Status | Meaning |
|---|---|
active |
Worker is on the project, eligible to sign in |
suspended |
Temporarily prevented from signing in (e.g. failed drug test, disciplinary) |
archived |
Worker has left the project — historical record retained |
| From | Permitted transitions |
|---|---|
active |
suspended, archived |
suspended |
active, archived |
archived |
— terminal |
Archiving a worker does not delete their attendance history.
Business Rules¶
Personnel¶
-
A worker is registered at the project level and can be signed into any site within that project.
-
employment_type = subcontractorrequiressubcontractor_id. Validated in service layer before DB write. -
ni_numberis encrypted at rest using AES-256 via a SQLAlchemy TypeDecorator. It is never returned in list responses — only in the full detail response to Project Manager and above. Displayed masked (NI: ****7890) on the frontend. -
Worker photos are uploaded via the presigned S3 pattern.
photo_keystores the S3 object key. The API returns a short-lived presigned GET URL in the response, not the raw key. -
Soft deleting a worker does not remove their attendance records or licence data. Historical records are preserved.
-
A
suspendedorarchivedworker is blocked from signing in. The sign-in flow checks status before processing.
Licences¶
-
expiry_date IS NULLmeans the licence does not expire. These licences are never included in expiry alert scans. -
verified = truemeans someone has physically inspected the card. Theverified_byandverified_atfields record who did it. Verification can be revoked (set back tofalse) — for example if the card was found to be a forgery. -
For the sign-in licence check, a licence is expired if
expiry_date < CURRENT_DATE. Licences expiring today are not yet expired. -
The licence check at sign-in reviews all non-deleted licences for the worker. If any have
expiry_date < CURRENT_DATE,licence_check_passed = falseis recorded on the attendance record and thepersonnel.licence_expired_on_sitenotification is triggered. The sign-in is not blocked by expired licences — only induction non-completion blocks sign-in.
Attendance¶
-
A worker can only have one open attendance record per site at a time. Enforced by the unique partial index. Attempting to sign in a worker already on site returns 409.
-
The QR code sign-in flow resolves the worker by name lookup against the personnel register for the project. No Cognito account is required for a worker to sign in via QR.
-
End-of-day auto sign-out: a configurable time (tenant default 23:59) triggers a background job to sign out all workers still marked on site. Method recorded as
auto_eod. The auto sign-out time is tenant-level config, not per-site. -
Site managers can manually sign workers in or out from the Attendance page. Manual sign-in records
sign_in_method = manualandsign_in_by = current_user.id. -
Headcount is a derived value — never stored. Computed as
COUNT(*) FROM attendance_records WHERE site_id = ? AND sign_out_at IS NULL. -
A QR code whose
expires_atis in the past returns HTTP 403 on the public/sign-inendpoint with error codeqr_code_expired. The UI prompts the site manager to regenerate. Regenerating always creates a fresh token with a newexpires_at(90 days, or tenant-configured value).
Sign-in Flow¶
The QR code and manual sign-in paths follow the same check sequence:
Worker arrives at site → scans QR code (or site manager triggers manual sign-in)
1. Resolve token → site_id (site_qr_codes table)
2. Look up worker in personnel register by name selection
3. Check worker status:
suspended → 403, display reason
archived → 403
4. Check induction completion:
No complete induction for this site
→ write partial attendance record (induction_check_passed = false)
→ trigger notification: induction.blocked_signin
→ return 422 with induction_required error
5. Check open attendance record — already signed in → 409
6. Check licences for expired entries (expiry_date < CURRENT_DATE):
Found → set licence_check_passed = false on the record
→ trigger notification: personnel.licence_expired_on_site
→ sign-in proceeds (not blocked)
7. Write attendance_record (sign_in_at = now(), induction_check_passed = true)
8. Return 201 — sign-in confirmed, licence_warnings in response if any
The induction check in step 4 calls the Inductions module service:
has_completed_induction(personnel_id, site_id) → bool.
Build with a stub returning True during Personnel development; replace with the
real implementation when Inductions is built.
API Endpoints¶
Personnel¶
| Method | Path | Description |
|---|---|---|
| GET | /projects/{projectId}/personnel |
List workers (paginated, filterable) |
| POST | /projects/{projectId}/personnel |
Register worker |
| GET | /projects/{projectId}/personnel/{id} |
Full detail |
| PATCH | /projects/{projectId}/personnel/{id} |
Partial update |
| DELETE | /projects/{projectId}/personnel/{id} |
Soft delete |
| POST | /projects/{projectId}/personnel/{id}/transition |
Status transition |
| POST | /projects/{projectId}/personnel/{id}/photo |
Upload photo (returns presigned PUT URL) |
Licences¶
| Method | Path | Description |
|---|---|---|
| GET | /projects/{projectId}/personnel/{id}/licences |
List licences for worker |
| POST | /projects/{projectId}/personnel/{id}/licences |
Add licence |
| GET | /projects/{projectId}/personnel/{id}/licences/{licenceId} |
Licence detail |
| PATCH | /projects/{projectId}/personnel/{id}/licences/{licenceId} |
Update licence |
| DELETE | /projects/{projectId}/personnel/{id}/licences/{licenceId} |
Soft delete |
| POST | /projects/{projectId}/personnel/{id}/licences/{licenceId}/verify |
Mark as verified |
| POST | /projects/{projectId}/personnel/{id}/licences/{licenceId}/photo |
Upload licence photo |
Attendance¶
| Method | Path | Description |
|---|---|---|
| GET | /sites/{siteId}/attendance |
List records (paginated, filterable) |
| POST | /sites/{siteId}/attendance/sign-in |
Sign in a worker |
| POST | /sites/{siteId}/attendance/sign-out |
Sign out a worker |
| GET | /sites/{siteId}/attendance/headcount |
Current headcount and on-site list |
| GET | /sites/{siteId}/attendance/{id} |
Single record detail |
| PATCH | /sites/{siteId}/attendance/{id} |
Correct a record (Project Manager+) |
QR Codes¶
| Method | Path | Description |
|---|---|---|
| GET | /sites/{siteId}/qr-code |
Get current QR code (generates if none exists) |
| POST | /sites/{siteId}/qr-code/regenerate |
Invalidate and reissue |
| POST | /sign-in |
Public endpoint — resolves token, initiates sign-in |
POST /sign-in is the only unauthenticated endpoint. It accepts a token query
parameter and uses a narrow scoped session that can only complete a sign-in — it
cannot access any other API operation.
Request / Response Shapes¶
POST /projects/{projectId}/personnel¶
// Request
{
"first_name": "James",
"last_name": "Brennan",
"date_of_birth": "1985-03-14",
"phone": "07700 900123",
"email": "j.brennan@acme-civil.co.uk",
"employment_type": "subcontractor",
"subcontractor_id": "uuid-subco",
"emergency_contact_name": "Sarah Brennan",
"emergency_contact_phone": "07700 900456",
"custom_fields": {}
}
// Response 201
{
"id": "uuid-worker",
"project_id": "uuid-project",
"first_name": "James",
"last_name": "Brennan",
"full_name": "James Brennan",
"date_of_birth": "1985-03-14",
"phone": "07700 900123",
"email": "j.brennan@acme-civil.co.uk",
"employment_type": "subcontractor",
"subcontractor_id": "uuid-subco",
"subcontractor_name": "Acme Civil Ltd",
"employer_name": null,
"emergency_contact_name": "Sarah Brennan",
"emergency_contact_phone": "07700 900456",
"photo_url": null,
"status": "active",
"licence_count": 0,
"has_expired_licences": false,
"custom_fields": {},
"created_at": "2026-05-28T09:00:00Z",
"updated_at": "2026-05-28T09:00:00Z",
"created_by": "uuid-user"
}
ni_number is accepted as an input but never returned in responses. Project Managers
see it masked (NI: ****7890) in the detail view; Site Managers do not see it at all.
GET /projects/{projectId}/personnel¶
Query params: status, employment_type, subcontractor_id, q (name search),
has_expired_licences (boolean), sort (default last_name), limit, cursor
// Response 200
{
"data": [
{
"id": "uuid-worker",
"full_name": "James Brennan",
"employment_type": "subcontractor",
"subcontractor_name": "Acme Civil Ltd",
"status": "active",
"licence_count": 3,
"has_expired_licences": false,
"next_expiry_date": "2026-11-30",
"photo_url": "https://...",
"created_at": "2026-05-28T09:00:00Z"
}
],
"pagination": {
"total": 47,
"cursor": "eyJpZCI6Ii4uLiJ9",
"has_more": true
}
}
POST /projects/{projectId}/personnel/{id}/licences¶
// Request
{
"licence_type": "CSCS — Skilled Worker",
"licence_number": "CS/1234567",
"issuing_body": "CITB",
"issue_date": "2022-06-01",
"expiry_date": "2027-06-01",
"verified": false
}
// Response 201
{
"id": "uuid-licence",
"personnel_id": "uuid-worker",
"licence_type": "CSCS — Skilled Worker",
"licence_number": "CS/1234567",
"issuing_body": "CITB",
"issue_date": "2022-06-01",
"expiry_date": "2027-06-01",
"verified": false,
"verified_by": null,
"verified_at": null,
"photo_url": null,
"days_until_expiry": 369,
"status": "valid",
"created_at": "2026-05-28T09:00:00Z"
}
status on a licence response is computed:
| Value | Condition |
|---|---|
no_expiry |
expiry_date IS NULL |
valid |
expiry_date >= CURRENT_DATE + configured threshold |
expiring_soon |
expiry_date within the configured alert threshold |
expired |
expiry_date < CURRENT_DATE |
POST /sites/{siteId}/attendance/sign-in¶
// Request
{
"personnel_id": "uuid-worker",
"sign_in_method": "manual",
"notes": null
}
// Response 201 — signed in, expired licence present
{
"id": "uuid-attendance",
"personnel_id": "uuid-worker",
"full_name": "James Brennan",
"site_id": "uuid-site",
"sign_in_at": "2026-05-28T07:34:00Z",
"sign_out_at": null,
"sign_in_method": "manual",
"induction_check_passed": true,
"licence_check_passed": false,
"licence_warnings": [
{
"licence_type": "CPCS — Excavator 360°",
"expiry_date": "2026-04-30",
"days_overdue": 28
}
]
}
// Response 422 — induction not complete
{
"error": "induction_required",
"message": "James Brennan has not completed the site induction for this site.",
"personnel_id": "uuid-worker",
"site_id": "uuid-site"
}
// Response 409 — already on site
{
"error": "already_signed_in",
"message": "James Brennan is already signed in to this site.",
"attendance_id": "uuid-open-record"
}
GET /sites/{siteId}/attendance/headcount¶
// Response 200
{
"site_id": "uuid-site",
"site_name": "Canary Wharf — Site A",
"headcount": 14,
"as_at": "2026-05-28T10:15:00Z",
"workers": [
{
"attendance_id": "uuid-attendance",
"personnel_id": "uuid-worker",
"full_name": "James Brennan",
"employment_type": "subcontractor",
"subcontractor_name": "Acme Civil Ltd",
"sign_in_at": "2026-05-28T07:34:00Z",
"licence_check_passed": false
}
]
}
Permission Matrix¶
| Action | Platform Admin | Tenant Admin | Project Manager | Site Manager | Site Operative | Viewer |
|---|---|---|---|---|---|---|
| List personnel | ✓ all | ✓ all | ✓ assigned projects | ✓ assigned sites | ✗ | ✓ assigned projects |
| Register worker | ✓ | ✓ | ✓ assigned | ✓ | ✗ | ✗ |
| View worker detail | ✓ | ✓ | ✓ assigned | ✓ assigned site | ✗ | ✓ assigned (no NI) |
| View NI number | ✓ | ✓ | ✓ masked | ✗ | ✗ | ✗ |
| Edit worker | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| Transition status | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| Delete worker | ✓ | ✓ | ✓ assigned | ✗ | ✗ | ✗ |
| Manage licences | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| Verify licence | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| View attendance | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✓ |
| Manual sign-in/out | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| Self sign-in (QR) | — | — | — | — | ✓ | — |
| View headcount | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✓ |
| Correct attendance | ✓ | ✓ | ✓ assigned | ✗ | ✗ | ✗ |
| Manage QR codes | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
"Own site" = sites where site_manager_id = current_user.id or explicit project assignment row.
Notifications Triggered¶
| Event | Trigger point | Notification type |
|---|---|---|
| Licence approaching expiry | Daily scanner (EventBridge 06:00 UTC) | licence.expiry |
| Expired licence detected on sign-in | POST /attendance/sign-in | personnel.licence_expired_on_site |
| Induction blocked sign-in | POST /attendance/sign-in | induction.blocked_signin |
| QR code approaching expiry | Daily scanner (EventBridge 06:00 UTC) | site.qr_expiry |
The daily scanner queries:
SELECT pl.*, p.project_id
FROM personnel_licences pl
JOIN personnel p ON p.id = pl.personnel_id
WHERE pl.deleted_at IS NULL
AND pl.expiry_date IS NOT NULL
AND pl.expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + :advance_days
AND p.status = 'active'
AND p.deleted_at IS NULL
The personnel.licence_expired_on_site notification fires at sign-in time, once per
worker per site per day. Idempotency key:
personnel.licence_expired_on_site:{personnel_id}:{site_id}:{date}
The site.qr_expiry notification fires 7 days before site_qr_codes.expires_at. The
daily scanner queries:
SELECT sqc.*, s.site_manager_id, s.project_id
FROM site_qr_codes sqc
JOIN sites s ON s.id = sqc.site_id
WHERE sqc.expires_at BETWEEN NOW() AND NOW() + INTERVAL '7 days'
AND s.deleted_at IS NULL
Recipient: the site's site_manager_id. Idempotency key:
site.qr_expiry:{site_id}:{expires_at::date}
Offline Sync Scope¶
| Data | Syncs to | Notes |
|---|---|---|
| Personnel list | Project Manager, Site Manager | All fields except ni_number |
| PersonnelLicences | Project Manager, Site Manager | All fields |
| AttendanceRecords | Site Manager | Last 90 days for assigned sites |
| SiteQRCode | Site Manager | Token required for offline sign-in confirmation |
| Headcount (derived) | Not synced | Recomputed from local AttendanceRecords |
Offline sign-in queues the attendance write locally and replays on reconnect. If the
server returns 409 on replay (worker signed in by another device in the interim), the
queue item is marked failed and shown to the site manager as a conflict to resolve.
ni_number and share_code never sync to any client device under any circumstances.
right_to_work_status syncs as a read-only field so site managers can see
verification state offline, but right_to_work_verified_at and
right_to_work_verified_by do not sync.
Cross-Module Dependencies¶
Upstream (must exist before building)¶
| Module | Dependency |
|---|---|
| Projects & Sites | project_id, site_id |
| Subcontractors | subcontractor_id — required for subcontract workers |
The Subcontractors dependency is soft in V1: subcontractor_id can be nullable and
the FK constraint added in a migration once Subcontractors is built.
Downstream (read from Personnel & Attendance)¶
| Module | Usage |
|---|---|
| Inductions | personnel_id — induction completion check at sign-in |
| Site Diary | Headcount from attendance_records (daily summary) |
| Incidents | References involved workers from personnel register |
Sign-in dependency on Inductions¶
The sign-in flow reads induction completion status from the Inductions service. The
interface: has_completed_induction(personnel_id: UUID, site_id: UUID) -> bool.
Build with a stub returning True during Personnel development; replace with the
real implementation when Inductions is built.
Tests Required¶
Personnel CRUD¶
- Register worker — valid payload → 201
- Register worker —
employment_type = subcontractorwithoutsubcontractor_id→ 422 - Register worker — duplicate email within project → 409
- Register worker — same email on a different project → 201 (allowed)
- List workers — pagination correct
- List workers — filter by
employment_type,status,has_expired_licences - List workers — name search (
q) matches on first name, last name, and full name - Get worker detail —
ni_numberpresent (masked) for Project Manager, absent for Site Manager - Update worker — PATCH semantics, unspecified fields unchanged
- Transition status — all valid transitions succeed
- Transition status — invalid transition → 409
- Delete worker — soft delete; attendance history and licences preserved
Licences¶
- Add licence — valid payload → 201
- Add licence —
expiry_datein the past → accepted (records expired licence) days_until_expirycomputed correctly — positive (future), negative (overdue)- Computed
statuscorrect for all four values - Verify licence — sets
verified = true, recordsverified_byandverified_at - Soft delete licence — excluded from sign-in check and list; attendance history unchanged
Attendance¶
- Sign in — induction complete, no expired licences → 201, both checks
true - Sign in — expired licence → 201,
licence_check_passed = false,licence_warningspopulated - Sign in — induction not complete → 422,
induction_requirederror - Sign in — worker suspended → 403
- Sign in — worker archived → 403
- Sign in — worker already signed in → 409
- Sign out — open record closed,
sign_out_atset - Sign out — worker not currently signed in → 404
- Headcount — count and list reflect only workers with open records
- Headcount — signed-out workers excluded
- Manual sign-in —
sign_in_method = manual,sign_in_byset to acting user - End-of-day auto sign-out — open records closed, method recorded as
auto_eod - Correct attendance — PATCH by Project Manager changes
sign_in_atorsign_out_at - Correct attendance — Site Manager cannot correct → 403
QR Codes¶
- Get QR code — generates token on first request
- Get QR code — returns same token on subsequent requests
- QR sign-in — valid token resolves correct site
- QR sign-in — invalid token → 404
- QR sign-in — expired token (
expires_atin past) → 403qr_code_expired - Regenerate QR — old token no longer resolves → 404
- Regenerate QR — new token has
expires_at90 days from now - Daily scanner — fires
site.qr_expiryexactly 7 days before expiry, idempotent on re-run
Cross-tenant isolation (mandatory)¶
- GET worker whose
project_idbelongs to another tenant → 404 - POST licence with
personnel_idfrom another tenant → 404 - POST sign-in with
personnel_idfrom another tenant's project → 404 - QR token belonging to another tenant's site → 404
Right to Work¶
- Create personnel with
right_to_work_status = share_code, noshare_codevalue → 422 - Update
right_to_work_statustoshare_code— setsright_to_work_verified_atandright_to_work_verified_byfrom request context share_codeabsent from all API responses (encrypted, never exposed)share_codeabsent from offline sync payload
Permission boundaries¶
- Site Manager cannot view
ni_number— field absent from response - Site Manager cannot view
share_code— field absent from response - Site Manager cannot register a worker on an unassigned project → 403
- Site Manager cannot correct an attendance record → 403
- Viewer cannot edit personnel → 403
- Viewer headcount response contains count only —
workersarray absent
Notification triggers¶
- Sign-in with expired licence →
personnel.licence_expired_on_sitepushed to SQS - Same worker, same site, same day sign-in again → second push suppressed (idempotency)
- Sign-in blocked by induction →
induction.blocked_signinpushed to SQS
Default Tenant Labels¶
| Key | Default |
|---|---|
module.personnel |
Personnel |
module.attendance |
Attendance |
field.personnel.employment_type |
Employment Type |
field.personnel.subcontractor_id |
Company |
field.personnel.employer_name |
Employer |
field.personnel.emergency_contact_name |
Emergency Contact |
field.personnel.ni_number |
NI Number |
field.licence.licence_type |
Licence / Certification |
field.licence.issuing_body |
Issuing Body |
field.licence.expiry_date |
Expiry Date |
field.licence.verified |
Verified |
field.attendance.sign_in_method |
Sign-in Method |
status.personnel.active |
Active |
status.personnel.suspended |
Suspended |
status.personnel.archived |
Archived |
status.licence.valid |
Valid |
status.licence.expiring_soon |
Expiring Soon |
status.licence.expired |
Expired |
status.licence.no_expiry |
No Expiry |
Foundation Dependencies¶
| Component | Location | Used for |
|---|---|---|
TenantMiddleware |
core/middleware.py |
Sets request.state.tenant |
get_db |
core/db.py |
Schema-scoped AsyncSession |
get_current_user |
core/security.py |
CurrentUser with roles and project access |
require_permission |
core/security.py |
RBAC dependency factory |
EntityMixin |
core/models.py |
Universal columns |
PaginatedResponse |
core/pagination.py |
List endpoint wrapper |
ConstruoError hierarchy |
core/exceptions.py |
Typed exceptions |
presigned_put_url |
core/storage.py |
Photo upload flow |
encrypt_field / decrypt_field |
core/crypto.py |
NI number encryption |
SQSClient |
core/events.py |
Notification trigger at sign-in |
audit_entity_change trigger |
DB foundation | Applied to all four tables |
conftest.py fixtures |
tests/conftest.py |
client, auth_headers, test_tenants |
factories.py |
tests/factories.py |
create_personnel, create_licence, create_attendance |