All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m43s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Implements the FileStorage abstraction (uploads/<pondId>/<fileId> on the mounted volume), the attachments model, and POST /ponds/:id/files, GET /media/:fileId, DELETE /files/:id. Uploads are validated by sniffing magic bytes rather than trusting the client's Content-Type/filename (catches a renamed .html-as-.png), checked against the max_file_bytes and storage_bytes quotas, and served with nosniff + immutable caching. Closes #27
302 lines
9.9 KiB
Plaintext
302 lines
9.9 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[]
|
|
|
|
@@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?
|
|
attachments Attachment[]
|
|
|
|
@@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")
|
|
}
|
|
|
|
/// 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` is nullable and
|
|
/// left unset by #27's endpoints — images are uploaded before the page
|
|
/// referencing them is known (paste-then-insert, issue #28); a later story
|
|
/// wires pages to set it once they track their embedded attachments.
|
|
/// `deletedAt` is unused by #27 (its `DELETE /files/:id` hard-deletes
|
|
/// immediately, bytes and all) — reserved for the page-trash-purge flow
|
|
/// (#31, ADR 0011 "orphan cleanup"), which soft-deletes an attachment when
|
|
/// its page is purged before the nightly job physically removes it.
|
|
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")
|
|
}
|