- 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
371 lines
13 KiB
Plaintext
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)
|
|
}
|