feat(backend): self-registration for Gemeinde Verantwortliche
New onboarding/ module. A prospective Verantwortliche/r signs in with their
Konfi-Castle-ID (Authentik), looks up a KC by invite code, picks an existing
Gemeinde, and registers:
- GET /api/onboarding/kc/:inviteCode -> KC name + its Gemeinden (public;
the invite code is the shared secret)
- POST /api/onboarding/verantwortliche -> verifies the raw Authentik bearer
token's claims (no local Membership required yet via new
TokenVerificationService.verifyAuthentikClaims), JIT-provisions the local
User, and creates a Membership with status PENDING. Idempotent per
(user, kc, gemeinde).
- GET /api/onboarding/requests?kcId= (LT) list pending
- POST /api/onboarding/requests/:id/approve|reject (LT) approve flips to
ACTIVE, reject deletes.
Schema: Membership gains status (enum MembershipStatus { ACTIVE, PENDING },
default ACTIVE). AuthentikStrategy / TokenVerificationService / TeamAuthService
now load only ACTIVE memberships, so a pending request grants nothing until
approved. Membership create/update/delete flow through the sync log.
Tests: onboarding.service.spec.ts (14 cases); npm test green at 46.
Docs (plan + backend README) updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { TokenVerificationService } from '../auth/token-verification.service';
|
||||
|
||||
/// 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.
|
||||
@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 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 this.upsertUser(claims);
|
||||
|
||||
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 async upsertUser(claims: {
|
||||
sub: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}) {
|
||||
const email = claims.email.toLowerCase();
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { authentikSub: claims.sub },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
authentikSub: claims.sub,
|
||||
email,
|
||||
firstName: claims.firstName,
|
||||
lastName: claims.lastName,
|
||||
},
|
||||
});
|
||||
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private summary(
|
||||
membershipId: string,
|
||||
status: MembershipStatus,
|
||||
kcName: string,
|
||||
gemeindeName: string,
|
||||
) {
|
||||
return { membershipId, status, kcName, gemeindeName };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user