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 |
|---|---|---|
| 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 |
30, 7 | project_manager | |
plant.inspection_expiry |
30, 7 | site_manager | |
plant.hire_expiry |
7, 1 | site_manager | |
document.expiry |
30, 7 | tenant_admin | |
subcontractor.*_expiry |
30, 7 | tenant_admin | |
incident.riddor_deadline |
5, 2 | tenant_admin, project_manager | |
personnel.licence_expired_on_site |
— | project_manager, site_manager | |
plant.inspection_overdue_in_use |
— | project_manager, site_manager | |
induction.blocked_signin |
— | 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:SendMessageon notifications-queue,rds-data:ExecuteStatement - Scans all schemas in
public.tenants WHERE status = 'active' - Uses
SET LOCAL search_pathper 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