feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync)
Full NestJS backend for the KC-App platform: - auth: Authentik OIDC resource-server strategy + guest invite-code JWT login, plus TokenVerificationService for the WS handshake path - kc: Leitungsteam-only KC (event) creation/listing - wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService (port of the WP plugin's kc_run_zuteilung), CSV export - files: LT-only upload with visibility tiers; list/download filtered by caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3) - chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws gateway sharing ChatService access rules - sync: append-only SyncLogEntry replication log + local<->cloud push/pull scheduler, shared-secret guarded - common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global) - serves client/web/ interim static web client under / (API under /api) Typecheck, nest build and boot test pass; needs real Postgres/Authentik/ Nextcloud to run end to end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+119
-56
@@ -9,19 +9,19 @@ datasource db {
|
||||
|
||||
/// 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
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
inviteCode String @unique
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
gemeinden Gemeinde[]
|
||||
gemeinden Gemeinde[]
|
||||
memberships Membership[]
|
||||
wahlen Wahl[]
|
||||
files File[]
|
||||
channels ChatChannel[]
|
||||
guests GuestAccount[]
|
||||
wahlen Wahl[]
|
||||
files File[]
|
||||
channels ChatChannel[]
|
||||
guests GuestAccount[]
|
||||
}
|
||||
|
||||
/// A local congregation/community participating in one Kc.
|
||||
@@ -31,7 +31,7 @@ model Gemeinde {
|
||||
kcId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
memberships Membership[]
|
||||
guests GuestAccount[]
|
||||
|
||||
@@ -46,15 +46,16 @@ enum Role {
|
||||
|
||||
/// Authentik-backed user (team member with elevated rights).
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
authentikSub String @unique
|
||||
email String @unique
|
||||
firstName String
|
||||
lastName String
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
authentikSub String @unique
|
||||
email String @unique
|
||||
firstName String
|
||||
lastName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
memberships Membership[]
|
||||
messages ChatMessage[]
|
||||
memberships Membership[]
|
||||
messages ChatMessage[]
|
||||
chatParticipations ChatParticipant[]
|
||||
}
|
||||
|
||||
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||
@@ -83,62 +84,79 @@ model GuestAccount {
|
||||
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[]
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
teilnehmer Teilnehmer[]
|
||||
}
|
||||
|
||||
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
|
||||
model Wahl {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
name String
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
name String
|
||||
datumsSchluessel String
|
||||
teil String
|
||||
isOpen Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
teil String
|
||||
isOpen Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
workshops Workshop[]
|
||||
teilnehmer Teilnehmer[]
|
||||
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
|
||||
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[]
|
||||
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
|
||||
id String @id @default(cuid())
|
||||
wahlId String
|
||||
guestAccountId String
|
||||
prioritaeten Json
|
||||
createdAt DateTime @default(now())
|
||||
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?
|
||||
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])
|
||||
}
|
||||
|
||||
/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer.
|
||||
/// 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
|
||||
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: Cascade)
|
||||
workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
enum FileVisibility {
|
||||
@@ -173,19 +191,64 @@ model ChatChannel {
|
||||
gemeindeId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
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())
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user