Initial commit

This commit is contained in:
siliconsolder committed 2026-09-07 09:54:30 +00:00
commit 38c24fc9dc
166 files changed
+22002

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
# 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).