Skip to content

Notifications

Status: Specified  ·  Scope: All modules

Covers the notification architecture for all event-driven alerts across the platform — expiry warnings, compliance deadlines, and operational triggers.


Channels

V1 supports two channels. Both are configured per notification type, per tenant.

Channel Service Use
Email AWS SES Default channel for all notification types
SMS AWS SNS Optional, per tenant, for any notification type

In-app notifications and push are deferred to V2.


Notification Types

Expiry alerts

Sent in advance of an upcoming expiry. Thresholds are configurable per tenant (see Tenant Configuration below). Platform default is 30 days and 7 days.

Event type key Trigger Module
licence.expiry Worker licence approaching or past expiry Personnel
plant.inspection_expiry Plant inspection certificate approaching or past expiry Plant & Equipment
plant.hire_expiry Hired plant return date approaching Plant & Equipment
document.expiry Document with expiry date approaching or past Documents
subcontractor.insurance_expiry Subcontractor insurance approaching or past expiry Subcontractors
subcontractor.accreditation_expiry Subcontractor accreditation approaching or past expiry Subcontractors

Compliance deadlines

Time-sensitive events with legal consequences.

Event type key Trigger Module
incident.riddor_deadline RIDDOR-reportable incident approaching 10-day HSE reporting deadline Incidents

Operational triggers

Fired immediately when a specific action occurs.

Event type key Trigger Module
personnel.licence_expired_on_site Worker with an expired licence is currently signed in to a site Personnel
plant.inspection_overdue_in_use Plant with overdue inspection certificate is allocated to an active site Plant & Equipment
induction.blocked_signin Worker attempted to sign in without completing site induction Inductions

Data Model

All tables live in the tenant schema except where noted.

notification_config

Tenant-level settings per event type. One row per (tenant_id, event_type). A missing row means the platform default applies.

CREATE TABLE notification_config (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES public.tenants(id),
    event_type      VARCHAR(60) NOT NULL,
    enabled         BOOLEAN NOT NULL DEFAULT true,
    channels        TEXT[] NOT NULL DEFAULT '{email}',
    -- For expiry events: days before expiry to send. e.g. '{30,7}'
    advance_days    INTEGER[] NULL,
    -- Which roles receive this notification type
    -- Values: 'tenant_admin', 'project_manager', 'site_manager', or a specific user UUID
    recipients      TEXT[] NOT NULL DEFAULT '{tenant_admin}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (tenant_id, event_type)
);

Platform defaults (applied when no config row exists):

event_type channels advance_days recipients
licence.expiry email 30, 7 project_manager
plant.inspection_expiry email 30, 7 site_manager
plant.hire_expiry email 7, 1 site_manager
document.expiry email 30, 7 tenant_admin
subcontractor.*_expiry email 30, 7 tenant_admin
incident.riddor_deadline email 5, 2 tenant_admin, project_manager
personnel.licence_expired_on_site email project_manager, site_manager
plant.inspection_overdue_in_use email project_manager, site_manager
induction.blocked_signin email site_manager

notification_preferences

Per-user opt-out overrides. Only rows for users who have deviated from the tenant config.

CREATE TABLE notification_preferences (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL,
    user_id     UUID NOT NULL,
    event_type  VARCHAR(60) NOT NULL,
    opted_out   BOOLEAN NOT NULL DEFAULT false,
    sms_number  VARCHAR(20) NULL,
    -- Overrides tenant config channels for this user only. NULL = use tenant config.
    channels    TEXT[] NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (tenant_id, user_id, event_type)
);

notification_log

Immutable record of every notification sent or attempted. Used for audit trail, duplicate suppression, and delivery status tracking.

CREATE TABLE notification_log (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    event_type      VARCHAR(60) NOT NULL,
    entity_type     VARCHAR(50) NOT NULL,  -- e.g. 'personnel', 'plant_equipment'
    entity_id       UUID NOT NULL,
    channel         VARCHAR(10) NOT NULL,  -- 'email' or 'sms'
    recipient       VARCHAR(255) NOT NULL, -- email address or phone number
    status          VARCHAR(20) NOT NULL DEFAULT 'queued',
    -- queued | sent | failed | bounced
    idempotency_key VARCHAR(100) NOT NULL UNIQUE,
    -- Prevents duplicate sends: '{event_type}:{entity_id}:{advance_days or date}'
    sent_at         TIMESTAMPTZ NULL,
    error_message   TEXT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_notification_log_entity
    ON notification_log(entity_type, entity_id);
CREATE INDEX idx_notification_log_idempotency
    ON notification_log(idempotency_key);

Architecture

Expiry alert flow (scheduled)

EventBridge rule (daily, 06:00 UTC)
  → Lambda: notification_scanner
      For each active tenant schema:
        Query entities with expiry dates within configured advance_days windows
        For each match:
          Build idempotency_key
          Check notification_log — skip if already sent today for this key
          Resolve recipients and channels from notification_config
          Write 'queued' row to notification_log
          Push message to SQS: notifications-queue
  → Lambda: notification_sender (SQS trigger)
      Dequeue message
      Send via SES (email) or SNS (SMS)
      Update notification_log status to 'sent' or 'failed'

Operational trigger flow (immediate)

FastAPI route handler (e.g. POST /attendance, POST /incidents)
  → Detect triggerable condition (licence expired, RIDDOR threshold, etc.)
  → Build idempotency_key
  → Push message to SQS: notifications-queue (same queue, higher priority)
  → notification_sender Lambda processes as above

Lambda: notification_scanner

  • Runtime: Python 3.12
  • Schedule: EventBridge cron 0 6 * * ? *
  • VPC-attached — needs RDS access
  • IAM: sqs:SendMessage on notifications-queue, rds-data:ExecuteStatement
  • Scans all schemas in public.tenants WHERE status = 'active'
  • Uses SET LOCAL search_path per tenant (same pattern as API)

Lambda: notification_sender

  • Runtime: Python 3.12
  • Trigger: SQS notifications-queue
  • IAM: ses:SendEmail, sns:Publish, sqs:DeleteMessage
  • Does not have RDS access — all data passed in the SQS message payload
  • Batch size: 10 messages

SQS queue

Queue name:  construo-notifications-{env}
Type:        Standard (order not guaranteed — acceptable for notifications)
DLQ:         construo-notifications-dlq-{env}  (after 3 delivery attempts)
Visibility:  60 seconds
Retention:   4 days

SQS message schema

{
  "tenant_id": "uuid",
  "notification_log_id": "uuid",
  "event_type": "personnel.licence_expired_on_site",
  "channel": "sms",
  "recipient": "+447700900123",
  "subject": "Expired licence — worker on site",
  "body": "John Smith (CSCS card) — licence expired 3 days ago. Currently signed in to Canary Wharf Site A.",
  "idempotency_key": "personnel.licence_expired_on_site:uuid:2026-05-28"
}

Tenant Configuration

Tenant admins configure notifications via the Notification Settings page (V1 scope).

Per notification type, they can set:

  • Enabled / disabled
  • Channels: email only, SMS only, or email + SMS
  • Recipients: roles and/or specific named users
  • Advance days (expiry types only): comma-separated list, e.g. 30, 14, 7, 1

User-level opt-outs are managed by each user in their account preferences.


Email Templates

Templates are stored in apps/api/src/core/notifications/templates/ as Jinja2 HTML files. One template per event type. All templates:

  • Use tenant branding (logo, primary colour from tenant_labels)
  • Include the tenant name in the sender display name: "Construo via Acme Construction <noreply@construo.build>"
  • Link directly to the relevant entity in the platform
  • Are plain-text fallback safe

SES sending domain: construo.build. DKIM and SPF configured at domain level.


V2 Considerations

  • In-app notification bell (WebSocket or polling)
  • Push notifications for the mobile app (APNs / FCM)
  • Notification digest preference (immediate vs daily digest per type)
  • Webhook delivery for tenants who want to pipe alerts into their own systems