Files
KC-APP-Server/src/teamer/teamer.service.ts
T
linusandClaude Sonnet 5 d48c07b0e4 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>
2026-09-09 16:50:06 +02:00

183 lines
5.8 KiB
TypeScript

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 { 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,
) {}
async createTeamer(
caller: AuthenticatedUser,
gemeindeId: string,
input: { firstName: string; lastName: string; email: string; password: string },
): Promise<PublicUser> {
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<PublicUser[]> {
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);
return invite;
}
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,
};
}