generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } /// A Konfi-Castle event; the top-level tenant. One instance manages many KCs. model Kc { id String @id @default(cuid()) name String inviteCode String @unique isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt gemeinden Gemeinde[] memberships Membership[] wahlen Wahl[] files File[] channels ChatChannel[] guests GuestAccount[] localUsers User[] teamerInvites TeamerInvite[] } /// A local congregation/community participating in one Kc. model Gemeinde { id String @id @default(cuid()) name String kcId String createdAt DateTime @default(now()) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) memberships Membership[] guests GuestAccount[] teamerInvites TeamerInvite[] @@unique([kcId, name]) } enum Role { LEITUNGSTEAM GEMEINDE_VERANTWORTLICHER GEMEINDE_TEAMER } /// A team member account. Leitungsteam and Gemeinde Verantwortliche are /// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde /// Teamer are local accounts created by a Verantwortliche/r (`passwordHash` /// set, `authentikSub` null, `kcId` set) and, like guests, scoped to one KC. model User { id String @id @default(cuid()) authentikSub String? @unique email String @unique firstName String lastName String passwordHash String? kcId String? createdAt DateTime @default(now()) kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade) memberships Membership[] messages ChatMessage[] chatParticipations ChatParticipant[] } /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). /// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId. model Membership { id String @id @default(cuid()) userId String kcId String gemeindeId String? role Role createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) @@unique([userId, kcId, gemeindeId]) } /// Local, non-Authentik account for Konfis/guests, scoped to one Kc/event. model GuestAccount { id String @id @default(cuid()) kcId String gemeindeId String? firstName String lastName String createdAt DateTime @default(now()) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) messages ChatMessage[] teilnehmer Teilnehmer[] } /// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer /// can self-register a local account for one Gemeinde. A group link leaves /// `email` null and may be redeemed up to `maxUses` times (null = unlimited); /// a personal invite pins `email` and defaults to a single use. model TeamerInvite { id String @id @default(cuid()) kcId String gemeindeId String token String @unique email String? maxUses Int? usedCount Int @default(0) expiresAt DateTime? revokedAt DateTime? createdByUserId String createdAt DateTime @default(now()) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) } /// A workshop election, scoped to a Kc; name carries a date key + "Teil". model Wahl { id String @id @default(cuid()) kcId String name String datumsSchluessel String teil String isOpen Boolean @default(true) createdAt DateTime @default(now()) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) workshops Workshop[] teilnehmer Teilnehmer[] forceZuteilungen ForceZuteilung[] } model Workshop { id String @id @default(cuid()) wahlId String name String kapazitaet Int minTeilnehmer Int @default(0) wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) zuteilungen Zuteilung[] forceZuteilungen ForceZuteilung[] } /// A participant's submitted choices for a Wahl. model Teilnehmer { id String @id @default(cuid()) wahlId String guestAccountId String prioritaeten Json createdAt DateTime @default(now()) wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) zuteilung Zuteilung? forceZuteilung ForceZuteilung? @@unique([wahlId, guestAccountId]) } /// Manual override set by LT before running the assignment algorithm; takes precedence. model ForceZuteilung { id String @id @default(cuid()) wahlId String teilnehmerId String @unique workshopId String wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade) workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade) } /// Result of the assignment algorithm for one Teilnehmer; workshopId is null if unassigned (no capacity left). model Zuteilung { id String @id @default(cuid()) teilnehmerId String @unique workshopId String? wunschRang Int @default(-1) isForced Boolean @default(false) createdAt DateTime @default(now()) teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade) workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull) } enum FileVisibility { ALLE ALLE_AUSSER_KONFIS NUR_LT } model File { id String @id @default(cuid()) kcId String storageKey String filename String visibility FileVisibility uploadedById String createdAt DateTime @default(now()) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) } enum ChatChannelType { GEMEINDE_GRUPPE DIREKT LT_UEBERGREIFEND BROADCAST } model ChatChannel { id String @id @default(cuid()) kcId String type ChatChannelType gemeindeId String? createdAt DateTime @default(now()) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) messages ChatMessage[] participants ChatParticipant[] } /// Explicit membership for DIREKT (1:1) channels; other channel types derive /// access from Membership/Gemeinde instead of this table. model ChatParticipant { id String @id @default(cuid()) channelId String userId String createdAt DateTime @default(now()) channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([channelId, userId]) } model ChatMessage { id String @id @default(cuid()) channelId String senderUserId String? senderGuestId String? body String createdAt DateTime @default(now()) channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) senderUser User? @relation(fields: [senderUserId], references: [id]) senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id]) } enum SyncOperation { CREATE UPDATE DELETE } /// Append-only log of local mutations, replicated to the peer server (local /// <-> cloud). `originId` is the SERVER_ID that made the change, so applying /// an incoming entry never gets re-captured/re-pushed back (no echo loops). model SyncLogEntry { id String @id @default(cuid()) sequence Int @default(autoincrement()) model String recordId String operation SyncOperation payload Json originId String createdAt DateTime @default(now()) } /// Per-peer replication progress, kept on the side that initiates sync /// (normally the local, on-site server, since it can always dial out to the /// cloud even when the cloud can't reach into the event's local network). model SyncCursor { id String @id @default(cuid()) peerId String @unique lastPushedSequence Int @default(0) lastPulledSequence Int @default(0) }