212 lines
7.3 KiB
Markdown
212 lines
7.3 KiB
Markdown
# 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).
|