feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1

Merged
linus merged 28 commits from feat/backend-phases-0-6 into main 2026-09-12 11:26:16 +00:00
12 changed files with 403 additions and 40 deletions
Showing only changes of commit 2f76790135 - Show all commits
+5
View File
@@ -8,6 +8,11 @@
# --- 1. Backend build --------------------------------------------------------- # --- 1. Backend build ---------------------------------------------------------
FROM node:20-bookworm-slim AS api-build FROM node:20-bookworm-slim AS api-build
WORKDIR /src WORKDIR /src
# Prisma detects the OpenSSL version at `generate` time to pick the matching
# query engine binary; without OpenSSL present here it silently defaults to
# openssl-1.1.x, which then fails to load in the runtime stage (openssl 3.0.x).
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY backend/package.json backend/package-lock.json ./ COPY backend/package.json backend/package-lock.json ./
RUN npm ci RUN npm ci
COPY backend/ ./ COPY backend/ ./
+4 -2
View File
@@ -7,6 +7,8 @@ services:
POSTGRES_DB: kcapp POSTGRES_DB: kcapp
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"] test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"]
interval: 5s interval: 5s
@@ -29,9 +31,9 @@ services:
DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public
PORT: "3000" PORT: "3000"
GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json
APP_BASE_URL: http://localhost:3000 APP_BASE_URL: http://localhost:3010
ports: ports:
- "3000:3000" - "3010:3000"
volumes: volumes:
# Firebase service account — kept out of the image, mounted read-only. # Firebase service account — kept out of the image, mounted read-only.
- ./backend/serviceAccount.json:/app/serviceAccount.json:ro - ./backend/serviceAccount.json:/app/serviceAccount.json:ro
@@ -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[] guests GuestAccount[]
localUsers User[] localUsers User[]
teamerInvites TeamerInvite[] teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
} }
/// A local congregation/community participating in one Kc. /// A local congregation/community participating in one Kc.
@@ -37,6 +38,7 @@ model Gemeinde {
memberships Membership[] memberships Membership[]
guests GuestAccount[] guests GuestAccount[]
teamerInvites TeamerInvite[] teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
@@unique([kcId, name]) @@unique([kcId, name])
} }
@@ -152,13 +154,42 @@ model TeamerInvite {
gemeinde Gemeinde @relation(fields: [gemeindeId], 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". /// 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 { model Wahl {
id String @id @default(cuid()) id String @id @default(cuid())
kcId String kcId String
name String name String
datumsSchluessel String datumsSchluessel String
teil String teil String
beschreibung String?
phasenAnzahl Int @default(1)
isOpen Boolean @default(true) isOpen Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -168,10 +199,14 @@ model Wahl {
forceZuteilungen ForceZuteilung[] 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 { model Workshop {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
phase Int @default(1)
name String name String
beschreibung String?
kapazitaet Int kapazitaet Int
minTeilnehmer Int @default(0) minTeilnehmer Int @default(0)
@@ -180,10 +215,13 @@ model Workshop {
forceZuteilungen ForceZuteilung[] 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 { model Teilnehmer {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
phase Int @default(1)
guestAccountId String guestAccountId String
prioritaeten Json prioritaeten Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -193,7 +231,7 @@ model Teilnehmer {
zuteilung Zuteilung? zuteilung Zuteilung?
forceZuteilung ForceZuteilung? forceZuteilung ForceZuteilung?
@@unique([wahlId, guestAccountId]) @@unique([wahlId, guestAccountId, phase])
} }
/// Manual override set by LT before running the assignment algorithm; takes precedence. /// Manual override set by LT before running the assignment algorithm; takes precedence.
+7 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { GuestAuthService } from './guest-auth.service'; import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service'; import { TeamAuthService } from './team-auth.service';
@@ -49,10 +49,14 @@ export class AuthController {
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName); return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
} }
/// Password login for local Gemeinde Teamer accounts. /// Password login for local Gemeinde Teamer accounts — by Gemeinde name
/// (the normal path) or by email (legacy/personal accounts).
@Post('team-login') @Post('team-login')
teamLogin(@Body() dto: TeamLoginDto) { teamLogin(@Body() dto: TeamLoginDto) {
return this.teamAuth.login(dto.email, dto.password); if (!dto.email && !dto.gemeindeName) {
throw new BadRequestException('email or gemeindeName is required');
}
return this.teamAuth.login({ email: dto.email, gemeindeName: dto.gemeindeName }, dto.password);
} }
/// Self-registration for a Gemeinde Teamer via an invite token/link. /// Self-registration for a Gemeinde Teamer via an invite token/link.
+13 -2
View File
@@ -1,8 +1,19 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
/// Team login accepts EITHER an email (legacy/personal Verantwortliche
/// accounts) OR a Gemeinde name (the normal Teamer login path, since a
/// Teamer thinks of their login as "meine Gemeinde", not their email).
/// At least one of email/gemeindeName is required; enforced in the
/// controller rather than a custom validator to keep this DTO simple.
export class TeamLoginDto { export class TeamLoginDto {
@IsOptional()
@IsEmail() @IsEmail()
email!: string; email?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
gemeindeName?: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
+20 -2
View File
@@ -20,6 +20,11 @@ export class GuestAuthService {
private readonly sync: SyncService, private readonly sync: SyncService,
) {} ) {}
/// Redeems a KC invite code for a guest/Konfi session. If a guest account
/// with the same (trimmed, case-insensitive) name already exists for this
/// KC, reuses it instead of creating a new one — this is what lets a Konfi
/// "log back in" with the same code + name and keep their chat history /
/// Workshop-Wahl submission instead of losing it to a fresh blank account.
async createGuest( async createGuest(
inviteCode: string, inviteCode: string,
firstName: string, firstName: string,
@@ -30,10 +35,23 @@ export class GuestAuthService {
throw new NotFoundException('Unknown or inactive KC invite code'); throw new NotFoundException('Unknown or inactive KC invite code');
} }
const guest = await this.prisma.guestAccount.create({ const trimmedFirst = firstName.trim();
data: { kcId: kc.id, firstName, lastName }, const trimmedLast = lastName.trim();
let guest = await this.prisma.guestAccount.findFirst({
where: {
kcId: kc.id,
firstName: { equals: trimmedFirst, mode: 'insensitive' },
lastName: { equals: trimmedLast, mode: 'insensitive' },
},
});
if (!guest) {
guest = await this.prisma.guestAccount.create({
data: { kcId: kc.id, firstName: trimmedFirst, lastName: trimmedLast },
}); });
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest); await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
}
const payload: GuestJwtPayload = { const payload: GuestJwtPayload = {
guestId: guest.id, guestId: guest.id,
+87 -11
View File
@@ -28,6 +28,10 @@ interface InviteRow {
function makeService(seed: { function makeService(seed: {
invites?: InviteRow[]; invites?: InviteRow[];
users?: { id: string; email: string; passwordHash: string | null }[]; users?: { id: string; email: string; passwordHash: string | null }[];
memberships?: {
gemeindeName: string;
user: { id: string; passwordHash: string | null };
}[];
}) { }) {
const invites = [...(seed.invites ?? [])]; const invites = [...(seed.invites ?? [])];
const users = [...(seed.users ?? [])].map((u) => ({ const users = [...(seed.users ?? [])].map((u) => ({
@@ -39,6 +43,7 @@ function makeService(seed: {
memberships: [] as unknown[], memberships: [] as unknown[],
...u, ...u,
})); }));
const memberships = seed.memberships ?? [];
const prisma = { const prisma = {
user: { user: {
@@ -64,6 +69,21 @@ function makeService(seed: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) => create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `m-1`, ...data }), Promise.resolve({ id: `m-1`, ...data }),
), ),
findMany: jest.fn(
({
where,
}: {
where: { gemeinde: { name: { equals: string; mode: string } } };
}) =>
Promise.resolve(
memberships
.filter(
(m) =>
m.gemeindeName.toLowerCase() === where.gemeinde.name.equals.toLowerCase(),
)
.map((m) => ({ user: m.user })),
),
),
}, },
teamerInvite: { teamerInvite: {
findUnique: jest.fn(({ where }: { where: { token: string } }) => findUnique: jest.fn(({ where }: { where: { token: string } }) =>
@@ -195,18 +215,18 @@ describe('TeamAuthService.registerFromInvite', () => {
describe('TeamAuthService.login', () => { describe('TeamAuthService.login', () => {
it('rejects an unknown email', async () => { it('rejects an unknown email', async () => {
const { service } = makeService({ users: [] }); const { service } = makeService({ users: [] });
await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf( await expect(
UnauthorizedException, service.login({ email: 'nobody@example.org' }, 'x'),
); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('rejects a user without a password hash (Authentik-only account)', async () => { it('rejects a user without a password hash (Authentik-only account)', async () => {
const { service } = makeService({ const { service } = makeService({
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }], users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
}); });
await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf( await expect(
UnauthorizedException, service.login({ email: 'lt@example.org' }, 'x'),
); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('rejects a wrong password', async () => { it('rejects a wrong password', async () => {
@@ -215,18 +235,74 @@ describe('TeamAuthService.login', () => {
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
], ],
}); });
await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf( await expect(
UnauthorizedException, service.login({ email: 't@example.org' }, 'wrong'),
); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('issues a token for correct credentials', async () => { it('issues a token for correct credentials by email', async () => {
const { service } = makeService({ const { service } = makeService({
users: [ users: [
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
], ],
}); });
const res = await service.login('T@example.org', 'right'); const res = await service.login({ email: 'T@example.org' }, 'right');
expect(res.accessToken).toEqual(expect.any(String)); expect(res.accessToken).toEqual(expect.any(String));
}); });
it('rejects when neither email nor gemeindeName is given', async () => {
const { service } = makeService({});
await expect(service.login({}, 'x')).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects an unknown Gemeinde name', async () => {
const { service } = makeService({});
await expect(
service.login({ gemeindeName: 'Nirgendwo' }, 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('logs in by Gemeinde name, matching case-insensitively and trimmed', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
const res = await service.login({ gemeindeName: ' musterstadt ' }, 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
it('tries every Teamer account for a Gemeinde until one password matches', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('wrong-one', 10) },
},
{
gemeindeName: 'Musterstadt',
user: { id: 'u-2', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
const res = await service.login({ gemeindeName: 'Musterstadt' }, 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
it('rejects a Gemeinde login when no account password matches', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
await expect(
service.login({ gemeindeName: 'Musterstadt' }, 'wrong'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
}); });
+33 -8
View File
@@ -38,21 +38,46 @@ export class TeamAuthService {
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET'); this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
} }
async login(email: string, password: string): Promise<{ accessToken: string }> { /// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal
/// path, since a Teamer thinks of their login as "meine Gemeinde" rather
/// than an email address. A Gemeinde can have several Teamer accounts, so
/// a name lookup tries the password against every active GEMEINDE_TEAMER
/// membership for that Gemeinde (case-insensitive, trimmed name) until one
/// matches, rather than assuming a 1:1 Gemeinde-to-account mapping.
async login(
credentials: { email?: string; gemeindeName?: string },
password: string,
): Promise<{ accessToken: string }> {
if (credentials.email) {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() }, where: { email: credentials.email.toLowerCase() },
include: { memberships: true },
}); });
if (!user || !user.passwordHash) { if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
return { accessToken: this.sign(user.id) }; return { accessToken: this.sign(user.id) };
} }
const gemeindeName = credentials.gemeindeName?.trim();
if (!gemeindeName) {
throw new UnauthorizedException('Invalid credentials');
}
const memberships = await this.prisma.membership.findMany({
where: {
role: Role.GEMEINDE_TEAMER,
status: 'ACTIVE',
gemeinde: { name: { equals: gemeindeName, mode: 'insensitive' } },
},
include: { user: true },
});
for (const m of memberships) {
if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) {
return { accessToken: this.sign(m.user.id) };
}
}
throw new UnauthorizedException('Invalid credentials');
}
/// Redeems an invite token and creates the local Teamer account + its /// Redeems an invite token and creates the local Teamer account + its
/// GEMEINDE_TEAMER membership for the invite's Gemeinde. /// GEMEINDE_TEAMER membership for the invite's Gemeinde.
async registerFromInvite(input: { async registerFromInvite(input: {
+145 -5
View File
@@ -5,16 +5,15 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { MembershipStatus, Role, SyncOperation } from '@prisma/client'; import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
import { randomBytes } from 'crypto';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { TokenVerificationService } from '../auth/token-verification.service'; import { TokenVerificationService } from '../auth/token-verification.service';
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user'; import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in /// Self-service onboarding for Gemeinde Verantwortliche, plus the
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus /// Leitungsteam-initiated shortcut that skips the approval step entirely.
/// the Gemeinde they belong to; this provisions their local User (JIT) and a
/// PENDING membership that a Leitungsteam member must approve before it grants
/// any rights.
@Injectable() @Injectable()
export class OnboardingService { export class OnboardingService {
constructor( constructor(
@@ -133,4 +132,145 @@ export class OnboardingService {
) { ) {
return { membershipId, status, kcName, gemeindeName }; return { membershipId, status, kcName, gemeindeName };
} }
// --- Leitungsteam-issued Verantwortliche invites ---
// Skips the PENDING approval step: an LT member vouching for someone
// directly is enough, unlike self-registration which needs review.
async createInvite(
caller: AuthenticatedUser,
gemeindeId: string,
dto: { email?: string; maxUses?: number; expiresInHours?: number },
) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can issue this invite');
}
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
if (!gemeinde) {
throw new NotFoundException('Gemeinde not found');
}
const email = dto.email?.toLowerCase() ?? null;
const maxUses = dto.maxUses ?? (email ? 1 : null);
const expiresAt = dto.expiresInHours
? new Date(Date.now() + dto.expiresInHours * 3600_000)
: null;
const invite = await this.prisma.verantwortlicheInvite.create({
data: {
kcId: gemeinde.kcId,
gemeindeId,
token: randomBytes(24).toString('base64url'),
email,
maxUses,
expiresAt,
createdByUserId: caller.userId,
},
});
await this.sync.capture('VerantwortlicheInvite', SyncOperation.CREATE, invite.id, invite);
return invite;
}
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can view this');
}
return this.prisma.verantwortlicheInvite.findMany({
where: { gemeindeId },
orderBy: { createdAt: 'desc' },
});
}
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can revoke this');
}
const invite = await this.prisma.verantwortlicheInvite.findFirst({
where: { id: inviteId, gemeindeId },
});
if (!invite) {
throw new NotFoundException('Invite not found');
}
const updated = await this.prisma.verantwortlicheInvite.update({
where: { id: inviteId },
data: { revokedAt: new Date() },
});
await this.sync.capture('VerantwortlicheInvite', SyncOperation.UPDATE, updated.id, updated);
return updated;
}
/// Redeems an LT-issued invite: provisions/updates the caller's Authentik
/// User and grants an immediately-ACTIVE GEMEINDE_VERANTWORTLICHER
/// membership (no approval step, unlike self-registration).
async redeemInvite(token: string | undefined, inviteToken: string) {
if (!token) {
throw new UnauthorizedException('Missing Authentik bearer token');
}
const invite = await this.prisma.verantwortlicheInvite.findUnique({
where: { token: inviteToken },
});
if (!invite || invite.revokedAt) {
throw new NotFoundException('Unknown or revoked invite');
}
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('Invite has expired');
}
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
throw new BadRequestException('Invite has already been used up');
}
const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token);
if (invite.email && invite.email !== claims.email.toLowerCase()) {
throw new BadRequestException('This invite is pinned to a different account');
}
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
claims,
isLeitungsteam,
);
const existing = await this.prisma.membership.findUnique({
where: {
userId_kcId_gemeindeId: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
},
},
});
const membership = existing
? await this.prisma.membership.update({
where: { id: existing.id },
data: { status: MembershipStatus.ACTIVE, role: Role.GEMEINDE_VERANTWORTLICHER },
})
: await this.prisma.membership.create({
data: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
role: Role.GEMEINDE_VERANTWORTLICHER,
status: MembershipStatus.ACTIVE,
},
});
await this.sync.capture(
'Membership',
existing ? SyncOperation.UPDATE : SyncOperation.CREATE,
membership.id,
membership,
);
const updatedInvite = await this.prisma.verantwortlicheInvite.update({
where: { id: invite.id },
data: { usedCount: { increment: 1 } },
});
await this.sync.capture(
'VerantwortlicheInvite',
SyncOperation.UPDATE,
updatedInvite.id,
updatedInvite,
);
return { membershipId: membership.id, status: membership.status };
}
} }
+1
View File
@@ -9,6 +9,7 @@ const SYNCED_MODELS = [
'User', 'User',
'Membership', 'Membership',
'TeamerInvite', 'TeamerInvite',
'VerantwortlicheInvite',
'GuestAccount', 'GuestAccount',
'Wahl', 'Wahl',
'Workshop', 'Workshop',
+1 -1
View File
@@ -172,7 +172,7 @@ export class WahlService {
throw new ForbiddenException('Wahl is closed'); throw new ForbiddenException('Wahl is closed');
} }
const teilnehmer = await this.prisma.teilnehmer.upsert({ const teilnehmer = await this.prisma.teilnehmer.upsert({
where: { wahlId_guestAccountId: { wahlId, guestAccountId } }, where: { wahlId_guestAccountId_phase: { wahlId, guestAccountId, phase: 1 } },
create: { wahlId, guestAccountId, prioritaeten }, create: { wahlId, guestAccountId, prioritaeten },
update: { prioritaeten }, update: { prioritaeten },
}); });