Database tables for multi-channel notification system.
Note: The
notificationsandnotification_settingsbase tables are defined in ../app-shell/notifications.md. This file covers multi-channel extensions.
Schema Overview
┌──────────────────────┐
│ user │
└──────────┬───────────┘
│
┌─────┴─────┬────────────────┬──────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌───────────────┐ ┌────────────────┐ ┌────────────────┐
│user_ │ │user_ │ │notification_ │ │telegram_ │
│telegram_ │ │discord_ │ │settings │ │verification_ │
│accounts │ │accounts │ │(extended) │ │tokens │
└──────────┘ └───────────────┘ └────────────────┘ └────────────────┘
│
│
┌──────────────────┴──────────────────┐
│ │
▼ ▼
┌──────────────┐ ┌────────────────────┐
│notifications │ │notification_ │
│ (existing) │───────────────────▶│deliveries │
└──────────────┘ └────────────────────┘
push_subscriptions hangs off user the same way as user_telegram_accounts/user_discord_accounts (omitted above for width) — except it's N rows per user, not one.
New Tables
user_telegram_accounts
Stores Telegram chat_id for sending messages.
| Column | Type | Constraints | Purpose |
|---|---|---|---|
id |
text | PK | tga_xxxxx |
user_id |
text | FK → user, UNIQUE | One Telegram per user |
telegram_chat_id |
text | NOT NULL, UNIQUE | Bot sends to this chat |
telegram_username |
text | Display in UI | |
is_active |
boolean | NOT NULL, DEFAULT true | False if bot blocked |
linked_at |
timestamptz | NOT NULL, DEFAULT NOW() | Audit |
unlinked_at |
timestamptz | Soft delete timestamp |
Indexes:
user_id(unique) - lookup by usertelegram_chat_id(unique) - prevent duplicate linking
Design decisions:
- One-to-one with user (UNIQUE constraint)
- Soft delete via
is_active+unlinked_atfor audit trail - No tokens stored - Telegram uses chat_id only
user_discord_accounts
Stores Discord credentials with OAuth tokens.
| Column | Type | Constraints | Purpose |
|---|---|---|---|
id |
text | PK | dca_xxxxx |
user_id |
text | FK → user, UNIQUE | One Discord per user |
discord_user_id |
text | NOT NULL, UNIQUE | For DM channel creation |
discord_username |
text | Display in UI | |
access_token |
text | NOT NULL | Encrypted (AES-256-GCM) |
refresh_token |
text | NOT NULL | Encrypted (AES-256-GCM) |
token_expires_at |
timestamptz | NOT NULL | 7 days from issue |
is_active |
boolean | NOT NULL, DEFAULT true | False if refresh fails |
token_refresh_failed_at |
timestamptz | Skip refresh if set | |
linked_at |
timestamptz | NOT NULL, DEFAULT NOW() | Audit |
tokens_refreshed_at |
timestamptz | Last successful refresh | |
unlinked_at |
timestamptz | Soft delete timestamp |
Indexes:
user_id(unique) - lookup by userdiscord_user_id(unique) - prevent duplicate linking- Partial index on
token_expires_atWHEREis_active = true AND token_refresh_failed_at IS NULL- for refresh job
Token encryption:
- AES-256-GCM (Web Crypto) with unique 96-bit nonce per encryption
- Key is a 64-char hex
ENCRYPTION_KEYenv var (raw 32-byte key, no KMS/KEK) - Storage format:
nonce:ciphertext(Base64) — the GCM auth tag is appended to the ciphertext by Web Crypto, so there is no separate:tagsegment
telegram_verification_tokens
Temporary tokens for deep link verification.
| Column | Type | Constraints | Purpose |
|---|---|---|---|
id |
text | PK | tvt_xxxxx |
user_id |
text | FK → user | Who is linking |
token |
text | NOT NULL, UNIQUE | Random 32-byte Base64URL |
expires_at |
timestamptz | NOT NULL | 15 minutes from creation |
used_at |
timestamptz | NULL until used | |
created_at |
timestamptz | NOT NULL, DEFAULT NOW() | Audit |
Indexes:
token(unique) - lookup for validation- Partial index on
expires_atWHEREused_at IS NULL- for cleanup job
Cleanup: Delete records where expires_at < NOW() - INTERVAL '1 day'
push_subscriptions
Per-device Web Push subscriptions. Unlike Telegram/Discord, this is N rows per user — one per browser/device — not a one-to-one link.
| Column | Type | Constraints | Purpose |
|---|---|---|---|
id |
text | PK | pns_xxxxx |
user_id |
text | FK → user, CASCADE | Many devices per user (NOT unique) |
endpoint |
text | NOT NULL, UNIQUE | Push service URL — the subscription's identity |
p256dh |
text | NOT NULL | Client public key (payload encryption) |
auth |
text | NOT NULL | Client auth secret (payload encryption) |
user_agent |
text | Display in settings UI | |
created_at |
timestamptz | NOT NULL, DEFAULT NOW() | Audit |
last_used_at |
timestamptz | Updated on successful send |
Indexes:
user_id- fan-out lookup: all subscriptions for a user, on every push sendendpoint(unique) - one row per subscription, prevents duplicate registration
Design decisions:
- Capped at 10 subscriptions per user —
createPushSubscription(db/notifications/mutations.ts) evicts the oldest (bycreated_at) past the cap - Endpoint pruned on 404/410 from the push service (
deletePushSubscriptionByEndpoint) — dead subscriptions don't accumulate - Cascade-deleted with the account — no orphaned subscriptions
notification_deliveries
Audit log for external channel deliveries.
| Column | Type | Constraints | Purpose |
|---|---|---|---|
id |
text | PK | ndl_xxxxx |
notification_id |
text | FK → notifications, NOT NULL | Parent notification |
channel |
enum | NOT NULL | telegram, discord, email (never push — push delivers synchronously, no outbox row) |
status |
enum | NOT NULL, DEFAULT pending | pending, processing, sent, failed, skipped, retrying, dead |
provider_message_id |
text | External reference for correlation | |
error_code |
text | Provider-specific error code | |
error_message |
text | Human-readable error | |
attempts |
integer | NOT NULL, DEFAULT 0 | Attempt count — also the claim's fence token |
next_attempt_at |
timestamptz | NOT NULL, DEFAULT NOW() | Earliest time this row may be claimed; carries the retry backoff |
attempted_at |
timestamptz | Claim timestamp and lease start | |
sent_at |
timestamptz | Successful send timestamp | |
created_at |
timestamptz | NOT NULL, DEFAULT NOW() | Queue time |
next_attempt_at is NOT NULL on purpose: nullable would force a COALESCE into both the claim's WHERE and its ORDER BY, and btree ASC sorts NULLs last — never-attempted rows would queue behind every retry.
There is no separate claimed_at / lock_expires_at / worker_id. attempted_at is the claim stamp, the lease length is a constant (DELIVERY_CLAIM_LEASE_MS), and attempts is the fence. See architecture/workers.md.
Enums:
CREATE TYPE delivery_status AS ENUM (
'pending', -- Queued for send
'processing', -- Currently sending
'sent', -- Provider accepted
'failed', -- Provider says it will never work (403, bad address) — retry is pointless
'skipped', -- UNUSED: never written by any code path
'retrying', -- UNUSED: a retry is 'pending' with a future next_attempt_at
'dead' -- Retryable fault outlived the budget — surfaces in the admin panel
);
CREATE TYPE notification_channel AS ENUM (
'email',
'telegram',
'discord',
'push'
);
push is a valid enum value (shared with notification_settings and the router), but no row in notification_deliveries ever carries it — push delivery is synchronous and never touches the outbox.
skipped and retrying stay in the enum only because removing a Postgres enum value requires recreating the type. A row being retried is pending with attempts > 0 and a future next_attempt_at — derive the label at read time rather than adding DDL.
Indexes:
notification_id- lookup deliveries for a notificationdelivery_pending_idxon(next_attempt_at, created_at)WHEREstatus = 'pending'- the claim's exact WHERE + ORDER BYdelivery_processing_idxon(attempted_at)WHEREstatus = 'processing'- the stale-claim reaper- Partial index on
(created_at)WHEREstatus = 'failed'- failure review - Partial index WHERE
status = 'dead'- for the admin dashboard (getDeadDeliveries) (channel, created_at)index - channel health stats (getChannelHealthStats)
Retention:
sentrecords: 7 daysfailedrecords: 30 daysskippedrecords: 7 days
Extended Table
notification_settings (add columns)
Extend existing table with per-channel toggles. Notification channel configuration is a Setting (affects functionality) per ../../foundation/user-data.md.
| New Column | Type | Default | Purpose |
|---|---|---|---|
telegram_mention |
boolean | false | Mentions via Telegram |
telegram_comment |
boolean | false | Comments via Telegram |
telegram_system |
boolean | false | System via Telegram |
telegram_security |
boolean | true | Security via Telegram |
discord_mention |
boolean | false | Mentions via Discord |
discord_comment |
boolean | false | Comments via Discord |
discord_system |
boolean | false | System via Discord |
discord_security |
boolean | true | Security via Discord |
push_mention |
boolean | false | Mentions via Web Push |
push_comment |
boolean | false | Comments via Web Push |
push_system |
boolean | false | System via Web Push |
push_security |
boolean | true | Security via Web Push |
Email covers all 6 notification types (mention, comment, system, success, security, follow); Telegram, Discord, and Push cover only the 4 above. There are deliberately no push_success/push_follow columns — this mirrors the telegram/discord precedent, and the router's key in settings guard means those two types simply never route to push.
Why columns, not junction table?
- Fixed channel/type matrix, stored as flat columns
- Single row per user, no joins needed
- Simpler queries:
SELECT telegram_mention FROM notification_settings WHERE user_id = ? - Adding a new type = add columns (migration)
- Adding a new channel = add columns (migration, but rare)
Junction table alternative considered:
-- Rejected: more complex queries, no clear benefit for small matrix
CREATE TABLE notification_channel_settings (
user_id TEXT,
notification_type notification_type_enum,
channel notification_channel,
enabled BOOLEAN,
PRIMARY KEY (user_id, notification_type, channel)
);
Index Strategy
Hot Path Queries
| Query | Frequency | Index |
|---|---|---|
| Get Telegram chat_id for user | Every send | user_telegram_accounts.user_id (unique) |
| Get Discord credentials for user | Every send | user_discord_accounts.user_id (unique) |
| Get push subscriptions for user | Every push send | push_subscriptions.user_id |
| Get settings for user | Every send | notification_settings.user_id (PK) |
| Validate verification token | On deep link | telegram_verification_tokens.token (unique) |
Cold Path Queries
| Query | Frequency | Index |
|---|---|---|
| Tokens needing refresh | Hourly job | Partial on token_expires_at |
| Expired verification tokens | Daily cleanup | Partial on expires_at |
| Failed deliveries for retry | Periodic | Partial on status = 'failed' |
| Deliveries for notification | Debugging | notification_id |
Partial Indexes
Partial indexes reduce size and improve performance for filtered queries:
-- Only active accounts needing refresh
CREATE INDEX user_discord_accounts_needs_refresh_idx
ON user_discord_accounts (token_expires_at)
WHERE is_active = true AND token_refresh_failed_at IS NULL;
-- Only unused verification tokens
CREATE INDEX telegram_verification_tokens_cleanup_idx
ON telegram_verification_tokens (expires_at)
WHERE used_at IS NULL;
-- Only failed deliveries
CREATE INDEX notification_deliveries_failed_idx
ON notification_deliveries (status, created_at)
WHERE status = 'failed';
-- Only pending deliveries — ordered by due-time, which is what the claim scans
CREATE INDEX notification_deliveries_pending_idx
ON notification_deliveries (next_attempt_at, created_at)
WHERE status = 'pending';
-- Claimed-but-unreported rows, for the stale-claim reaper
CREATE INDEX notification_deliveries_processing_idx
ON notification_deliveries (attempted_at)
WHERE status = 'processing';
Migration Plan
Migration 1: Channel Connection Tables
-- user_telegram_accounts
CREATE TABLE user_telegram_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE REFERENCES "user"(id) ON DELETE CASCADE,
telegram_chat_id TEXT NOT NULL UNIQUE,
telegram_username TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
unlinked_at TIMESTAMPTZ
);
-- user_discord_accounts
CREATE TABLE user_discord_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE REFERENCES "user"(id) ON DELETE CASCADE,
discord_user_id TEXT NOT NULL UNIQUE,
discord_username TEXT,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
token_expires_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
token_refresh_failed_at TIMESTAMPTZ,
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tokens_refreshed_at TIMESTAMPTZ,
unlinked_at TIMESTAMPTZ
);
CREATE INDEX user_discord_accounts_needs_refresh_idx
ON user_discord_accounts (token_expires_at)
WHERE is_active = true AND token_refresh_failed_at IS NULL;
Migration 2: Verification Tokens
CREATE TABLE telegram_verification_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX telegram_verification_tokens_cleanup_idx
ON telegram_verification_tokens (expires_at)
WHERE used_at IS NULL;
Migration 3: Extended Settings
ALTER TABLE notification_settings
ADD COLUMN telegram_mention BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN telegram_comment BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN telegram_system BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN telegram_security BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN discord_mention BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN discord_comment BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN discord_system BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN discord_security BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN push_mention BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN push_comment BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN push_system BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN push_security BOOLEAN NOT NULL DEFAULT true;
Migration 4: Delivery Tracking
CREATE TYPE delivery_status AS ENUM (
'pending', 'processing', 'sent', 'failed', 'skipped', 'retrying', 'dead'
);
CREATE TYPE notification_channel AS ENUM (
'email', 'telegram', 'discord', 'push'
);
CREATE TABLE notification_deliveries (
id TEXT PRIMARY KEY,
notification_id TEXT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
channel notification_channel NOT NULL,
status delivery_status NOT NULL DEFAULT 'pending',
provider_message_id TEXT,
error_code TEXT,
error_message TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
attempted_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX notification_deliveries_notification_id_idx
ON notification_deliveries (notification_id);
CREATE INDEX notification_deliveries_failed_idx
ON notification_deliveries (status, created_at)
WHERE status = 'failed';
CREATE INDEX notification_deliveries_pending_idx
ON notification_deliveries (next_attempt_at, created_at)
WHERE status = 'pending';
CREATE INDEX notification_deliveries_processing_idx
ON notification_deliveries (attempted_at)
WHERE status = 'processing';
CREATE INDEX notification_deliveries_dead_idx
ON notification_deliveries (status, created_at)
WHERE status = 'dead';
CREATE INDEX notification_deliveries_channel_recent_idx
ON notification_deliveries (channel, created_at);
Migration 5: Push Subscriptions
CREATE TABLE push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX push_subscriptions_user_id_idx
ON push_subscriptions (user_id);
Related
- ./routing.md - How tables are used in delivery
- ./channels.md - Connection flows that populate tables
- ../db/relational.md - Full database schema
- ../app-shell/notifications.md - Existing notifications table
- ../pwa.md -
push_subscriptionslifecycle, payload contract