# 03 — Database Model (PostgreSQL 16) Conventions: `id uuid primary key default gen_random_uuid()` (needs `pgcrypto` or use `uuid-ossp`'s `uuid_generate_v4()` — Drizzle migration will enable `pgcrypto`), all timestamps `timestamptz`, stored/compared in UTC. Migrations managed by `drizzle-kit` (plain SQL files checked into the repo). ## `users` | column | type | notes | |---|---|---| | id | uuid pk | | | email | text unique not null | | | display_name | text not null | | | avatar_url | text null | | | role | text not null default 'user' | check in ('admin','user') | | auth_subject | text not null | OIDC `sub`, or the proxy-auth identity string | | auth_issuer | text null | OIDC issuer URL, null for proxy mode | | created_at | timestamptz default now() | | | updated_at | timestamptz default now() | | | last_login_at | timestamptz null | | Unique index on `(auth_issuer, auth_subject)`. ## `topics` | column | type | notes | |---|---|---| | id | uuid pk | | | slug | text unique not null | URL/publish-path identifier, e.g. `homeassistant` | | display_name | text not null | | | description | text null | | | icon | text null | icon key or emoji | | accent_color | text null | hex | | retention_days | int null | null = use global default; 0 = forever | | restricted | boolean not null default false | if true, visibility gated by `topic_members` | | created_by | uuid fk users(id) null | | | created_at | timestamptz default now() | | | updated_at | timestamptz default now() | | | archived_at | timestamptz null | soft delete | ## `topic_members` Only consulted when `topics.restricted = true`. | column | type | |---|---| | topic_id | uuid fk topics(id) | | user_id | uuid fk users(id) | Primary key `(topic_id, user_id)`. ## `user_topic_subscriptions` Per-user push preference for a topic. Absence of a row = default `push_enabled = true` for any topic the user can see (avoids needing to backfill a row per user per topic on topic creation). | column | type | notes | |---|---|---| | id | uuid pk | | | user_id | uuid fk users(id) | | | topic_id | uuid fk topics(id) | | | push_enabled | boolean not null default true | | | created_at | timestamptz default now() | | | updated_at | timestamptz default now() | | Unique `(user_id, topic_id)`. ## `notifications` | column | type | notes | |---|---|---| | id | uuid pk | | | topic_id | uuid fk topics(id) not null | | | source | text not null | free-form publisher-supplied service name | | title | text not null | | | message | text not null | | | external_url | text null | "click" URL, browser-rendered only | | image_url | text null | browser-rendered only, never server-fetched | | attachment_filename | text null | metadata only | | attachment_content_type | text null | metadata only | | attachment_size_bytes | bigint null | metadata only | | deduplication_key | text null | | | occurrence_count | int not null default 1 | incremented on deduped repeats | | raw_priority | text null | ntfy-compat passthrough, never read for logic | | sender_token_id | uuid fk api_tokens(id) null | | | search_vector | tsvector generated | `to_tsvector('simple', title \|\| ' ' \|\| message)`, GIN indexed | | created_at | timestamptz default now() | = "received_at" (server receipt time) | | updated_at | timestamptz default now() | bumped when occurrence_count increments | Indexes: - `(topic_id, created_at desc)` — topic history pagination - `(deduplication_key, topic_id, created_at desc)` where `deduplication_key is not null` — dedupe window lookups - GIN `(search_vector)` — full-text search - `(created_at)` — retention cleanup sweep ## `user_notification_state` Per-user read/star state (notifications are shared rows across all users who can see the topic). | column | type | notes | |---|---|---| | id | uuid pk | | | user_id | uuid fk users(id) | | | notification_id | uuid fk notifications(id) | | | read | boolean not null default false | | | read_at | timestamptz null | | | starred | boolean not null default false | | | starred_at | timestamptz null | | Unique `(user_id, notification_id)`. Indexes on `(user_id, read)`, `(user_id, starred)` for unread-count and starred-list queries. ## `push_subscriptions` | column | type | notes | |---|---|---| | id | uuid pk | | | user_id | uuid fk users(id) | | | endpoint | text unique not null | Push service subscription URL | | p256dh | text not null | | | auth | text not null | | | user_agent | text null | | | device_label | text null | auto-derived from UA, user-editable | | created_at | timestamptz default now() | | | last_seen_at | timestamptz default now() | bumped on each successful push or resubscribe | | disabled_at | timestamptz null | set after push service returns 404/410 | ## `api_tokens` | column | type | notes | |---|---|---| | id | uuid pk | | | name | text not null | e.g. "Home Assistant" | | token_hash | text unique not null | SHA-256 hex of the full token | | token_prefix | text not null | first 8 chars after `nzr_`, for display | | allowed_source | text null | if set, publisher's `source` must match | | allowed_cidrs | text[] null | null = no IP restriction beyond `INGESTION_ALLOWED_CIDRS` | | expires_at | timestamptz null | | | revoked_at | timestamptz null | | | created_by | uuid fk users(id) | | | created_at | timestamptz default now() | | | last_used_at | timestamptz null | | | usage_count | bigint not null default 0 | | ## `api_token_topics` Join table: which topics a token may publish to (empty = all topics). | column | type | |---|---| | token_id | uuid fk api_tokens(id) | | topic_id | uuid fk topics(id) | Primary key `(token_id, topic_id)`. ## `push_delivery_attempts` | column | type | notes | |---|---|---| | id | uuid pk | | | notification_id | uuid fk notifications(id) | | | push_subscription_id | uuid fk push_subscriptions(id) | | | status | text not null | enum: pending, accepted_by_push_service, failed, invalid_subscription, expired | | http_status | int null | | | error_message | text null | sanitized (no PII/secrets) | | retry_count | int not null default 0 | | | attempted_at | timestamptz default now() | | Index `(notification_id)`, `(push_subscription_id, attempted_at desc)`. ## `audit_log` | column | type | notes | |---|---|---| | id | uuid pk | | | actor_user_id | uuid fk users(id) null | | | actor_token_id | uuid fk api_tokens(id) null | | | action | text not null | e.g. `topic.create`, `token.revoke`, `rate_limit.rejected`, `user.role_change` | | target_type | text null | | | target_id | text null | | | metadata | jsonb null | | | ip_address | inet null | | | created_at | timestamptz default now() | | Index `(created_at desc)`, `(action)`. ## `system_settings` Singleton key/value store — VAPID keys, global retention default, rate-limit config overrides. | column | type | |---|---| | key | text primary key | | value | jsonb not null | | updated_at | timestamptz default now() | Known keys: `vapid_keys` (`{publicKey, privateKey, generatedAt}`), `retention_default_days`, `rate_limit_overrides`. ## Retention cleanup An interval job (every 1h) deletes `notifications` rows older than the effective retention (`topics.retention_days`, falling back to `system_settings.retention_default_days`, default 90). `retention_days = 0` means "forever" and is skipped. Cascade deletes remove associated `user_notification_state` and `push_delivery_attempts` rows (`ON DELETE CASCADE` on the FKs).