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[] verantwortlicheInvites VerantwortlicheInvite[] } /// 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[] verantwortlicheInvites VerantwortlicheInvite[] @@unique([kcId, name]) } enum Role { LEITUNGSTEAM GEMEINDE_VERANTWORTLICHER GEMEINDE_TEAMER } /// PENDING memberships come from self-registration and grant no rights until /// a Leitungsteam member approves them. Everything created by LT/Verantwortliche /// directly is ACTIVE from the start. enum MembershipStatus { ACTIVE PENDING } /// 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? /// Mirrored from the caller's Authentik group membership on every login. /// LEITUNGSTEAM is global (not KC-scoped), so it lives here rather than as /// a per-KC Membership row; the auth layer synthesises a virtual global /// LEITUNGSTEAM membership from this flag. isLeitungsteam Boolean @default(false) createdAt DateTime @default(now()) kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade) memberships Membership[] messages ChatMessage[] chatParticipations ChatParticipant[] deviceTokens DeviceToken[] } /// 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 status MembershipStatus @default(ACTIVE) 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[] deviceTokens DeviceToken[] } /// A push-notification target (FCM registration token) bound to whoever /// registered it — a team `User` or a `GuestAccount`. Replicated so a /// notification can be sent from either server. model DeviceToken { id String @id @default(cuid()) token String @unique platform String userId String? guestAccountId String? createdAt DateTime @default(now()) lastSeenAt DateTime @default(now()) user User? @relation(fields: [userId], references: [id], onDelete: Cascade) guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) } /// 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) } /// Invitation issued by a Leitungsteam member so a person can register as /// Gemeinde Verantwortliche/r for a specific Gemeinde via their /// Konfi-Castle-ID (Authentik) — skips the self-registration approval step /// since a Leitungsteam member is vouching for them directly. 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 VerantwortlicheInvite { 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". /// `phasenAnzahl` mirrors the WP plugin's `anzahl_einheiten`: a Wahl can run /// several independent phases (e.g. morning/afternoon), each with its own /// workshops, its own guest submission, and its own assignment run — a guest /// submits once per phase, not once for the whole Wahl. model Wahl { id String @id @default(cuid()) kcId String name String datumsSchluessel String teil String beschreibung String? phasenAnzahl Int @default(1) isOpen Boolean @default(true) createdAt DateTime @default(now()) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) workshops Workshop[] teilnehmer Teilnehmer[] forceZuteilungen ForceZuteilung[] } /// A workshop offered in one phase of a Wahl. `phase` is 1-based and must be /// <= the owning Wahl's `phasenAnzahl`. model Workshop { id String @id @default(cuid()) wahlId String phase Int @default(1) name String beschreibung 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 one phase of a Wahl. A guest submits /// separately per phase (matching the WP plugin), so the same guest can have /// one row per (wahlId, phase). model Teilnehmer { id String @id @default(cuid()) wahlId String phase Int @default(1) 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, phase]) } /// 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) }