feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes

- New VerantwortlicheInvite model: LT-issued invites so a person can
  register as Gemeinde Verantwortliche(r) for a specific Gemeinde,
  skipping the self-registration approval step.
- Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl,
  beschreibung), mirroring the WP plugin's multi-phase elections.
  Teilnehmer unique constraint now scoped per phase.
- Auth: team login + guest auth adjustments, spec coverage.
- sync.service.ts: register VerantwortlicheInvite as a synced model.
- wahl.service.ts: submitTeilnehmer updated for the new phase-scoped
  unique key.
- client: login/home screen rework, new theme.dart, FCM web tweaks.
- .gitignore: ignore .DS_Store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 18:00:18 +02:00
co-authored by Claude Sonnet 5
parent cfa0070eab
commit 2f76790135
12 changed files with 403 additions and 40 deletions
@@ -0,0 +1,43 @@
-- DropIndex
DROP INDEX "Teilnehmer_wahlId_guestAccountId_key";
-- AlterTable
ALTER TABLE "Wahl" ADD COLUMN "beschreibung" TEXT,
ADD COLUMN "phasenAnzahl" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "Workshop" ADD COLUMN "beschreibung" TEXT,
ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "Teilnehmer" ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1;
-- CreateTable
CREATE TABLE "VerantwortlicheInvite" (
"id" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"gemeindeId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"email" TEXT,
"maxUses" INTEGER,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VerantwortlicheInvite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "VerantwortlicheInvite_token_key" ON "VerantwortlicheInvite"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_phase_key" ON "Teilnehmer"("wahlId", "guestAccountId", "phase");
-- AddForeignKey
ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+40 -2
View File
@@ -24,6 +24,7 @@ model Kc {
guests GuestAccount[]
localUsers User[]
teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
}
/// A local congregation/community participating in one Kc.
@@ -37,6 +38,7 @@ model Gemeinde {
memberships Membership[]
guests GuestAccount[]
teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
@@unique([kcId, name])
}
@@ -152,13 +154,42 @@ model TeamerInvite {
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())
@@ -168,10 +199,14 @@ model Wahl {
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)
@@ -180,10 +215,13 @@ model Workshop {
forceZuteilungen ForceZuteilung[]
}
/// A participant's submitted choices for a Wahl.
/// 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())
@@ -193,7 +231,7 @@ model Teilnehmer {
zuteilung Zuteilung?
forceZuteilung ForceZuteilung?
@@unique([wahlId, guestAccountId])
@@unique([wahlId, guestAccountId, phase])
}
/// Manual override set by LT before running the assignment algorithm; takes precedence.