Skip to content

Inductions

Status: Specified — ready to build Build order: 3 of 10 — depends on Projects & Sites, Personnel & Attendance


Purpose

Inductions ensures every worker has read and acknowledged site safety rules before being allowed onto a site for the first time. A site manager creates an induction template containing the briefing content and any declaration statements. Workers complete it digitally and capture a signature. The completion is the gate for the Personnel sign-in flow.


Entities

InductionTemplate

One active template per site. Defines the content and required declarations. When the content is updated a new version is created — old completions remain valid against the version at the time of completion.

Field Type Required Notes
id UUID System PK, generated
site_id UUID FK → sites.id
project_id UUID Denormalised for query efficiency
title VARCHAR(255) Displayed to workers on the completion screen
content TEXT Briefing text — supports Markdown
declarations JSONB Ordered list of statement strings the worker must tick
signature_required BOOLEAN Default: true
refresh_days INTEGER NULL = never expires. 90 / 180 / 365 = worker must redo after N days
version INTEGER Default: 1. Increments on each publish
is_active BOOLEAN Default: true. Only one active template per site
invalidate_on_new_version BOOLEAN Default: false. If true, publishing a new version voids all existing completions
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
-- Only one active template per site
CREATE UNIQUE INDEX idx_induction_template_active
    ON induction_templates(site_id) WHERE is_active = true AND deleted_at IS NULL;
CREATE INDEX idx_induction_template_site
    ON induction_templates(site_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_induction_template_project
    ON induction_templates(project_id) WHERE deleted_at IS NULL;

Declarations format:

[
  "I have read and understood the site induction briefing",
  "I am aware of the emergency assembly point and evacuation procedure",
  "I confirm I will wear the required PPE at all times on site",
  "I understand that breaching site safety rules may result in removal from site"
]

Each declaration renders as a required checkbox on the completion form.


InductionCompletion

A record of a worker completing a specific induction template. One completion per (worker, template) pair. If invalidate_on_new_version = true, old completions are soft-deleted when a new version is published.

Field Type Required Notes
id UUID System PK, generated
template_id UUID FK → induction_templates.id
template_version INTEGER Snapshot of template.version at time of completion
personnel_id UUID FK → personnel.id
site_id UUID Denormalised from template
project_id UUID Denormalised
completed_at TIMESTAMPTZ
completed_by_method VARCHAR(20) self | witnessed
witnessed_by UUID FK → users.id — site manager who witnessed in-person completion
declarations_accepted BOOLEAN[] One boolean per declaration, in order
signature_key VARCHAR(500) S3 key for drawn signature image — required if signature_required = true
expires_at TIMESTAMPTZ Computed: completed_at + template.refresh_days DAYS. NULL if no refresh
notes TEXT
created_at TIMESTAMPTZ System
updated_at TIMESTAMPTZ System
deleted_at TIMESTAMPTZ NULL = valid. Set to NOW() when invalidated
-- Valid completion lookup — used on every sign-in
CREATE INDEX idx_induction_completion_lookup
    ON induction_completions(personnel_id, site_id)
    WHERE deleted_at IS NULL;
CREATE INDEX idx_induction_completion_template
    ON induction_completions(template_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_induction_completion_project
    ON induction_completions(project_id) WHERE deleted_at IS NULL;
-- Refresh expiry scanner
CREATE INDEX idx_induction_completion_expires
    ON induction_completions(expires_at)
    WHERE expires_at IS NOT NULL AND deleted_at IS NULL;

Sign-in Check Interface

The Personnel module calls this function during the sign-in flow.

async def has_completed_induction(
    personnel_id: UUID,
    site_id: UUID,
    db: AsyncSession,
) -> bool:
    """
    Returns True if:
    - No active InductionTemplate exists for the site (no induction configured), OR
    - A valid InductionCompletion exists for this worker at this site:
        - deleted_at IS NULL
        - expires_at IS NULL OR expires_at > NOW()

    Returns False if an active template exists but no valid completion does.
    """

Build with a stub returning True during Personnel development. Replace with this real implementation when Inductions is built. The stub lives in apps/api/src/modules/inductions/service.py and is imported by the Personnel sign-in service.


Business Rules

  1. Only one InductionTemplate can be active (is_active = true) per site at a time. Creating a new version deactivates the previous one.

  2. A worker's completion remains valid until:

  3. expires_at passes (if refresh_days is set), OR
  4. The template is updated with invalidate_on_new_version = true (soft-deletes all completions for that template), OR
  5. A site manager explicitly invalidates a specific completion.

  6. If no active template exists for a site, has_completed_induction() returns True. Sites are not forced to have an induction.

  7. The declarations array is ordered. declarations_accepted is a parallel boolean array. If any element is false, the completion is rejected — all declarations must be ticked.

  8. If signature_required = true, a signature_key is mandatory on the completion record. The signature is drawn on a canvas element on the worker's device or the kiosk tablet.

  9. completed_by_method = witnessed means a site manager watched the worker acknowledge in person and submitted the completion on their behalf. witnessed_by must be set in this case.

  10. completed_by_method = self means the worker completed it independently (kiosk screen or on their own device via QR link).

  11. A new version can be created as a draft (a separate future enhancement). In V1, publishing is immediate — create the new template, old one deactivated.

  12. Workers completing on their own device use the same QR code scan URL as sign-in. The sign-in flow detects an incomplete induction and redirects to the induction completion form before allowing sign-in.


Induction Completion Flow

Worker scans site QR code (or site manager taps "Add induction")
  1. Resolve site from QR token
  2. Worker selects their name from the personnel register
  3. System checks existing valid completion:
       Valid completion found → proceed to sign-in flow
       No valid completion found → present induction
  4. Display induction content (title + briefing text)
  5. Worker reads content (no enforced timer in V1)
  6. Each declaration rendered as a required checkbox
  7. Signature pad shown if signature_required = true
  8. Worker submits
  9. System validates: all declarations ticked, signature present if required
  10. Write InductionCompletion record
      completed_by_method = 'self'
      completed_at = now()
      expires_at = now() + refresh_days IF refresh_days NOT NULL
  11. Redirect to sign-in flow → worker is now admitted

Site manager witnessed flow (in person):

Site Manager taps "Mark induction complete" for a worker
  1. Select worker from personnel list
  2. System shows declarations for confirmation
  3. Site manager submits (no signature capture in witnessed mode in V1)
  4. Write completion: completed_by_method = 'witnessed', witnessed_by = current_user

API Endpoints

Template management

Method Path Description
GET /sites/{siteId}/induction Get active template (or 404 if none)
POST /sites/{siteId}/induction Create new template (deactivates current)
PATCH /sites/{siteId}/induction Update active template content
GET /sites/{siteId}/induction/history All versions for this site

Completions

Method Path Description
GET /sites/{siteId}/induction/completions List completions for site (paginated)
POST /sites/{siteId}/induction/complete Submit a completion
POST /sites/{siteId}/induction/complete/witnessed Mark as complete on worker's behalf
DELETE /sites/{siteId}/induction/completions/{id} Invalidate a completion
POST /sites/{siteId}/induction/invalidate-all Invalidate all completions (on new version)
POST /sites/{siteId}/induction/complete/signature Upload signature (returns presigned PUT URL)

Project-level view

Method Path Description
GET /projects/{projectId}/inductions Completion status across all project sites
GET /projects/{projectId}/inductions/pending Workers with no valid completion

Request / Response Shapes

GET /sites/{siteId}/induction

// Response 200
{
  "id": "uuid-template",
  "site_id": "uuid-site",
  "title": "Canary Wharf Phase 2 — Site Safety Induction",
  "content": "## Emergency Procedures\n\nIn the event of a fire...\n\n## PPE Requirements\n\n...",
  "declarations": [
    "I have read and understood the site induction briefing",
    "I am aware of the emergency assembly point and evacuation procedure",
    "I confirm I will wear the required PPE at all times on site"
  ],
  "signature_required": true,
  "refresh_days": 180,
  "version": 2,
  "is_active": true,
  "invalidate_on_new_version": false,
  "completion_count": 34,
  "created_at": "2026-05-28T09:00:00Z",
  "updated_at": "2026-05-28T09:00:00Z"
}

// Response 404 — no active template
{ "error": "not_found", "message": "No active induction template for this site." }

POST /sites/{siteId}/induction/complete

// Request
{
  "personnel_id": "uuid-worker",
  "declarations_accepted": [true, true, true],
  "signature_key": "tenants/uuid-tenant/signatures/uuid-sig.png"
}

// Response 201
{
  "id": "uuid-completion",
  "personnel_id": "uuid-worker",
  "full_name": "James Brennan",
  "site_id": "uuid-site",
  "template_version": 2,
  "completed_at": "2026-05-28T07:30:00Z",
  "completed_by_method": "self",
  "expires_at": "2026-11-28T07:30:00Z",
  "signature_url": "https://...",
  "valid": true
}

// Response 422 — not all declarations accepted
{
  "error": "declarations_incomplete",
  "message": "All declarations must be accepted before completing the induction.",
  "rejected_indices": [2]
}

// Response 422 — signature missing
{
  "error": "signature_required",
  "message": "A signature is required to complete this induction."
}

GET /projects/{projectId}/inductions

// Response 200
{
  "data": [
    {
      "site_id": "uuid-site",
      "site_name": "Canary Wharf — Site A",
      "has_template": true,
      "template_version": 2,
      "total_workers": 47,
      "completed": 41,
      "pending": 6,
      "expired": 0
    }
  ],
  "pagination": { "total": 2, "cursor": null, "has_more": false }
}

GET /projects/{projectId}/inductions/pending

// Response 200
{
  "data": [
    {
      "personnel_id": "uuid-worker",
      "full_name": "Marcus Obi",
      "employment_type": "direct",
      "site_id": "uuid-site",
      "site_name": "Canary Wharf — Site A",
      "last_signed_in": null,
      "completion_status": "never_completed"
    },
    {
      "personnel_id": "uuid-worker-2",
      "full_name": "Dan Pearce",
      "employment_type": "subcontractor",
      "site_id": "uuid-site",
      "site_name": "Canary Wharf — Site A",
      "last_signed_in": "2026-03-01T08:00:00Z",
      "completion_status": "expired"
    }
  ],
  "pagination": { "total": 6, "cursor": null, "has_more": false }
}

completion_status values: never_completed | expired | invalidated


Permission Matrix

Action Platform Admin Tenant Admin Project Manager Site Manager Site Operative Viewer
View template ✓ assigned ✓ own site ✓ own site
Create/edit template ✓ assigned ✓ own site
View completions ✓ assigned ✓ own site
Complete own induction
Mark as witnessed ✓ assigned ✓ own site
Invalidate completion ✓ assigned ✓ own site
Invalidate all (new version) ✓ assigned ✓ own site
View pending list ✓ assigned ✓ own site

Notifications Triggered

Event Trigger point Notification type
Worker blocked at sign-in (induction not done) Personnel sign-in flow induction.blocked_signin

The notification is fired by the Personnel module's sign-in service when has_completed_induction() returns False. The Inductions module does not fire notifications directly.

No expiry scanner for inductions in V1. Expired completions are detected lazily at sign-in time (the worker gets blocked and is prompted to redo the induction).


Offline Sync Scope

Data Syncs to Notes
Active InductionTemplate Site Manager Full content including declarations
InductionCompletions Site Manager Last 90 days for assigned sites
Pending workers (derived) Not synced Recomputed from local data

Offline induction completion: - Worker completes induction on site manager's device (kiosk mode) - Completion written to local SQLite queue - Signature stored locally until upload - On reconnect: completion record synced to server, signature uploaded to S3 - Worker's sign-in proceeds offline immediately after local completion

If a worker completes offline and an invalidation was published while offline, the server returns a 409 on sync. The site manager sees a conflict flag and can re-do the induction manually.


Cross-Module Dependencies

Upstream

Module Dependency
Projects & Sites project_id, site_id
Personnel & Attendance personnel_id — workers must be in the register

Downstream

Module Usage
Personnel & Attendance Calls has_completed_induction() at every sign-in

Tests Required

Template management

  • Create template — valid payload → 201, previous template deactivated
  • Create template — first template for site → 201, is_active = true
  • Update template — PATCH content, version unchanged
  • Get template — 200 with completion count
  • Get template — no active template → 404
  • History endpoint — returns all versions including deactivated

Completions

  • Complete induction — all declarations accepted, signature provided → 201
  • Complete induction — not all declarations accepted → 422
  • Complete induction — signature missing when required → 422
  • Complete induction — worker already has valid completion → 409
  • Witnessed completion — witnessed_by set, no signature required
  • Invalidate completion — soft-deletes, has_completed_induction() now returns False
  • Invalidate all — all completions for that template soft-deleted
  • expires_at computed correctly from refresh_days
  • Expired completion — has_completed_induction() returns False

Sign-in check interface

  • Worker with valid completion → has_completed_induction() returns True
  • Worker with no completion → has_completed_induction() returns False
  • Worker with expired completion → has_completed_induction() returns False
  • Site with no active template → has_completed_induction() returns True
  • Worker with invalidated completion → has_completed_induction() returns False

Project-level view

  • /pending returns workers with no completion or expired completion
  • Summary counts accurate across multiple sites

Cross-tenant isolation (mandatory)

  • GET template for site in another tenant → 404
  • POST completion with personnel_id from another tenant → 404
  • POST completion for template in another tenant → 404

Permission boundaries

  • Site Operative can view template, cannot manage it → 403 on PATCH
  • Viewer cannot view completions list → 403 on completions endpoint
  • Site Manager cannot manage template for unassigned site → 403

Default Tenant Labels

Key Default
module.inductions Inductions
field.induction.title Induction Title
field.induction.content Briefing Content
field.induction.declarations Declarations
field.induction.signature_required Require Signature
field.induction.refresh_days Refresh Period (days)
status.induction.completed Completed
status.induction.expired Expired
status.induction.pending Pending

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 and role checks
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 Signature upload
audit_entity_change trigger DB foundation Applied to both tables
conftest.py fixtures tests/conftest.py client, auth_headers, test_tenants
factories.py tests/factories.py create_induction_template, create_induction_completion