Files
KC-APP-Server/prisma/schema.prisma
linus 288628f20e
CybeDefend Security Scan / cybedefend_scan (push) Failing after 19s
CybeDefend Security Scan / cybedefend_scan (pull_request) Failing after 1s
feat(chat): free-form GRUPPE channels with mutable participants
- ChatChannelType.GRUPPE: created by Leitungsteam (any KC) or a Gemeinde
  Verantwortliche/r (own KC), mixing team users and guests/Konfis as
  explicit ChatParticipant rows (unlike GEMEINDE_GRUPPE, membership is
  not derived from Gemeinde)
- POST /chat/:kcId/gruppen to create, GET participant-candidates, and
  POST/DELETE /chat/gruppen/:channelId/participants to manage membership
  (creator, LT, or Verantwortliche/r of that KC)
- ChatGateway broadcasts chat:participants-changed on membership change
- PushService updated for nullable ChatParticipant.userId + new
  guestAccountId column
- SyncService now replicates ChatParticipant
- Prisma migration + 14 new unit tests (75/75 passing), tsc clean
- CI: add .gitea/workflows/cybedefend-scan.yml + .cybedefend project config
2026-09-12 13:25:48 +02:00

371 lines
13 KiB
Plaintext

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[]
chatParticipations ChatParticipant[]
}
/// 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
/// Freely composed group chat: created by a Leitungsteam member or a
/// Gemeinde Verantwortliche/r (for their own KC), with an explicit,
/// mutable participant list (team users and/or guests) via ChatParticipant
/// - unlike GEMEINDE_GRUPPE, membership is not derived from Gemeinde.
GRUPPE
}
model ChatChannel {
id String @id @default(cuid())
kcId String
type ChatChannelType
gemeindeId String?
/// Display name; used by GRUPPE channels (optional for other types).
name String?
/// Who created the channel; only set for GRUPPE so far. Used to let the
/// creator manage participants alongside Leitungsteam/Verantwortliche.
createdByUserId String?
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
messages ChatMessage[]
participants ChatParticipant[]
}
/// Explicit membership for DIREKT (1:1) and GRUPPE channels; other channel
/// types derive access from Membership/Gemeinde instead of this table.
/// Exactly one of userId/guestAccountId is set per row.
model ChatParticipant {
id String @id @default(cuid())
channelId String
userId String?
guestAccountId String?
createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
@@unique([channelId, userId])
@@unique([channelId, guestAccountId])
}
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)
}