Introduce a CodeResolverService to classify user login codes, complete with detailed resolution logic and usability checks. Extend the sync system to handle conflicts via last-write-wins arbitration, with detailed conflict tracking for review. Update file permissions and runtime isolation in Docker to enhance security.
407 lines
14 KiB
Plaintext
407 lines
14 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).
|
|
/// `occurredAt` is the wall-clock moment of the mutation itself (set at
|
|
/// capture time), distinct from `createdAt` which is just row-insert time -
|
|
/// conflict resolution compares `occurredAt`, never sync/network timing.
|
|
model SyncLogEntry {
|
|
id String @id @default(cuid())
|
|
sequence Int @default(autoincrement())
|
|
model String
|
|
recordId String
|
|
operation SyncOperation
|
|
payload Json
|
|
originId String
|
|
occurredAt DateTime @default(now())
|
|
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)
|
|
}
|
|
|
|
/// Last-write-wins register, one row per replicated record. Tracks the
|
|
/// wall-clock time and origin of whichever mutation - local or remote - is
|
|
/// currently considered authoritative for that record, so a concurrent edit
|
|
/// on both servers resolves deterministically by actual edit time instead
|
|
/// of by sync/network arrival order.
|
|
model SyncRecordVersion {
|
|
id String @id @default(cuid())
|
|
model String
|
|
recordId String
|
|
lastWriteAt DateTime
|
|
lastWriteOrigin String
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([model, recordId])
|
|
}
|
|
|
|
/// Audit trail of detected conflicts: two servers wrote the same record
|
|
/// within the replication window. Resolution (last-write-wins by
|
|
/// occurredAt) still happens automatically and immediately - nothing here
|
|
/// blocks live sync - but Leitungsteam can review afterwards whether a
|
|
/// discarded edit needs to be manually reapplied.
|
|
model SyncConflict {
|
|
id String @id @default(cuid())
|
|
model String
|
|
recordId String
|
|
winningOrigin String
|
|
losingOrigin String
|
|
winningPayload Json
|
|
losingPayload Json
|
|
detectedAt DateTime @default(now())
|
|
}
|