Notification System Tables¶
Notification Senders (notification_senders)¶
Purpose: Stores email addresses and phone numbers used to send notifications
Use Case Example: Configure notifications@farmcove.com as the default email sender and "+1234567890" as the WhatsApp sender for system notifications.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| sender_type | TEXT | Type of sender: 'email' or 'phone' |
| sender_value | TEXT | The actual email address or phone number |
| sender_name | TEXT | Display name shown to recipients (e.g., "FarmCove Support") |
| provider | TEXT | Service provider (e.g. resend, twilio, ses, sendgrid) |
| is_active | BOOLEAN | Whether this sender is currently active |
| is_default | BOOLEAN | Whether this is the default sender for its type |
| category_id | UUID | Optional FK to categories - links sender to specific project types (NULL = all categories) |
| metadata | JSONB | Provider-specific configuration |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- Unique constraint on (sender_type, sender_value)
- Only one default sender per type
- Supports multiple providers for redundancy
- Optional category association allows senders to be project-type specific (e.g., WhatsApp for media projects)
Default Senders:
| sender_value | sender_name | Purpose |
|---|---|---|
noreply@farmcove.co.uk |
FarmCove | Default email sender (exports) |
approval@farmcove.co.uk |
Farm Cove Approvals | Approval workflow emails |
Notification Template Categories (notification_template_categories)¶
Purpose: Categories for grouping notification templates in the UI
Use Case Example: Group all budget-related notifications under "Budget & Finance" category for better organisation in user preferences.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| code | TEXT | Unique code for the category (e.g., system, project, team) |
| title | TEXT | Display title for the category |
| description | TEXT | Description of the category to show in UI |
| display_order | INTEGER | Order for displaying categories in UI |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- Unique constraint on code
- Used to organize notification preferences UI
- Seeded categories, in display order:
approval(50),forms(60),project_documents(70),processing(80),exports(100),sana(999) - RLS Policies: Authenticated read-only. Notification categories managed by service_role
Notification Templates (notification_templates)¶
Purpose: Templates for different types of notifications with multi-channel support
Use Case Example: Create a "budget-threshold-reached" template that sends email alerts, WhatsApp messages, and in-app notifications when budget exceeds 80%.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| template_key | TEXT | Unique key used in code (e.g., 'invoice-created') |
| name | TEXT | Human-readable name for admin UI |
| notification_template_category_id | UUID | Reference to notification_template_categories |
| description | TEXT | Description for admin UI |
| is_active | BOOLEAN | Whether this template is currently active and can be used |
| email_available | BOOLEAN | Whether this notification can be sent via email |
| whatsapp_available | BOOLEAN | Whether this notification can be sent via WhatsApp |
| in_app_available | BOOLEAN | Whether this notification can be shown in-app |
| subject_template | TEXT | Email subject line with ${variables}. |
| body_template | TEXT | HTML for email, plain text for in-app |
| body_template_text | TEXT | Plain text version for email clients |
| whatsapp_config | JSONB | WhatsApp configuration including template: {name, language}, flow: { id, name, first_screen} and components |
| metadata | JSONB | Additional metadata including media information (media_type, media_url) and other template-specific data |
| default_email_sender_id | UUID | Reference to default email sender |
| default_phone_sender_id | UUID | Reference to default phone sender |
| show_in_preferences | BOOLEAN | Whether this notification template should be shown in user preferences UI |
| mandatory_channels | TEXT[] | Array of channels that are mandatory for this notification template (cannot be disabled by users). Valid values: in_app, email, whatsapp |
| default_email_enabled | BOOLEAN | Whether email is enabled by default for this template (user can override in preferences). Default: false |
| default_whatsapp_enabled | BOOLEAN | Whether WhatsApp is enabled by default for this template (user can override in preferences). Default: false |
| default_in_app_enabled | BOOLEAN | Whether in-app is enabled by default for this template (user can override in preferences). Default: true |
| required_permissions | TEXT[] | Array of permission keys required to see this template in preferences. NULL or empty = visible to all users |
| email_template | TEXT | HTML template specifically for email channel. If NULL, body_template is used for email. Supports rich HTML with styled action buttons |
| digest_category_parent_code | TEXT | Parent category code for digest grouping (e.g., 'transactions'). Links templates to category-based digest preferences |
| digest_category_group_code | TEXT | Category group code for digest grouping (e.g., 'approval_types'). Used with parent code to identify category hierarchy for digests |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- Templates control which channels are available via
*_availableflags - Templates define default enabled state via
default_*_enabledflags - Supports variable substitution with ${variables}
- WhatsApp configuration consolidated in single JSONB field
- Mandatory notifications cannot be disabled by users
- Category relationship for better organisation
- Mandatory channels array for granular control per template
- Permission-based visibility: templates can require specific permissions to be shown in user preferences
processing_stalled:
Seeded template for the stalled-AI-processing alert. Raised when the stall-recovery sweeper exhausts its retry budget for a job (see AI_PROCESSING.md → Stall recovery), telling the team that automatic document processing could not complete and can be retried from the transaction screen.
- In-app plus opt-in WhatsApp (
in_app_availableandwhatsapp_availabletrue,email_availablefalse) — the alert is actionable in-app, not correspondence, so email stays off; WhatsApp is available but off by default (default_whatsapp_enabledfalse) for users who want it pushed in_appis a mandatory channel, so users cannot silence the one signal that a job needs a manual retry;show_in_preferencesis true so it is still visible in the preferences UI- Carries the
${transaction_reference}variable, in the standard${...}substitution syntax - Grouped under the
processingcategory (notification_template_category_id), alongsidetransaction_processing_not_started - Seeded idempotently via
ON CONFLICT (template_key) DO NOTHING; shape pinned by assertions inpackages/database/supabase/tests/01_tables_and_pks.sql
transaction_processing_not_started:
Seeded template for the dispatch-loss escalation. Raised by the unprocessed-transaction sweeper when a received transaction's initial processing job was never created and the sweep's own re-dispatch attempts keep failing past a 2-hour threshold (see PROCESSING_ARCHITECTURE.md → The unprocessed-transaction sweeper). Recipients are every ACTIVE holder of the project:owner role on the transaction's project.
- In-app ONLY (
in_app_availabletrue; bothemail_availableandwhatsapp_availablefalse) — the condition is an operational anomaly for project owners to chase in the app, so neither channel can be enabled from the preference screen in_appis a mandatory channel;show_in_preferencesis true so the template is still visible in the preferences UI- Carries the
${transaction_reference}variable, in the standard${...}substitution syntax - Grouped under the
processingcategory (notification_template_category_id), likeprocessing_stalled - Deliberately a notification and NOT a processing issue: pre-pipeline transactions are hidden from every list surface, so an issue row on one would reach nobody
- Seeded idempotently via
ON CONFLICT (template_key) DO NOTHING; shape pinned by assertions inpackages/database/supabase/tests/01_tables_and_pks.sql
Channel Priority:
When determining which channels to use for a notification:
- Check if channel is available on template (
*_available= true) - Check if channel is mandatory (
mandatory_channelsarray) - user cannot disable - Check if user has template-specific override in preferences
- Otherwise use template default (
default_*_enabled)
Category-Aware Templates:
Several templates support dynamic content based on project category flags (is_media, is_accounting, is_personal_accounting):
known_person- Uses${project_type_description}variable:- Media: "You are talking to {person_name}, who {person_relationship} for the project {project_title}, as {role_name}..."
- Personal Accounting: "You are talking to {person_name}, who {person_relationship} for the {project_title} personal accounting project..."
-
Company Accounting: "You are talking to {person_name}, who {person_relationship} for the {project_title} company accounting project..."
-
switch_project(renamed fromswitch_film) - Generic project switching instructions - Template key:
switch_project -
Body: "Please choose a project or onboard to a new one using the button."
-
switch_success- Uses${project_type_description}variable: - Media: "You are now working on the project {project_title}, as {role_name}, please just let me know if you need to switch context."
- Personal Accounting: "You are now working on the {project_title} personal accounting project, please just let me know if you need to switch context."
- Company Accounting: "You are now working on the {project_title} company accounting project, please just let me know if you need to switch context."
Approval Templates (Category: approval):
Templates for approval workflow notifications:
| Template Key | Name | Description | Variables |
|---|---|---|---|
approval_query |
Approval Query | Notification sent to submitter when an approver queries a submission | ${name}, ${reference} |
approval_rejection |
Approval Rejection | Notification sent to submitter when their submission is rejected | ${name}, ${reference}, ${reason} |
approval_reply_received |
Approval Reply Received | Notification sent to approver when submitter replies to their query | ${name}, ${submitter_name}, ${reference} |
transaction_approval |
Transaction Approval Request | Notification sent to approvers when a transaction is submitted | Email/In-App: ${transaction_type}, ${transaction_identifier}, ${reference}, ${submitter_name}, ${description}, ${total}. WhatsApp body: ${attachmentId}, ${typeAndProject}, ${typeAndReference}, ${reference}, ${submitter_name}, ${description}. WhatsApp flowData: ${heading}, ${subheading}, ${flowDescription}, ${minDate}, ${scheduledPaymentDate}, ${scheduledPaymentAmount}, ${queryHistory}, ${options} |
All approval templates have:
in_app_available: truewhatsapp_available: trueemail_available: truemandatory_channels: ['in_app']
Notifications (notifications)¶
Purpose: Central log of all notifications sent to users
Use Case Example: When budget reaches 80%, system creates notification record with rendered content and tracks which channels were used to deliver it.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| project_id | UUID | Project this notification relates to (optional) |
| recipient_user_id | UUID | User who received the notification |
| created_by_user_id | UUID | User who created/triggered this notification (NULL for system-generated notifications) |
| template_id | UUID | Template used (optional for custom notifications) |
| subject | TEXT | Rendered email subject |
| content_html | TEXT | HTML version for email |
| content_text | TEXT | Plain text for in-app/WhatsApp |
| data | JSONB | Original template variables for re-rendering/audit |
| whatsapp_data | JSONB | Rendered WhatsApp component data |
| channels_sent | JSONB | Which channels were used {"email": true, "whatsapp": false} |
| allow_mark_as_unread | BOOLEAN | Whether this notification can be marked as unread by the recipient (disabled by default) |
| created_at | TIMESTAMPTZ | When notification was created |
Key Features:
- Immutable audit log of all notifications
- Stores rendered content for display
- Tracks which channels were actually used
- Enabled for Realtime to power in-app notifications
Notification Deliveries (notification_deliveries)¶
Purpose: Tracks delivery status for each channel
Use Case Example: Email sent via Resend at 2 PM, delivered at 2:01 PM, read at 3 PM. WhatsApp message failed due to invalid number.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| notification_id | UUID | Reference to parent notification |
| channel | TEXT | Delivery channel: email/whatsapp/in_app |
| recipient_address | TEXT | Email, phone number, or user_id |
| sender_address | TEXT | Email or phone used to send |
| sender_id | UUID | Reference to notification sender |
| provider | TEXT | Service provider used (resend, twilio, supabase-realtime) |
| status | TEXT | pending/sent/delivered/failed/read |
| error_message | TEXT | Error details if failed |
| sent_at | TIMESTAMPTZ | When notification was sent |
| delivered_at | TIMESTAMPTZ | When reached recipient device/inbox |
| read_at | TIMESTAMPTZ | When recipient read/opened |
| retry_count | INTEGER | Number of retry attempts |
| created_at | TIMESTAMPTZ | Creation timestamp |
| notification_action_token_id | UUID | FK to notification_action_tokens, nullable - for EMAIL and WHATSAPP deliveries with action links |
Key Features:
- One record per channel per notification
- Tracks complete delivery lifecycle
- Supports retry logic with attempt counting
- WhatsApp-specific tracking moved to message.whatsapp_metadata
- Links to email action tokens for traceability of email action links
- Digest notifications use live-query pattern: cron jobs query pending items directly at digest time rather than storing stale delivery records
Notification Attachments (notification_attachments)¶
Purpose: Links notifications to file attachments
Use Case Example: Invoice notification includes PDF attachment sent via email, but not via WhatsApp.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| notification_id | UUID | Reference to notification |
| attachment_id | UUID | Reference to attachment |
| channel | TEXT | Which channel this attachment is for (email/whatsapp) |
| created_at | TIMESTAMPTZ | Creation timestamp |
Key Features:
- Supports channel-specific attachments
- Links to existing attachment system
- Allows different attachments per channel
Notification Action Tokens (notification_action_tokens)¶
Purpose: Generic secure token system for notification action links (approvals, query responses, exports, etc.). Supports both EMAIL and WHATSAPP channels. Uses metadata.record_ids array for flexible single/bulk record references.
Use Case Example: When sending an approval notification via email or WhatsApp, a token is generated that allows the approver to take action (approve/reject/query) directly from the notification without logging in. Digest notifications can include bulk tokens referencing multiple pending items.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| token_hash | TEXT | Lowercase-hex SHA-256 of the plaintext token — sole at-rest representation and lookup key (NOT NULL) |
| action_type | TEXT | Type of action: 'approval_review', 'approval_review_bulk', 'query_response', 'export_download', etc. |
| user_id | UUID | User the token was issued to (optional, for validation) |
| metadata | JSONB | Action context including record_ids array: [{approval_instance_id, approval_request_id}, ...] |
| expires_at | TIMESTAMPTZ | When the token expires (typically 7 days from creation) |
| used_at | TIMESTAMPTZ | When the token was consumed (NULL if unused) |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
| created_by_user_id | UUID | User who triggered the token creation (system notifications = NULL) |
Constraints:
- UNIQUE index on
token_hash(the lookup key; NOT NULL, so a plain — not partial — unique index) - CHECK
token_hash ~ '^[0-9a-f]{64}$'(hash shape) - FK on
user_idreferencesusers(id)ON DELETE CASCADE - FK on
created_by_user_idreferencesusers(id)ON DELETE SET NULL (the column existed before the forms migration but had no FK or index)
Indexes:
idx_notification_action_tokens_token_hashUNIQUE ontoken_hash- Hash lookup (the only lookup path)idx_notification_action_tokens_metadata_record_idsGIN index on(metadata->'record_ids')- Find tokens by record referencesidx_notification_action_tokens_expiresonexpires_atWHEREused_at IS NULL- Cleanup of unused expired tokensidx_notification_action_tokens_created_by_user_idoncreated_by_user_id
Token hash-at-rest (final state):
The token is a bearer credential, so the usable secret is never stored at rest. token_hash (SHA-256 of the plaintext, encode(digest(convert_to(<plaintext>,'UTF8'),'sha256'),'hex')) is the sole at-rest representation and the lookup key. The server layer mints a CSPRNG token, writes only token_hash, and returns the plaintext in-memory to the caller for building the action URL — it is discarded after and never persisted. Validation hashes the incoming token string and looks up by token_hash; there is no plaintext column and no plaintext fallback path.
This shipped as a single-release cutover (the originally-planned expand/contract two-phase rollout was collapsed): the migration backfilled token_hash for every existing row (keeping still-live approval/query links resolvable by hash), then invalidated every outstanding unused token of every action type by EXPIRY (UPDATE … SET expires_at = now() WHERE used_at IS NULL AND expires_at > now()), dropped the plaintext token column, and made token_hash NOT NULL + uniquely indexed. Invalidation is by expiry rather than consumption (used_at) so a never-filled pre-cutover form-fill link renders the accurate "Link Expired" state — validation maps a used_at token to the terminal "Already Submitted" replay UX, which would be wrong for a link that was never actually submitted. There is no dual-write phase and no compatibility trigger.
Operational consequence: every notification-action link (approval review, query response, form-fill, export download, …) sent before the deploy stops working and must be re-sent / regenerated from the app. New links minted post-deploy are CSPRNG + hash-at-rest from birth. Deploy-window behaviour (accepted): between the CI migration apply and traffic switching to new instances, old-code token mints/validations reference the dropped token column and error — acceptable because all outstanding links were invalidated above and are re-sent afterward; the window is minutes (instance drain).
Security:
- Tokens are one-time use (marked as used after successful action)
- Default expiration: 7 days (168 hours)
- No authentication required - token acts as the auth credential
- Service-role only. The
authenticatedrole holds NO grant on this table (all four privileges revoked). RLS stays ENABLED with four explicit deny-all policies ("Service role only - no direct {select,insert,update,delete} access",USING/WITH CHECK false) — these document default-deny and silence the Supabase "RLS enabled, no policies" advisor; they grant nothing (service_role bypasses RLS). Every token operation (mint, validate, consume) runs service-role in the server layer. This closes the previous forgeable-mint / IDOR surface whereauthenticatedcould INSERT/UPDATE/SELECT token rows. See PERMISSIONS.md.
Action Types:
| Action Type | Metadata.record_ids Structure | Purpose |
|---|---|---|
| approval_review | [{approval_instance_id, approval_request_id}] |
Approver reviews a single submission |
| approval_review_bulk | [{approval_instance_id, approval_request_id}, ...] |
Approver reviews multiple items from digest |
| query_response | [{approval_request_id}] |
Submitter responds to a query |