feat(backend): local accounts + invites for Gemeinde Teamer

Per the updated plan, Gemeinde Teamer are no longer Authentik-backed; they
are local accounts a Gemeinde Verantwortliche/r provisions per KC.

Schema:
- User.authentikSub now nullable; add passwordHash + kcId (cascade from Kc)
  so one User model covers Authentik members and local Teamer.
- new TeamerInvite model: shareable group link (email null, maxUses null)
  or personal invite (email pinned, single use), with expiry + revoke.
- sync log now also replicates User / Membership / TeamerInvite.

Auth:
- TeamAuthService: bcrypt password login (POST /auth/team-login) and invite
  redemption (POST /auth/teamer/register) issuing a JWT signed with
  TEAM_JWT_SECRET, payload typ:"team".
- TeamJwtStrategy (AuthGuard('team')) resolves it to the same
  AuthenticatedUser shape as AuthentikStrategy.
- TokenVerificationService.verifyEither() also accepts team tokens (WS).
- files + chat read endpoints accept 'team' tokens; Teamer see non-Konfi
  files and can use chat / start DMs.

Teamer admin (teamer/ module, under /gemeinde/:gemeindeId):
- POST/GET teamer, DELETE teamer/:userId
- POST/GET teamer-invites, DELETE teamer-invites/:inviteId
- LT may manage any Gemeinde; a Verantwortliche/r only their own
  (checked in TeamerService, since RolesGuard only scopes by kcId).

Tests: TeamAuthService + TeamerService specs added (Prisma/Sync mocked),
npm test green at 32. Docs (plan + backend README) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 16:50:06 +02:00
co-authored by Claude Sonnet 5
parent 081a9aa241
commit d48c07b0e4
24 changed files with 1076 additions and 30 deletions
+41 -11
View File
@@ -16,12 +16,14 @@ model Kc {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
gemeinden Gemeinde[]
memberships Membership[]
wahlen Wahl[]
files File[]
channels ChatChannel[]
guests GuestAccount[]
gemeinden Gemeinde[]
memberships Membership[]
wahlen Wahl[]
files File[]
channels ChatChannel[]
guests GuestAccount[]
localUsers User[]
teamerInvites TeamerInvite[]
}
/// A local congregation/community participating in one Kc.
@@ -31,9 +33,10 @@ model Gemeinde {
kcId String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
guests GuestAccount[]
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
guests GuestAccount[]
teamerInvites TeamerInvite[]
@@unique([kcId, name])
}
@@ -44,15 +47,21 @@ enum Role {
GEMEINDE_TEAMER
}
/// Authentik-backed user (team member with elevated rights).
/// 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
authentikSub String? @unique
email String @unique
firstName String
lastName String
passwordHash String?
kcId String?
createdAt DateTime @default(now())
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
messages ChatMessage[]
chatParticipations ChatParticipant[]
@@ -90,6 +99,27 @@ model GuestAccount {
teilnehmer Teilnehmer[]
}
/// 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)
}
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
model Wahl {
id String @id @default(cuid())