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:
@@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module';
|
||||
import { KcModule } from './kc/kc.module';
|
||||
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||
import { TeamerModule } from './teamer/teamer.module';
|
||||
import { OnboardingModule } from './onboarding/onboarding.module';
|
||||
import { WahlModule } from './wahl/wahl.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
@@ -27,6 +28,7 @@ import { SyncModule } from './sync/sync.module';
|
||||
KcModule,
|
||||
GemeindeModule,
|
||||
TeamerModule,
|
||||
OnboardingModule,
|
||||
WahlModule,
|
||||
FilesModule,
|
||||
ChatModule,
|
||||
|
||||
@@ -43,7 +43,7 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
||||
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { authentikSub: payload.sub },
|
||||
include: { memberships: true },
|
||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not provisioned locally yet');
|
||||
|
||||
@@ -141,7 +141,7 @@ export class TeamAuthService {
|
||||
async resolve(userId: string): Promise<AuthenticatedUser> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, passwordHash: { not: null } },
|
||||
include: { memberships: true },
|
||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Team account no longer exists');
|
||||
|
||||
@@ -25,7 +25,15 @@ export class TokenVerificationService {
|
||||
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
||||
}
|
||||
|
||||
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
||||
/// Verifies an Authentik token's signature and returns its identity claims,
|
||||
/// without requiring a local User to exist yet (used by the onboarding
|
||||
/// self-registration path, which provisions that User).
|
||||
async verifyAuthentikClaims(token: string): Promise<{
|
||||
sub: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}> {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
const kid = decoded?.header.kid;
|
||||
if (!kid) {
|
||||
@@ -35,14 +43,28 @@ export class TokenVerificationService {
|
||||
const payload = jwt.verify(token, key.getPublicKey(), {
|
||||
issuer: this.issuerUrl,
|
||||
algorithms: ['RS256'],
|
||||
}) as jwt.JwtPayload;
|
||||
if (!payload.sub) {
|
||||
throw new UnauthorizedException('Authentik token missing subject');
|
||||
}) as jwt.JwtPayload & {
|
||||
email?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
};
|
||||
if (!payload.sub || !payload.email) {
|
||||
throw new UnauthorizedException('Authentik token missing subject or email');
|
||||
}
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
firstName: payload.given_name ?? '',
|
||||
lastName: payload.family_name ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
||||
const { sub } = await this.verifyAuthentikClaims(token);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { authentikSub: payload.sub },
|
||||
include: { memberships: true },
|
||||
where: { authentikSub: sub },
|
||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not provisioned locally yet');
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class RegisterVerantwortlicheDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
inviteCode!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
gemeindeId!: string;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { RegisterVerantwortlicheDto } from './dto/register-verantwortliche.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
function bearer(header?: string): string | undefined {
|
||||
return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
|
||||
}
|
||||
|
||||
@Controller('onboarding')
|
||||
export class OnboardingController {
|
||||
constructor(private readonly onboarding: OnboardingService) {}
|
||||
|
||||
/// Public lookup: invite code -> KC name + selectable Gemeinden.
|
||||
@Get('kc/:inviteCode')
|
||||
resolveInvite(@Param('inviteCode') inviteCode: string) {
|
||||
return this.onboarding.resolveInvite(inviteCode);
|
||||
}
|
||||
|
||||
/// Self-registration as Gemeinde Verantwortliche/r. Authenticated by the
|
||||
/// caller's raw Authentik bearer token (no local Membership required yet).
|
||||
@Post('verantwortliche')
|
||||
registerVerantwortliche(
|
||||
@Body() dto: RegisterVerantwortlicheDto,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
return this.onboarding.registerVerantwortliche(
|
||||
bearer(authorization),
|
||||
dto.inviteCode,
|
||||
dto.gemeindeId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Leitungsteam: review and act on pending self-registrations.
|
||||
@Get('requests')
|
||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
listRequests(@Query('kcId') kcId: string) {
|
||||
return this.onboarding.listRequests(kcId);
|
||||
}
|
||||
|
||||
@Post('requests/:membershipId/approve')
|
||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
approve(@Param('membershipId') membershipId: string) {
|
||||
return this.onboarding.approve(membershipId);
|
||||
}
|
||||
|
||||
@Post('requests/:membershipId/reject')
|
||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
reject(@Param('membershipId') membershipId: string) {
|
||||
return this.onboarding.reject(membershipId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { OnboardingController } from './onboarding.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [OnboardingService],
|
||||
controllers: [OnboardingController],
|
||||
})
|
||||
export class OnboardingModule {}
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { MembershipStatus, Role } from '@prisma/client';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
|
||||
/// Prisma / Sync / TokenVerification faked in memory.
|
||||
|
||||
const CLAIMS = {
|
||||
sub: 'authentik-sub-1',
|
||||
email: 'Vera@example.org',
|
||||
firstName: 'Vera',
|
||||
lastName: 'Wong',
|
||||
};
|
||||
|
||||
function makeService(seed: {
|
||||
kc?: { id: string; name: string; inviteCode: string; isActive: boolean } | null;
|
||||
gemeinde?: { id: string; name: string; kcId: string } | null;
|
||||
user?: { id: string; authentikSub: string } | null;
|
||||
membership?: {
|
||||
id: string;
|
||||
status: MembershipStatus;
|
||||
userId: string;
|
||||
kcId: string;
|
||||
gemeindeId: string;
|
||||
} | null;
|
||||
tokenThrows?: boolean;
|
||||
}) {
|
||||
const state = {
|
||||
membership: seed.membership ?? null,
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
kc: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
seed.kc === undefined
|
||||
? { id: 'kc-1', name: 'KC 2026', inviteCode: 'code-1', isActive: true, gemeinden: [] }
|
||||
: seed.kc,
|
||||
),
|
||||
},
|
||||
gemeinde: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
seed.gemeinde === undefined ? { id: 'gem-1', name: 'Nord', kcId: 'kc-1' } : seed.gemeinde,
|
||||
),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(seed.user ?? null),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: 'u-new', ...data }),
|
||||
),
|
||||
},
|
||||
membership: {
|
||||
findUnique: jest.fn(() => Promise.resolve(state.membership)),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
|
||||
state.membership = { id: 'mem-new', ...data } as never;
|
||||
return Promise.resolve(state.membership);
|
||||
}),
|
||||
update: jest.fn(({ data }: { data: { status: MembershipStatus } }) => {
|
||||
state.membership = { ...state.membership!, ...data };
|
||||
return Promise.resolve(state.membership);
|
||||
}),
|
||||
delete: jest.fn(() => Promise.resolve(state.membership!)),
|
||||
},
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const tokens = {
|
||||
verifyAuthentikClaims: seed.tokenThrows
|
||||
? jest.fn().mockRejectedValue(new UnauthorizedException('bad token'))
|
||||
: jest.fn().mockResolvedValue(CLAIMS),
|
||||
};
|
||||
|
||||
const service = new OnboardingService(prisma as never, sync as never, tokens as never);
|
||||
return { service, prisma, sync, tokens };
|
||||
}
|
||||
|
||||
describe('OnboardingService.resolveInvite', () => {
|
||||
it('404s an unknown code', async () => {
|
||||
const { service } = makeService({ kc: null });
|
||||
await expect(service.resolveInvite('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('404s an inactive KC', async () => {
|
||||
const { service } = makeService({
|
||||
kc: { id: 'kc-1', name: 'KC', inviteCode: 'c', isActive: false },
|
||||
});
|
||||
await expect(service.resolveInvite('c')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns the KC name and its Gemeinden', async () => {
|
||||
const { service, prisma } = makeService({});
|
||||
prisma.kc.findUnique = jest.fn().mockResolvedValue({
|
||||
id: 'kc-1',
|
||||
name: 'KC 2026',
|
||||
isActive: true,
|
||||
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
|
||||
});
|
||||
await expect(service.resolveInvite('code-1')).resolves.toEqual({
|
||||
kcId: 'kc-1',
|
||||
kcName: 'KC 2026',
|
||||
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OnboardingService.registerVerantwortliche', () => {
|
||||
it('rejects a missing token', async () => {
|
||||
const { service } = makeService({});
|
||||
await expect(
|
||||
service.registerVerantwortliche(undefined, 'code-1', 'gem-1'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('propagates an invalid token', async () => {
|
||||
const { service } = makeService({ tokenThrows: true });
|
||||
await expect(
|
||||
service.registerVerantwortliche('t', 'code-1', 'gem-1'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('404s an unknown invite code', async () => {
|
||||
const { service } = makeService({ kc: null });
|
||||
await expect(
|
||||
service.registerVerantwortliche('t', 'bad', 'gem-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('400s when the Gemeinde is not part of the KC', async () => {
|
||||
const { service } = makeService({ gemeinde: { id: 'gem-9', name: 'X', kcId: 'other-kc' } });
|
||||
await expect(
|
||||
service.registerVerantwortliche('t', 'code-1', 'gem-9'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('provisions the user and creates a PENDING membership', async () => {
|
||||
const { service, prisma, sync } = makeService({ user: null, membership: null });
|
||||
const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1');
|
||||
|
||||
expect(prisma.user.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
authentikSub: 'authentik-sub-1',
|
||||
email: 'vera@example.org',
|
||||
}),
|
||||
});
|
||||
expect(prisma.membership.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
role: Role.GEMEINDE_VERANTWORTLICHER,
|
||||
status: MembershipStatus.PENDING,
|
||||
gemeindeId: 'gem-1',
|
||||
}),
|
||||
});
|
||||
expect(res).toMatchObject({ status: MembershipStatus.PENDING, kcName: 'KC 2026' });
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything());
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything());
|
||||
});
|
||||
|
||||
it('does not re-create the user when one already exists', async () => {
|
||||
const { service, prisma } = makeService({
|
||||
user: { id: 'u-1', authentikSub: 'authentik-sub-1' },
|
||||
membership: null,
|
||||
});
|
||||
await service.registerVerantwortliche('t', 'code-1', 'gem-1');
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(prisma.membership.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the existing membership status without creating a second one', async () => {
|
||||
const { service, prisma } = makeService({
|
||||
user: { id: 'u-1', authentikSub: 'authentik-sub-1' },
|
||||
membership: {
|
||||
id: 'mem-1',
|
||||
status: MembershipStatus.ACTIVE,
|
||||
userId: 'u-1',
|
||||
kcId: 'kc-1',
|
||||
gemeindeId: 'gem-1',
|
||||
},
|
||||
});
|
||||
const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1');
|
||||
expect(res).toMatchObject({ membershipId: 'mem-1', status: MembershipStatus.ACTIVE });
|
||||
expect(prisma.membership.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OnboardingService.approve / reject', () => {
|
||||
const pending = {
|
||||
id: 'mem-1',
|
||||
status: MembershipStatus.PENDING,
|
||||
userId: 'u-1',
|
||||
kcId: 'kc-1',
|
||||
gemeindeId: 'gem-1',
|
||||
};
|
||||
|
||||
it('404s approving an unknown request', async () => {
|
||||
const { service } = makeService({ membership: null });
|
||||
await expect(service.approve('mem-x')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('400s approving a non-pending request', async () => {
|
||||
const { service } = makeService({
|
||||
membership: { ...pending, status: MembershipStatus.ACTIVE },
|
||||
});
|
||||
await expect(service.approve('mem-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('flips the status to ACTIVE and captures the update', async () => {
|
||||
const { service, prisma, sync } = makeService({ membership: { ...pending } });
|
||||
await service.approve('mem-1');
|
||||
expect(prisma.membership.update).toHaveBeenCalledWith({
|
||||
where: { id: 'mem-1' },
|
||||
data: { status: MembershipStatus.ACTIVE },
|
||||
});
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'UPDATE', 'mem-1', expect.anything());
|
||||
});
|
||||
|
||||
it('deletes on reject and captures the delete', async () => {
|
||||
const { service, prisma, sync } = makeService({ membership: { ...pending } });
|
||||
const res = await service.reject('mem-1');
|
||||
expect(res).toEqual({ id: 'mem-1' });
|
||||
expect(prisma.membership.delete).toHaveBeenCalledWith({ where: { id: 'mem-1' } });
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'DELETE', 'mem-1', { id: 'mem-1' });
|
||||
});
|
||||
});
|
||||
@@ -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