7.3 KiB
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 | |
| 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)wherededuplication_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).