import { ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { Role, SyncOperation } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { MailService } from '../mail/mail.service'; import { AuthenticatedUser } from '../auth/authenticated-request'; import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto'; const BCRYPT_ROUNDS = 10; type PublicUser = { id: string; email: string; firstName: string; lastName: string; createdAt: Date; }; /// Management of local Gemeinde Teamer accounts and their invites. Callable by /// the Leitungsteam (any Gemeinde) or by a Gemeinde Verantwortliche/r for /// their own Gemeinde only. @Injectable() export class TeamerService { constructor( private readonly prisma: PrismaClient, private readonly sync: SyncService, private readonly mail: MailService, ) {} async createTeamer( caller: AuthenticatedUser, gemeindeId: string, input: { firstName: string; lastName: string; email: string; password: string }, ): Promise { const gemeinde = await this.assertCanManage(caller, gemeindeId); const email = input.email.toLowerCase(); if (await this.prisma.user.findUnique({ where: { email } })) { throw new ConflictException('An account with this email already exists'); } const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS); const user = await this.prisma.user.create({ data: { email, firstName: input.firstName, lastName: input.lastName, passwordHash, kcId: gemeinde.kcId, }, }); const membership = await this.prisma.membership.create({ data: { userId: user.id, kcId: gemeinde.kcId, gemeindeId, role: Role.GEMEINDE_TEAMER, }, }); await this.sync.capture('User', SyncOperation.CREATE, user.id, user); await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership); return toPublicUser(user); } async listTeamer(caller: AuthenticatedUser, gemeindeId: string): Promise { await this.assertCanManage(caller, gemeindeId); const memberships = await this.prisma.membership.findMany({ where: { gemeindeId, role: Role.GEMEINDE_TEAMER }, include: { user: true }, orderBy: { user: { lastName: 'asc' } }, }); return memberships.map((m) => toPublicUser(m.user)); } async removeTeamer( caller: AuthenticatedUser, gemeindeId: string, userId: string, ): Promise<{ id: string }> { await this.assertCanManage(caller, gemeindeId); const membership = await this.prisma.membership.findFirst({ where: { userId, gemeindeId, role: Role.GEMEINDE_TEAMER }, include: { user: true }, }); if (!membership || !membership.user.passwordHash) { throw new NotFoundException('No local Teamer account for this Gemeinde'); } await this.prisma.user.delete({ where: { id: userId } }); await this.sync.capture('User', SyncOperation.DELETE, userId, { id: userId }); return { id: userId }; } async createInvite( caller: AuthenticatedUser, gemeindeId: string, dto: CreateTeamerInviteDto, ) { const gemeinde = await this.assertCanManage(caller, gemeindeId); 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.teamerInvite.create({ data: { kcId: gemeinde.kcId, gemeindeId, token: randomBytes(24).toString('base64url'), email, maxUses, expiresAt, createdByUserId: caller.userId, }, }); await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite); // Personal invites go out by email (best-effort); group links are shared // by the Verantwortliche/r directly. let emailSent = false; if (email) { const kc = await this.prisma.kc.findUnique({ where: { id: gemeinde.kcId }, select: { name: true }, }); emailSent = await this.mail.sendTeamerInvite({ to: email, kcName: kc?.name ?? '', gemeindeName: gemeinde.name, token: invite.token, expiresAt: invite.expiresAt, }); } return { ...invite, emailSent }; } async listInvites(caller: AuthenticatedUser, gemeindeId: string) { await this.assertCanManage(caller, gemeindeId); return this.prisma.teamerInvite.findMany({ where: { gemeindeId }, orderBy: { createdAt: 'desc' }, }); } async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) { await this.assertCanManage(caller, gemeindeId); const invite = await this.prisma.teamerInvite.findFirst({ where: { id: inviteId, gemeindeId }, }); if (!invite) { throw new NotFoundException('Invite not found'); } const updated = await this.prisma.teamerInvite.update({ where: { id: inviteId }, data: { revokedAt: new Date() }, }); await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updated.id, updated); return updated; } /// LT may manage every Gemeinde; a Verantwortliche/r only the one they hold /// that role for. Returns the Gemeinde (for its kcId) on success. private async assertCanManage(caller: AuthenticatedUser, gemeindeId: string) { const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } }); if (!gemeinde) { throw new NotFoundException('Gemeinde not found'); } const isLeitungsteam = caller.memberships.some( (m) => m.role === Role.LEITUNGSTEAM, ); const isVerantwortlich = caller.memberships.some( (m) => m.role === Role.GEMEINDE_VERANTWORTLICHER && m.gemeindeId === gemeindeId, ); if (!isLeitungsteam && !isVerantwortlich) { throw new ForbiddenException('Not responsible for this Gemeinde'); } return gemeinde; } } function toPublicUser(user: { id: string; email: string; firstName: string; lastName: string; createdAt: Date; }): PublicUser { return { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, createdAt: user.createdAt, }; }