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
+145 -5
View File
@@ -5,16 +5,15 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
import { randomBytes } from 'crypto';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { TokenVerificationService } from '../auth/token-verification.service';
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
/// 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.
/// Self-service onboarding for Gemeinde Verantwortliche, plus the
/// Leitungsteam-initiated shortcut that skips the approval step entirely.
@Injectable()
export class OnboardingService {
constructor(
@@ -133,4 +132,145 @@ export class OnboardingService {
) {
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 };
}
}