Files
Notizarr/docs/phase1/03-database-model.md
T
2026-09-07 09:54:30 +00:00

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
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).