// 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? // Global theme preference: one of lib/themes' THEMES, or "system" (follow // the OS light/dark setting). Null = never set -- the root layout treats // that as "system". Stored per account (not just in localStorage) so each // account in a linked set keeps its own look when the browser toggles // between them; the theme menu persists it via saveUserTheme // (lib/actions/profile.ts) and app/layout.tsx applies it on every load. theme 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[] // Account linking (see lib/actions/account-links.ts): pending link // requests this account has sent or received, confirmed links it is part // of, request blocks it has issued, and switch tokens it has requested. linkRequestsFrom AccountLinkRequest[] @relation("LinkRequestFrom") linkRequestsTo AccountLinkRequest[] @relation("LinkRequestTo") accountLinksUser AccountLink[] @relation("AccountLinkUser") accountLinksLinked AccountLink[] @relation("AccountLinkLinkedUser") linkBlocksFrom AccountLinkBlock[] @relation("LinkBlockFrom") linkBlocksTo AccountLinkBlock[] @relation("LinkBlockTo") switchTokenRequests AccountSwitchToken[] @relation("SwitchTokenRequester") switchTokenTargets AccountSwitchToken[] @relation("SwitchTokenTarget") } /** * A pending account-link request: `fromUser` asked to link with `toUser`, * who must decide ("Create Account Link", "Deny Account Link", or "Deny * and Block Account Link" on the Profile page). At most one pending * request per direction per pair (the composite unique), so re-requesting * is a no-op upsert rather than a duplicate. Denied requests are deleted, * not archived -- a later request simply starts fresh. */ model AccountLinkRequest { id String @id @default(cuid()) fromUserId String toUserId String createdAt DateTime @default(now()) fromUser User @relation("LinkRequestFrom", fields: [fromUserId], references: [id], onDelete: Cascade) toUser User @relation("LinkRequestTo", fields: [toUserId], references: [id], onDelete: Cascade) @@unique([fromUserId, toUserId]) @@index([toUserId]) } /** * A confirmed account link, stored exactly once per pair (either order) -- * lib/actions/account-links.ts normalizes the pair into the same order * before creating, and the composite unique below makes a concurrent * double-confirm fail with a unique violation instead of a duplicate row. * Either linked account can remove the link; removing it does not prevent * re-requesting (the request flow starts over). * `userId` and `linkedUserId` must differ -- enforced by the * account_link_not_self check constraint in the migration (Prisma has no * schema-level CHECK syntax). */ model AccountLink { id String @id @default(cuid()) userId String linkedUserId String createdAt DateTime @default(now()) user User @relation("AccountLinkUser", fields: [userId], references: [id], onDelete: Cascade) linkedUser User @relation("AccountLinkLinkedUser", fields: [linkedUserId], references: [id], onDelete: Cascade) @@unique([userId, linkedUserId]) } /** * A denied-and-blocked pair: `fromUser` may no longer send account-link * requests to `toUser` (checked in requestAccountLink). Created by the * "Deny and Block Account Link" action; reversible by the blocked-against * account (unblockAccountLinkRequests), since a block is permanent for the * requester otherwise. */ model AccountLinkBlock { id String @id @default(cuid()) fromUserId String toUserId String createdAt DateTime @default(now()) fromUser User @relation("LinkBlockFrom", fields: [fromUserId], references: [id], onDelete: Cascade) toUser User @relation("LinkBlockTo", fields: [toUserId], references: [id], onDelete: Cascade) @@unique([fromUserId, toUserId]) } /** * A short-lived, single-use token letting one linked account switch the * browser's session to the other without knowing its password (the * Profile page "Toggle" button). Created by toggleAccount (lib/actions/ * account-links.ts) after the link is verified, consumed exactly once by * authorize() in the "account-switch" provider of auth.ts. */ model AccountSwitchToken { id String @id @default(cuid()) token String @unique requestedBy String targetUserId String // Absolute expiry -- the token is dead after this even if unused. expiresAt DateTime // Set the moment authorize() accepts it. A token with usedAt set is // never valid again, so a replayed URL can't switch twice. usedAt DateTime? createdAt DateTime @default(now()) requester User @relation("SwitchTokenRequester", fields: [requestedBy], references: [id], onDelete: Cascade) target User @relation("SwitchTokenTarget", fields: [targetUserId], references: [id], onDelete: Cascade) } /** * 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]) }