Organize/prisma/schema.prisma

254 lines
9.2 KiB
Plaintext

// Prisma schema for the organizer app.
// Learn more: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum Role {
ADMIN
USER
PENDING
}
// How the site handles new sign-ups. See SiteSettings.signupMode.
enum SignupMode {
// Anyone can sign up and is immediately a USER. Today's behavior.
OPEN
// Anyone can sign up, but starts PENDING until an admin approves them
// (moves them to USER or ADMIN) on the Admin page.
APPROVED
// Anyone can sign up, but starts PENDING until they confirm their email.
// Not implemented yet -- the Admin page exposes this option greyed out.
CONFIRMED
// Sign-ups are turned off entirely: no form, no working sign-up action.
CLOSED
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
// Legacy single name field, captured at sign-up. Display and the Admin
// page still use it; the Profile page manages firstName/lastName below.
name String?
// Profile page fields. Null = the user hasn't set them.
firstName String?
lastName String?
// Profile photo, stored as a data URL (base64 JPEG) rather than a file on
// disk: this app's container filesystem is ephemeral (only the Postgres
// volume persists across deploys), so the DB is the one place it can live.
// Always server-generated from the user's upload (resized to 256x256 via
// sharp in lib/actions/profile.ts), never stored as the raw upload.
avatar String?
// The very first person to sign up becomes ADMIN regardless of
// signupMode (the site needs at least one admin to bootstrap). Everyone
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
// per SiteSettings.signupMode.
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories Category[]
projects Project[] @relation("ProjectOwner")
scheduledTodos ScheduledTodo[]
}
/**
* An isolated board: its own Categories/Groups/To-Dos, invisible from Home
* and from every other Project. Sole-owner for now -- `ownerId` is both
* "who created it" and (for now) the only person with access. When
* Projects become shared spaces, add a `ProjectMember` join table
* (projectId, userId, role) and extend the access checks in
* lib/access.ts to consult it too; `ownerId` stays as the implicit
* full-access member and doesn't need to change.
*/
model Project {
id String @id @default(cuid())
title String
ownerId String
// The theme assigned to this project (one of lib/themes' THEMES). Null =
// "Default Theme": follow whatever the user picked in the global theme
// menu. Applied only while the project's own page is open (see
// components/theme/project-theme-scope.tsx).
theme String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade)
categories Category[]
scheduledTodos ScheduledTodo[]
@@index([ownerId, createdAt])
}
// Single-row table of site-wide settings. Always has exactly one row, at
// the fixed id below -- read with `findFirst` (falling back to defaults
// when the row doesn't exist yet) and written with `upsert`.
model SiteSettings {
id String @id @default("singleton")
signupMode SignupMode @default(OPEN)
updatedAt DateTime @updatedAt
}
/**
* Single-row config for an OpenAI-compatible AI provider (e.g. OpenWebUI)
* the admin has wired up. Same singleton pattern as SiteSettings -- see
* lib/ai-settings.ts, which is also the *only* place `apiKey` should ever
* be read in full; every other consumer gets the masked view.
*/
model AiSettings {
id String @id @default("singleton")
apiUrl String?
// Stored as-is, not hashed -- it has to be sent to the provider
// verbatim on every request, unlike a password. Never sent back to the
// client in full.
apiKey String?
model String?
updatedAt DateTime @updatedAt
}
model Category {
id String @id @default(cuid())
name String
order Int
// Exactly one of these is set: userId for a Home category (personal,
// outside any project), projectId for a category inside a Project.
// Enforced by a DB check constraint (see migration) as well as by every
// access path going through lib/access.ts rather than querying userId
// or projectId directly.
userId String?
projectId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
groups Group[]
@@index([userId, order])
@@index([projectId, order])
}
model Group {
id String @id @default(cuid())
// Short display title, enforced at <=20 chars in app-level validation too.
title String @db.VarChar(20)
// Palette key (see lib/colors.ts) rather than a raw hex value, so the
// curated palette can be retinted centrally without a data migration.
color String
// Position within its Category lane.
order Int
// Single evolving markdown document for the group. Not a separate model:
// it's a 1:1, always-exists field with no independent lifecycle.
noteContent String @default("") @db.Text
// Set once a group is archived; archived groups are kept (not deleted)
// but excluded from the Home board query. Null = active.
archivedAt DateTime?
categoryId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
todos Todo[]
@@index([categoryId, order])
}
model Todo {
id String @id @default(cuid())
title String @db.VarChar(20)
details String? @db.Text
completed Boolean @default(false)
// Set when `completed` flips to true, cleared back to null when it
// flips to false. Distinct from `updatedAt`, which bumps on *any* field
// change (a title edit, a reorder, ...), not just completion.
completedAt DateTime?
// Position within its Group's to-do list.
order Int
groupId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@index([groupId, order])
}
/**
* A date-based or recurring to-do, shown in the right-hand "Scheduled"
* column rather than on a Group's kanban list -- entirely separate from
* Todo. Exactly one of userId/projectId is set, same ownership pattern as
* Category (enforced by a DB check constraint + lib/access.ts).
*/
model ScheduledTodo {
id String @id @default(cuid())
title String @db.VarChar(100)
details String? @db.Text
userId String?
projectId String?
// The only occurrence when rrule is null (a one-time to-do); the RRULE's
// DTSTART otherwise.
startDate DateTime @db.Date
// RFC 5545 recurrence rule string (e.g.
// "FREQ=WEEKLY;BYDAY=MO,WE;INTERVAL=2;UNTIL=20261231"), built and parsed
// via the `rrule` package -- never trust a client-supplied string, it's
// always rebuilt server-side from validated parts. Null = one-time.
rrule String?
// Optional "HH:MM" (24-hour) wall-clock time this to-do is due by --
// the same value applies to every occurrence of a recurring to-do. Null
// = due sometime that day, no specific time. It's local wall time with
// no timezone of its own; "is this occurrence's time due already past"
// is only ever evaluated client-side, against the viewer's own clock
// (see lib/time-of-day.ts) -- the server just stores and passes it through.
timeDue String? @db.VarChar(5)
// Minutes before `timeDue` to fire the "Remind me" notification -- 0 =
// "When due", a larger number = that many minutes earlier. Null = no
// reminder even though a time due is set (the user hasn't picked one, or
// explicitly chose "Don't remind me"). Always null whenever timeDue
// itself is null -- there's nothing to count down from otherwise
// (enforced in lib/actions/scheduled-todos.ts, not by a DB constraint).
remindMinutesBefore Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
completions ScheduledTodoCompletion[]
@@index([userId])
@@index([projectId])
}
/**
* Per-occurrence completion for a ScheduledTodo -- a recurring to-do needs
* independent completed state per calendar date (this Tuesday done, next
* Tuesday not), so this isn't a single boolean on ScheduledTodo itself.
* One-time to-dos also get exactly one row here once completed.
*/
model ScheduledTodoCompletion {
id String @id @default(cuid())
scheduledTodoId String
occurrenceDate DateTime @db.Date
completedAt DateTime @default(now())
scheduledTodo ScheduledTodo @relation(fields: [scheduledTodoId], references: [id], onDelete: Cascade)
@@unique([scheduledTodoId, occurrenceDate])
}