import { BadRequestException, Injectable, NotFoundException, 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, plus the /// Leitungsteam-initiated shortcut that skips the approval step entirely. @Injectable() export class OnboardingService { constructor( private readonly prisma: PrismaClient, private readonly sync: SyncService, private readonly tokens: TokenVerificationService, ) {} /// Public: resolves an invite code to the KC name and its Gemeinden so the /// registrant can pick theirs. The code itself is the shared secret. async resolveInvite(inviteCode: string) { const trimmed = inviteCode.trim(); const kc = (await this.prisma.kc.findFirst({ where: { inviteCode: { equals: trimmed, mode: 'insensitive' } }, include: { gemeinden: { select: { id: true, name: true }, orderBy: { name: 'asc' } }, }, })) || (await this.prisma.kc.findUnique({ where: { inviteCode: trimmed }, include: { gemeinden: { select: { id: true, name: true }, orderBy: { name: 'asc' } }, }, })); if (!kc || !kc.isActive) { throw new NotFoundException('Unknown or inactive KC invite code'); } return { kcId: kc.id, kcName: kc.name, gemeinden: kc.gemeinden }; } async registerVerantwortliche(token: string | undefined, inviteCode: string, gemeindeId: string) { if (!token) { throw new UnauthorizedException('Missing Authentik bearer token'); } const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token); const trimmed = inviteCode.trim(); const kc = (await this.prisma.kc.findFirst({ where: { inviteCode: { equals: trimmed, mode: 'insensitive' } }, })) || (await this.prisma.kc.findUnique({ where: { inviteCode: trimmed } })); if (!kc || !kc.isActive) { throw new NotFoundException('Unknown or inactive KC invite code'); } const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } }); if (!gemeinde || gemeinde.kcId !== kc.id) { throw new BadRequestException('Gemeinde does not belong to this KC'); } 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: kc.id, gemeindeId }, }, }); if (existing) { return this.summary(existing.id, existing.status, kc.name, gemeinde.name); } const membership = await this.prisma.membership.create({ data: { userId: user.id, kcId: kc.id, gemeindeId, role: Role.GEMEINDE_VERANTWORTLICHER, status: MembershipStatus.PENDING, }, }); await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership); return this.summary(membership.id, membership.status, kc.name, gemeinde.name); } async listRequests(kcId: string) { return this.prisma.membership.findMany({ where: { kcId, status: MembershipStatus.PENDING, role: Role.GEMEINDE_VERANTWORTLICHER, }, include: { user: { select: { id: true, email: true, firstName: true, lastName: true } }, gemeinde: { select: { id: true, name: true } }, }, orderBy: { createdAt: 'asc' }, }); } async approve(membershipId: string) { await this.getPendingOrThrow(membershipId); const membership = await this.prisma.membership.update({ where: { id: membershipId }, data: { status: MembershipStatus.ACTIVE }, }); await this.sync.capture('Membership', SyncOperation.UPDATE, membership.id, membership); return membership; } async reject(membershipId: string) { await this.getPendingOrThrow(membershipId); const membership = await this.prisma.membership.delete({ where: { id: membershipId } }); await this.sync.capture('Membership', SyncOperation.DELETE, membership.id, { id: membership.id }); return { id: membership.id }; } private async getPendingOrThrow(membershipId: string) { const membership = await this.prisma.membership.findUnique({ where: { id: membershipId } }); if (!membership) { throw new NotFoundException('Request not found'); } if (membership.status !== MembershipStatus.PENDING) { throw new BadRequestException('Request is not pending'); } return membership; } private summary( membershipId: string, status: MembershipStatus, kcName: string, gemeindeName: string, ) { 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 }; } }