Appointed Roles¶
Status: Specified — ready to build Build order: 12 of 13 — depends on Projects & Sites, Personnel
Purpose¶
Appointed Roles is the project-level register of formal safety role appointments. UK H&S legislation and construction contract requirements mandate that specific roles are held by named, qualified individuals on every project and site — Fire Marshal, First Aider, COSHH Coordinator, Lifting Appointed Person, and others. This module tracks who holds each appointment, their qualification status, and the formal appointment letter on record.
The register surfaces gaps: roles with no appointee, nominations awaiting approval, and roles where the appointed person's qualification is expiring.
Entities¶
RoleDefinition¶
A standard safety role that can be appointed on a project. Platform provides a default set; tenants can add custom roles.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| project_id | UUID | FK → projects.id — NULL = platform standard role | |
| name | VARCHAR(255) | ✓ | Role title |
| description | TEXT | Competence requirements and responsibilities | |
| requires_qualification | BOOLEAN | ✓ | Default: true — whether linked licence/card is expected |
| 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 | |
| deleted_at | TIMESTAMPTZ |
Platform standard roles:
| Role | Notes |
|---|---|
| First Aider | Valid First Aid at Work certificate required |
| Fire Marshal | Fire extinguisher trained; adequate authority |
| Fire Safety Coordinator | Senior fire safety role |
| COSHH Coordinator | CITB SMSTS or IOSH Managing Safely + COSHH Coordinator training |
| Lifting Appointed Person (AP) | Experienced, valid CPCS A61 or equivalent |
| Lifting Operations Supervisor | CPCS or CITB qualified |
| Lifting Operations Slinger/Signaller | CPCS A40 or equivalent |
| PVPC — People, Vehicle & Plant Coordinator | |
| Utility Coordinator | EUSR Service Avoidance or equivalent |
| AP — Permit to Break Ground | EUSR Category 1 & 2 or equivalent |
| Electrical SSoW Appointed Person | |
| MEWP Coordinator | |
| Confined Space Coordinator | |
| Environmental Coordinator | |
| Waste Coordinator | |
| Mental Health First Aider | |
| Hot Works Responsible Person | |
| Manual Handling Competent Person | |
| Temporary Works Coordinator | |
| Responsible Person (Excavation Gang) | EUSR Category 1 & 2 |
RoleAppointment¶
The record of a person holding (or being nominated for) a specific role on a project or site.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| project_id | UUID | ✓ | FK → projects.id |
| site_id | UUID | FK → sites.id — NULL = project-wide appointment | |
| role_definition_id | UUID | ✓ | FK → role_definitions.id |
| personnel_id | UUID | ✓ | FK → personnel.id |
| status | VARCHAR(30) | ✓ | See values below — default: nominated |
| effective_date | DATE | Date the appointment takes effect | |
| expiry_date | DATE | NULL = no expiry | |
| appointment_letter_key | VARCHAR(500) | S3 key for signed appointment letter | |
| notes | TEXT | Any conditions or limitations on the appointment | |
| 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 | |
| deleted_at | TIMESTAMPTZ |
CREATE INDEX idx_role_appointment_project
ON role_appointments(project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_role_appointment_site
ON role_appointments(site_id) WHERE deleted_at IS NULL AND site_id IS NOT NULL;
CREATE INDEX idx_role_appointment_personnel
ON role_appointments(personnel_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_role_appointment_status
ON role_appointments(project_id, status) WHERE deleted_at IS NULL;
-- Expiry scanner
CREATE INDEX idx_role_appointment_expiry
ON role_appointments(expiry_date) WHERE deleted_at IS NULL AND expiry_date IS NOT NULL;
Appointment status values:
| Status | Meaning |
|---|---|
appointed |
Formally appointed — appointment letter on file |
nominated |
Person identified; approval or paperwork outstanding |
training_required |
Person identified but does not yet hold the required qualification |
Business Rules¶
-
Multiple people can hold the same role on a project. There is no uniqueness constraint on
(project_id, role_definition_id)— a project may have three First Aiders. -
A
site_idscopes the appointment to a specific site. A project-wide appointment (site_id = NULL) covers all sites on the project. Both can coexist — a project-wide Lifting AP and a site-specific Lifting AP are separate records. -
Transitioning status to
appointeddoes not require anappointment_letter_key— for smaller projects, verbal confirmation may be recorded innotes. The letter upload is encouraged but not enforced by the API. -
The list endpoint surfaces a "gap" view: for each standard role, it shows whether the project has at least one
appointedholder. Roles with no appointed holder are flagged in the response. -
Platform standard role definitions (
project_id = NULL) cannot be edited or deleted. Tenants can create additional project-level roles. -
expiry_dateon an appointment reflects when the appointment itself expires (not the person's underlying qualification). The expiry scanner generates a notification when an appointment is approaching expiry.
API Endpoints¶
Role Definitions¶
| Method | Path | Description |
|---|---|---|
| GET | /projects/{projectId}/role-definitions |
List standard + project-specific roles |
| POST | /projects/{projectId}/role-definitions |
Create project-specific role |
| PATCH | /projects/{projectId}/role-definitions/{id} |
Update project-specific role |
| DELETE | /projects/{projectId}/role-definitions/{id} |
Soft delete project-specific role |
Appointments¶
| Method | Path | Description |
|---|---|---|
| GET | /projects/{projectId}/appointed-roles |
Full appointment register with gap view |
| POST | /projects/{projectId}/appointed-roles |
Create appointment |
| GET | /projects/{projectId}/appointed-roles/{id} |
Appointment detail |
| PATCH | /projects/{projectId}/appointed-roles/{id} |
Update appointment |
| DELETE | /projects/{projectId}/appointed-roles/{id} |
Soft delete |
| POST | /projects/{projectId}/appointed-roles/{id}/upload-letter |
Upload appointment letter (returns presigned PUT URL) |
Request / Response Shapes¶
POST /projects/{projectId}/appointed-roles¶
// Request
{
"site_id": null,
"role_definition_id": "uuid-role-def",
"personnel_id": "uuid-person",
"status": "appointed",
"effective_date": "2026-05-28",
"expiry_date": null,
"notes": "CPCS A61 card verified — copy on file."
}
// Response 201
{
"id": "uuid-appointment",
"project_id": "uuid-project",
"site_id": null,
"role": {
"id": "uuid-role-def",
"name": "Lifting Appointed Person (AP)",
"requires_qualification": true
},
"personnel": {
"id": "uuid-person",
"full_name": "James Fletcher",
"job_title": "Crane Supervisor"
},
"status": "appointed",
"effective_date": "2026-05-28",
"expiry_date": null,
"appointment_letter_key": null,
"notes": "CPCS A61 card verified — copy on file.",
"created_at": "2026-05-28T09:00:00Z"
}
GET /projects/{projectId}/appointed-roles¶
Query params: site_id (NULL for project-wide, UUID for site-specific, all
for both), status, role_definition_id, personnel_id,
show_gaps (default true), limit, cursor
// Response 200
{
"appointments": [
{
"id": "uuid-appointment",
"role": { "id": "uuid-role-def", "name": "First Aider" },
"personnel": { "id": "uuid-person", "full_name": "Sarah Ahmed" },
"status": "appointed",
"site_id": null,
"effective_date": "2026-05-01",
"expiry_date": null
}
],
"gaps": [
{
"role_definition_id": "uuid-role-def-2",
"role_name": "Fire Marshal",
"has_appointed": false,
"nominations": 1
}
],
"pagination": { "total": 8, "cursor": null, "has_more": false }
}
Permission Matrix¶
| Action | Platform Admin | Tenant Admin | Project Manager | Site Manager | Viewer |
|---|---|---|---|---|---|
| View appointment register | ✓ | ✓ | ✓ assigned | ✓ own site | ✓ assigned |
| Create / edit appointment | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Transition status | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Upload appointment letter | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Delete appointment | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Create project-specific role definition | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
Notifications Triggered¶
| Event | Trigger point | Notification type |
|---|---|---|
| Appointment approaching expiry | Daily scanner (EventBridge 06:00 UTC) | appointed_role.expiry |
Scanner query:
SELECT *
FROM role_appointments
WHERE deleted_at IS NULL
AND expiry_date IS NOT NULL
AND expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + :advance_days
AND status = 'appointed'
Offline Sync Scope¶
| Data | Syncs to | Notes |
|---|---|---|
| RoleDefinitions | Project Manager, Site Manager | Standard + project roles |
| RoleAppointments | Project Manager, Site Manager | Full register for assigned project |
Offline reads are sufficient — appointments are not created or edited on site in normal workflow.
Cross-Module Dependencies¶
Upstream¶
| Module | Dependency |
|---|---|
| Projects & Sites | project_id, optional site_id |
| Personnel | personnel_id |
Downstream¶
None. Appointed Roles is a terminal module.
Tests Required¶
Role Definitions¶
- List includes platform standard roles + project-specific roles
- Create project-specific role → 201
- Cannot delete platform standard role → 403
Appointments¶
- Create appointment — valid → 201
- Create appointment — multiple people in same role → 201 (allowed)
- Create project-wide appointment (
site_id = null) → 201 - Create site-scoped appointment → 201
- Transition status
nominated→appointed→ 200 - Upload appointment letter — returns presigned PUT URL
- List with
show_gaps=true— gaps array populated for roles with noappointedholder - Expiry scanner — appointment with
expiry_datein advance window → notification
Cross-tenant isolation (mandatory)¶
- GET appointments from another tenant's project → 404
- POST appointment with
personnel_idfrom another tenant → 404
Permission boundaries¶
- Site Manager cannot create or edit appointment → 403
- Project Manager can create appointment → 201
- Viewer can view register → 200
Default Tenant Labels¶
| Key | Default |
|---|---|
module.appointed_roles |
Appointed Roles |
field.appointment.role |
Role |
field.appointment.status |
Appointment Status |
field.appointment.effective_date |
Effective Date |
field.appointment.expiry_date |
Expiry Date |
field.appointment.appointment_letter |
Appointment Letter |
status.appointment.appointed |
Appointed |
status.appointment.nominated |
Nominated |
status.appointment.training_required |
Training Required |
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 |
Auth |
require_permission |
core/security.py |
RBAC |
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 |
Appointment letter upload |
audit_entity_change trigger |
DB foundation | Applied to role_appointments |
factories.py |
tests/factories.py |
create_role_appointment |