254 lines
9.2 KiB
Plaintext
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])
|
|
}
|