dorfteich/apps/api/prisma/schema.prisma
Claude Opus 4.8 4d48d72c40
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m21s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add grant model and shared permission-resolution algorithm (#51)
The heart of the security model: one algorithm, implemented once, for API,
collab, and UI (permissions.md — authoritative).

- shared `permissions/`: pure resolution (`resolvePageCapability`) exactly per
  permissions.md — specificity page > label (incl. ancestor labels) > pond,
  deny wins within a level, default-closed, Site Admin bypass — plus the trash
  rule (`canAccessPage` / `canAccessTrashedPage`, ADR 0013). `grantValidationError`
  enforces the structural constraints. Documented, I/O-free signatures for
  API/collab reuse.
- prisma: `RoleGrant` (+ grant enums) per data-model.md, unique on
  (pond, subject, role, scope); migration adds a CHECK backstop that a
  POND_ADMIN grant is pond-scope + user-subject.
- api `grants/`: `GrantsService.createGrant` validates before insert (structural
  + no extra admin on a personal pond), rejects duplicates; `grantsForPond`
  returns the shared resolver model (what #52/#53 consume); enum mappers between
  the DB and the shared model. Interim "who may manage grants" stays until #52.
- tests: exhaustive table-driven resolver suite — every worked example from
  permissions.md §Resolution, edge cases (multi-label deny-wins, ancestor
  inheritance, anonymous/public, most-specific-allow-beats-less-specific-deny,
  trash) and a property test (a less-specific grant never overrides a
  more-specific decision); validation unit tests; grants db test proving
  write-time rejection of invalid grants.
- i18n: grant error codes (de + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 14:02:07 +02:00

518 lines
18 KiB
Plaintext

// Prisma schema — the single source of truth for the database structure.
// The entity documentation lives in docs/architecture/data-model.md; keep
// both in sync when the schema evolves.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// Typed key-value configuration for the instance (registration mode,
/// default quotas, legal pages, …). Values are validated with Zod before
/// writing; see the InstanceSettings service (issue #19).
model InstanceSetting {
key String @id
value Json
updatedAt DateTime @updatedAt
@@map("instance_settings")
}
enum UserStatus {
PENDING_VERIFICATION
ACTIVE
DISABLED
}
/// Account profile. Login methods live in UserIdentity (OIDC-ready,
/// ADR 0007); Site Admin is a user flag, all other roles are grants.
model User {
id String @id @default(uuid())
username String @unique
email String @unique
displayName String @map("display_name")
locale String @default("en")
isSiteAdmin Boolean @default(false) @map("is_site_admin")
status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at")
identities UserIdentity[]
sessions Session[]
authTokens AuthToken[]
ponds Pond[]
pages Page[]
attachments Attachment[]
@@map("users")
}
enum PondType {
PERSONAL
SHARED
}
/// Top-level content container (data-model.md §ponds). Personal ponds are
/// created automatically on e-mail verification; `settings` stores only
/// deviations from the defaults (pondSettingsSchema in @dorfteich/shared).
/// `deletedAt`/`deletedBy` implement the pond-level trash (ADR 0013).
model Pond {
id String @id @default(uuid())
slug String @unique
name String
description String @default("")
type PondType
ownerId String @map("owner_id")
settings Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
owner User @relation(fields: [ownerId], references: [id])
usage PondUsage?
pages Page[]
attachments Attachment[]
labels Label[]
grants RoleGrant[]
@@index([ownerId])
@@map("ponds")
}
enum GrantSubjectType {
USER
AUTHENTICATED
PUBLIC
}
enum GrantRole {
POND_ADMIN
EDITOR
READER
}
enum GrantScopeType {
POND
LABEL
PAGE
}
enum GrantEffect {
ALLOW
DENY
}
/// The permission table (permissions.md, data-model.md §role_grants, issue #51).
/// One grant `(subject, role, scope, effect)` inside a pond. The shared
/// resolution algorithm (`@dorfteich/shared` permissions) decides access from
/// these; the API/collab enforce it. Structural rules: `POND_ADMIN` only at
/// `POND` scope with a `USER` subject (a CHECK constraint backs this up, added
/// in the migration); personal ponds allow only their owner as admin (enforced
/// in the service, needs the pond type). `subjectId` is set only for `USER`
/// subjects; `scopeId` is the label/page id for `LABEL`/`PAGE` scopes.
model RoleGrant {
id String @id @default(uuid())
pondId String @map("pond_id")
subjectType GrantSubjectType @map("subject_type")
subjectId String? @map("subject_id")
role GrantRole
scopeType GrantScopeType @map("scope_type")
scopeId String? @map("scope_id")
effect GrantEffect
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade)
@@unique([pondId, subjectType, subjectId, role, scopeType, scopeId])
@@index([pondId])
@@map("role_grants")
}
/// A wiki page (data-model.md §pages). Carries a Yjs document from day one
/// (ADR 0003) even though M2 saves it wholesale over REST; `ydocState` is
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
/// on every save (issue #23). `sortKey` uses fractional indexing so pages
/// can be reordered without rewriting siblings (sidebar reorder is #26).
model Page {
id String @id @default(uuid())
pondId String @map("pond_id")
title String
slug String
ydocState Bytes @map("ydoc_state")
sortKey String @map("sort_key")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
pond Pond @relation(fields: [pondId], references: [id])
creator User @relation(fields: [createdBy], references: [id])
updates PageUpdate[]
contentCache PageContentCache?
attachments Attachment[]
versions PageVersion[]
pendingContributors PagePendingContributor[]
labels PageLabel[]
outgoingLinks PageLink[] @relation("outgoingLinks")
incomingLinks PageLink[] @relation("incomingLinks")
@@unique([pondId, slug])
@@index([pondId])
@@map("pages")
}
/// Append log for incremental Yjs updates (data-model.md), compacted
/// periodically. Unused by M2's whole-state REST saves; the collab
/// server's persistence hooks (#35) are the first real writer.
model PageUpdate {
id String @id @default(uuid())
pageId String @map("page_id")
seq Int
update Bytes
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@unique([pageId, seq])
@@map("page_updates")
}
enum PageVersionTrigger {
AUTO
MANUAL
PRE_RESTORE
}
/// Version snapshot of a page (ADR 0013, data-model.md). `ydocSnapshot` is a
/// full, self-contained encoded Yjs state — restore never depends on the
/// update log, so compaction (#40) cannot lose restorable history.
/// `contributorIds` is the set of users who edited since the previous version
/// (derived from the live session, #41). Created automatically at session end
/// and on an active-editing interval by collab, and on demand (named) by the
/// api; `PRE_RESTORE` snapshots are written before a restore (#42).
model PageVersion {
id String @id @default(uuid())
pageId String @map("page_id")
ydocSnapshot Bytes @map("ydoc_snapshot")
trigger PageVersionTrigger
label String?
/// Who created this version: the editor for `MANUAL`/`PRE_RESTORE`, null for
/// automatic snapshots (which have a contributor set instead of one author).
createdBy String? @map("created_by")
contributorIds String[] @map("contributor_ids")
createdAt DateTime @default(now()) @map("created_at")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@index([pageId, createdAt])
@@map("page_versions")
}
/// Accumulator of users who have edited a page since its last version (#41).
/// Collab flushes the current session's contributors here (deduplicated by the
/// composite key); version creation on either side reads and clears it in the
/// same transaction as writing the snapshot. Cascades on page purge (ADR 0013).
model PagePendingContributor {
pageId String @map("page_id")
userId String @map("user_id")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@id([pageId, userId])
@@map("page_pending_contributors")
}
/// Live-session registry the collab server keeps current: one row per page
/// with an open collaboration session, refreshed by a heartbeat (issue #40).
/// The compaction job reads it to skip pages that are being edited; a stale
/// row (collab crashed without unloading) ages out via the heartbeat window,
/// so no FK to `pages` is needed and a leftover row is harmless. Collab is the
/// only writer, over raw SQL (it does not use Prisma).
model CollabOpenSession {
pageId String @id @map("page_id")
heartbeatAt DateTime @map("heartbeat_at")
@@index([heartbeatAt])
@@map("collab_open_sessions")
}
/// Derived plain representation refreshed on every state save (issue #23),
/// built from the Yjs state via the shared editor schema. `outline` is the
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
model PageContentCache {
pageId String @id @map("page_id")
plainText String @map("plain_text")
markdown String
html String
outline Json
updatedAt DateTime @updatedAt @map("updated_at")
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
/// hook (both write it with the same weighting). The GIN index is added in
/// the migration (raw SQL — Prisma cannot index an Unsupported column).
searchVector Unsupported("tsvector")? @map("search_vector")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@map("page_content_cache")
}
/// A hierarchical label within a pond (data-model.md §labels, issue #43).
/// Labels organize pages and later scope permissions (M5): a grant on a label
/// applies to it and all its descendants, so the hierarchy must be sound now.
/// `parentId` builds the tree (max 6 levels, enforced in the service); cycles
/// are rejected at write time. Names are unique per (pond, parent) — the
/// unique index below covers nested labels; root-label uniqueness (parent_id
/// NULL, which Postgres treats as distinct) is enforced in the service under a
/// per-pond advisory lock. Deleting a label cascades to its whole subtree and
/// detaches page assignments; the service gates that behind `?force=true`.
model Label {
id String @id @default(uuid())
pondId String @map("pond_id")
parentId String? @map("parent_id")
name String
color String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pond Pond @relation(fields: [pondId], references: [id])
parent Label? @relation("LabelHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Label[] @relation("LabelHierarchy")
pages PageLabel[]
@@unique([pondId, parentId, name])
@@index([pondId])
@@map("labels")
}
/// Wikilink index maintained on every content change (data-model.md §page_links,
/// issue #47). One row per (source page, distinct target slug). `toPageId` is
/// the resolved target within the same pond, or null for a "phantom" link whose
/// target does not exist yet; creating/renaming a page to that slug resolves the
/// row. Backlinks query by `toPageId`. Collab (the content writer) maintains the
/// outgoing rows over raw SQL; it does not use Prisma.
model PageLink {
id String @id @default(uuid())
fromPageId String @map("from_page_id")
toPageId String? @map("to_page_id")
targetSlug String @map("target_slug")
fromPage Page @relation("outgoingLinks", fields: [fromPageId], references: [id], onDelete: Cascade)
toPage Page? @relation("incomingLinks", fields: [toPageId], references: [id], onDelete: SetNull)
@@unique([fromPageId, targetSlug])
@@index([toPageId])
@@index([targetSlug])
@@map("page_links")
}
/// Assignment of a label to a page (data-model.md §labels). Cascades on both
/// sides: purging a page or deleting a label removes the assignment.
model PageLabel {
pageId String @map("page_id")
labelId String @map("label_id")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
@@id([pageId, labelId])
@@index([labelId])
@@map("page_labels")
}
enum QuotaSubjectType {
USER
POND
}
/// Per-user/per-pond quota values (ADR 0011). Resolution: pond override →
/// user override → instance default (QuotaService). `value` is BigInt so
/// storage limits beyond 2 GiB fit.
model QuotaOverride {
id String @id @default(uuid())
subjectType QuotaSubjectType @map("subject_type")
subjectId String @map("subject_id")
quotaKey String @map("quota_key")
value BigInt
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([subjectType, subjectId, quotaKey])
@@map("quota_overrides")
}
/// Cached usage counters per pond, updated transactionally with the
/// guarded writes (uploads land in M2 #27, membership in M5); reconciled
/// nightly by a maintenance job (operations.md).
model PondUsage {
pondId String @id @map("pond_id")
storageBytesUsed BigInt @default(0) @map("storage_bytes_used")
editorCount Int @default(0) @map("editor_count")
readerCount Int @default(0) @map("reader_count")
updatedAt DateTime @updatedAt @map("updated_at")
pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade)
@@map("pond_usage")
}
/// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at
/// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the
/// metadata needed to serve and account for it. `pageId` starts unset —
/// images are uploaded before the page referencing them is known
/// (paste-then-insert, issue #28) — and is set on every page state save to
/// whichever page's document currently embeds the file (issue #31,
/// `PagesService.saveState`); the trash-purge job uses that link to delete
/// a purged page's files. Not touched when an image is later removed from
/// its page's content — an orphan-file sweep to reclaim those is a
/// separate future maintenance job (operations.md), not this one.
/// `deletedAt` stays unused for now — purge hard-deletes attachments
/// directly rather than soft-deleting them first — reserved for that same
/// future orphan-sweep job.
model Attachment {
id String @id @default(uuid())
pondId String @map("pond_id")
pageId String? @map("page_id")
fileName String @map("file_name")
mimeType String @map("mime_type")
sizeBytes Int @map("size_bytes")
storagePath String @map("storage_path")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
deletedAt DateTime? @map("deleted_at")
pond Pond @relation(fields: [pondId], references: [id])
page Page? @relation(fields: [pageId], references: [id])
uploader User @relation(fields: [uploadedBy], references: [id])
@@index([pondId])
@@index([pageId])
@@map("attachments")
}
/// One row per login method. `provider` is "password" today and
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
/// password identities.
model UserIdentity {
id String @id @default(uuid())
userId String @map("user_id")
provider String
subject String
credential String?
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, subject])
@@index([userId])
@@map("user_identities")
}
/// Server-side browser sessions (ADR 0007). `id` is the SHA-256 hash of
/// the opaque cookie token — the raw token is never stored.
model Session {
id String @id
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
userAgent String? @map("user_agent")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("sessions")
}
enum AuthTokenPurpose {
EMAIL_VERIFICATION
PASSWORD_RESET
}
/// Single-use, expiring tokens for e-mail flows. Stored hashed; consuming
/// sets `consumedAt` so replays are detectable.
model AuthToken {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
userId String @map("user_id")
purpose AuthTokenPurpose
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, purpose])
@@map("auth_tokens")
}
/// Fixed-window rate-limit counters (ADR 0002: no Redis). `key` encodes
/// scope and subject, e.g. "login:ip:203.0.113.7".
model RateLimit {
key String @id
windowStart DateTime @map("window_start")
count Int @default(0)
@@map("rate_limits")
}
enum MailStatus {
PENDING
SENT
FAILED
}
/// Outbox for reliable e-mail delivery with retry (issue #12).
model MailOutbox {
id String @id @default(uuid())
toAddress String @map("to_address")
subject String
textBody String @map("text_body")
htmlBody String @map("html_body")
status MailStatus @default(PENDING)
attempts Int @default(0)
nextAttemptAt DateTime @default(now()) @map("next_attempt_at")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at")
sentAt DateTime? @map("sent_at")
@@index([status, nextAttemptAt])
@@map("mail_outbox")
}
enum JobStatus {
IDLE
RUNNING
FAILED
}
/// Generic maintenance-job bookkeeping (data-model.md, operations.md;
/// issue #31). One row per named job; `SchedulerService` is the only
/// writer. `status`/`lockedAt` double as the run-mutex: claiming a due job
/// is a single atomic `UPDATE ... WHERE status != 'RUNNING'`, which is safe
/// under concurrent processes without needing a session-scoped advisory
/// lock (Prisma doesn't guarantee one connection across separate calls).
/// `lastRunAt` is what makes the schedule survive an api restart — cadence
/// is computed from it, not from an in-memory timer start time. The mail
/// outbox worker (#12) predates this table and still runs its own loop;
/// folding it in is left for whenever that file is next touched, not this
/// issue's job to do.
model Job {
name String @id
cadenceSeconds Int @map("cadence_seconds")
status JobStatus @default(IDLE)
lastRunAt DateTime? @map("last_run_at")
lockedAt DateTime? @map("locked_at")
lastError String? @map("last_error")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("jobs")
}