dorfteich/apps/api/prisma/schema.prisma
Claude Sonnet 5 98e159ab50
All checks were successful
CD / Build and push images (push) Successful in 1m52s
CI / Lint, typecheck, test (push) Successful in 1m34s
CI / Auth e2e pack (push) Successful in 1m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m7s
CD / Promote to Int (push) Successful in 10s
Add page CRUD and Yjs state persistence (#23)
Prisma models `pages`/`page_updates`/`page_content_cache` per
data-model.md. Endpoints: POST /ponds/:id/pages (title -> empty Yjs doc
state, seeded via y-prosemirror), GET /pages/:id (meta + base64 state),
PUT /pages/:id/state (client-encoded Yjs state, rejected above the 5 MiB
operations.md limit or if it doesn't decode into a valid document for
the schema), PATCH /pages/:id (title/slug — explicit slug changes
validate uniqueness per pond, title-only renames keep the slug),
DELETE (soft). Access follows InterimAccessService via the page's pond,
same 404-not-403 interim rule as ponds.

State saves decode the Yjs update with yjs + y-prosemirror and run it
through the #24 shared derivation functions (docToPlainText/
docToMarkdown/docToHtml/extractOutline) to refresh page_content_cache.
The Yjs XmlFragment name ("default") and the derivation call are
factored so the collab server's persistence hooks (#35) can reuse both.

Raised the API's JSON body limit to 8 MiB (main.ts and the e2e test
app) to fit base64-encoded page state.

Closes #23
2026-07-05 22:25:41 +02:00

268 lines
8.4 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[]
@@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[]
@@index([ownerId])
@@map("ponds")
}
/// 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?
@@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")
}
/// 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")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@map("page_content_cache")
}
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")
}
/// 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")
}