- 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>
277 lines
9.4 KiB
TypeScript
277 lines
9.4 KiB
TypeScript
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 kc = await this.prisma.kc.findUnique({
|
|
where: { inviteCode },
|
|
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 kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
|
|
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 };
|
|
}
|
|
}
|