feat: client monorepo (Flutter app) + web fallback redesign #1
+13
-3
@@ -59,6 +59,15 @@ client's host - no separate web server is needed.
|
|||||||
or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`,
|
or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`,
|
||||||
since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept
|
since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept
|
||||||
`'team'` tokens too, so Teamer see non-Konfi files and chat.
|
`'team'` tokens too, so Teamer see non-Konfi files and chat.
|
||||||
|
- `onboarding/` — self-registration for Gemeinde Verantwortliche.
|
||||||
|
`GET /onboarding/kc/:inviteCode` (public) returns the KC name + its
|
||||||
|
Gemeinden to pick from. `POST /onboarding/verantwortliche` takes the
|
||||||
|
caller's raw Authentik bearer token (no local `Membership` needed yet),
|
||||||
|
JIT-provisions the local `User` from the token claims, and creates a
|
||||||
|
`Membership` with `status = PENDING`. Leitungsteam reviews via
|
||||||
|
`GET /onboarding/requests?kcId=` and `POST /onboarding/requests/:id/approve`
|
||||||
|
or `.../reject`. Auth strategies only load `ACTIVE` memberships, so a
|
||||||
|
pending request grants nothing until approved.
|
||||||
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
|
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
|
||||||
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
||||||
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
|
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
|
||||||
@@ -96,7 +105,8 @@ client's host - no separate web server is needed.
|
|||||||
Leitungsteam roles are global across all KCs).
|
Leitungsteam roles are global across all KCs).
|
||||||
|
|
||||||
All planned backend phases are implemented. `npm test` runs Jest unit tests
|
All planned backend phases are implemented. `npm test` runs Jest unit tests
|
||||||
(`ZuteilungService`, `TeamAuthService`, `TeamerService`; Prisma mocked).
|
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`;
|
||||||
Remaining work: the Flutter clients (see repo root README), Authentik
|
Prisma mocked). Remaining work: the Flutter clients (see repo root README),
|
||||||
provisioning for LT/Verantwortliche, and the first real Prisma migration
|
Authentik JIT provisioning for LT (Verantwortliche already self-provision via
|
||||||
|
`onboarding/`), invite email delivery, and the first real Prisma migration
|
||||||
(only `schema.prisma` exists so far).
|
(only `schema.prisma` exists so far).
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ enum Role {
|
|||||||
GEMEINDE_TEAMER
|
GEMEINDE_TEAMER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PENDING memberships come from self-registration and grant no rights until
|
||||||
|
/// a Leitungsteam member approves them. Everything created by LT/Verantwortliche
|
||||||
|
/// directly is ACTIVE from the start.
|
||||||
|
enum MembershipStatus {
|
||||||
|
ACTIVE
|
||||||
|
PENDING
|
||||||
|
}
|
||||||
|
|
||||||
/// A team member account. Leitungsteam and Gemeinde Verantwortliche are
|
/// A team member account. Leitungsteam and Gemeinde Verantwortliche are
|
||||||
/// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde
|
/// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde
|
||||||
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
|
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
|
||||||
@@ -70,12 +78,13 @@ model User {
|
|||||||
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||||
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
|
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
|
||||||
model Membership {
|
model Membership {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
kcId String
|
kcId String
|
||||||
gemeindeId String?
|
gemeindeId String?
|
||||||
role Role
|
role Role
|
||||||
createdAt DateTime @default(now())
|
status MembershipStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { KcModule } from './kc/kc.module';
|
import { KcModule } from './kc/kc.module';
|
||||||
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||||
import { TeamerModule } from './teamer/teamer.module';
|
import { TeamerModule } from './teamer/teamer.module';
|
||||||
|
import { OnboardingModule } from './onboarding/onboarding.module';
|
||||||
import { WahlModule } from './wahl/wahl.module';
|
import { WahlModule } from './wahl/wahl.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { ChatModule } from './chat/chat.module';
|
import { ChatModule } from './chat/chat.module';
|
||||||
@@ -27,6 +28,7 @@ import { SyncModule } from './sync/sync.module';
|
|||||||
KcModule,
|
KcModule,
|
||||||
GemeindeModule,
|
GemeindeModule,
|
||||||
TeamerModule,
|
TeamerModule,
|
||||||
|
OnboardingModule,
|
||||||
WahlModule,
|
WahlModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
ChatModule,
|
ChatModule,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
|||||||
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { authentikSub: payload.sub },
|
where: { authentikSub: payload.sub },
|
||||||
include: { memberships: true },
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('User not provisioned locally yet');
|
throw new UnauthorizedException('User not provisioned locally yet');
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export class TeamAuthService {
|
|||||||
async resolve(userId: string): Promise<AuthenticatedUser> {
|
async resolve(userId: string): Promise<AuthenticatedUser> {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id: userId, passwordHash: { not: null } },
|
where: { id: userId, passwordHash: { not: null } },
|
||||||
include: { memberships: true },
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Team account no longer exists');
|
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 });
|
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 decoded = jwt.decode(token, { complete: true });
|
||||||
const kid = decoded?.header.kid;
|
const kid = decoded?.header.kid;
|
||||||
if (!kid) {
|
if (!kid) {
|
||||||
@@ -35,14 +43,28 @@ export class TokenVerificationService {
|
|||||||
const payload = jwt.verify(token, key.getPublicKey(), {
|
const payload = jwt.verify(token, key.getPublicKey(), {
|
||||||
issuer: this.issuerUrl,
|
issuer: this.issuerUrl,
|
||||||
algorithms: ['RS256'],
|
algorithms: ['RS256'],
|
||||||
}) as jwt.JwtPayload;
|
}) as jwt.JwtPayload & {
|
||||||
if (!payload.sub) {
|
email?: string;
|
||||||
throw new UnauthorizedException('Authentik token missing subject');
|
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({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { authentikSub: payload.sub },
|
where: { authentikSub: sub },
|
||||||
include: { memberships: true },
|
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('User not provisioned locally yet');
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T
|
|||||||
- **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer.
|
- **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer.
|
||||||
- **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Lokaler Account.
|
- **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Lokaler Account.
|
||||||
- **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events).
|
- **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events).
|
||||||
- **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global.
|
- **Membership**: verknüpft `User` ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). LT-Memberships lassen `gemeindeId` leer und gelten global. `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships.
|
||||||
- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab als Gemeinde Verantwortlicher/Teamer einer Gemeinde registrieren.
|
- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab registrieren:
|
||||||
|
- **Gemeinde Verantwortliche/r**: `onboarding/`-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → `User` wird JIT angelegt, `Membership` als `PENDING`; LT genehmigt.
|
||||||
|
- **Gemeinde Teamer**: `teamer/`-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort `ACTIVE`).
|
||||||
- **Wahl** (Workshop-Wahl): von LT pro KC angelegt; Name trägt `datumsSchluessel` + `teil` (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" *innerhalb* einer Wahl via `Teilnehmer.phase`; hier ist stattdessen **eine Wahl = ein Teil/Phase**, gemäß expliziter Nutzer-Klarstellung).
|
- **Wahl** (Workshop-Wahl): von LT pro KC angelegt; Name trägt `datumsSchluessel` + `teil` (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" *innerhalb* einer Wahl via `Teilnehmer.phase`; hier ist stattdessen **eine Wahl = ein Teil/Phase**, gemäß expliziter Nutzer-Klarstellung).
|
||||||
- **Workshop**: `kapazitaet`, `minTeilnehmer` (für Konsolidierung unterbesetzter Workshops).
|
- **Workshop**: `kapazitaet`, `minTeilnehmer` (für Konsolidierung unterbesetzter Workshops).
|
||||||
- **Teilnehmer**: Guest übermittelt `prioritaeten` (geordnete Workshop-ID-Liste, max. 3 – entspricht wunsch1..wunsch3 im Original).
|
- **Teilnehmer**: Guest übermittelt `prioritaeten` (geordnete Workshop-ID-Liste, max. 3 – entspricht wunsch1..wunsch3 im Original).
|
||||||
@@ -50,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
|||||||
- **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
|
- **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
|
||||||
- **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit).
|
- **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit).
|
||||||
- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt.
|
- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt.
|
||||||
- **Authentik-Provisionierung (LT + Gemeinde Verantwortliche)**: Diese beiden Rollen laufen über die Konfi-Castle-ID (Authentik). Das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits lokal existiert, bevor er sich einloggen kann — ein automatischer Provisionierungs-/Sync-Pfad aus Authentik heraus fehlt noch. (Gemeinde Teamer brauchen das nicht mehr: seit dieser Session sind sie lokale Accounts, siehe `teamer/` + `auth/team-login`.)
|
- **Authentik-Provisionierung**: Gemeinde Verantwortliche legen ihren lokalen `User` jetzt selbst über `onboarding/` an (JIT aus den Token-Claims, dann `PENDING` bis LT-Freigabe). **LT** dagegen wird von `AuthentikStrategy` noch nicht JIT angelegt — ein LT-`User` muss vor dem ersten Login manuell existieren. (Gemeinde Teamer sind seit dieser Session lokale Accounts, siehe `teamer/` + `auth/team-login`.)
|
||||||
- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
|
- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
|
||||||
- **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden.
|
- **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden.
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
|||||||
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert jetzt Authentik/Team/Guest) | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` |
|
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert jetzt Authentik/Team/Guest) | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` |
|
||||||
| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` |
|
| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` |
|
||||||
| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` |
|
| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` |
|
||||||
|
| `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) |
|
||||||
| `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite; nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` |
|
| `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite; nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` |
|
||||||
| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` |
|
| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` |
|
||||||
| `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` |
|
| `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` |
|
||||||
@@ -107,10 +110,11 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
|
|||||||
1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
|
1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
|
||||||
2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
|
2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
|
||||||
3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token).
|
3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token).
|
||||||
4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 32 Tests):
|
4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 46 Tests):
|
||||||
- `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
|
- `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
|
||||||
- `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
|
- `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
|
||||||
- `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung.
|
- `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung.
|
||||||
|
- `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject.
|
||||||
5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
|
5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -118,7 +122,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
|
|||||||
## 8. Nächste Schritte
|
## 8. Nächste Schritte
|
||||||
|
|
||||||
1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API.
|
1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API.
|
||||||
2. Authentik-Provisionierung für LT + Gemeinde Verantwortliche automatisieren (JIT-Anlage des lokalen `User` beim ersten Login aus den Token-Claims, oder Sync aus der Authentik-Admin-API).
|
2. Authentik-JIT für **LT** vervollständigen: `AuthentikStrategy` legt den lokalen `User` beim ersten Login noch nicht selbst an (nur der `onboarding/`-Pfad für Verantwortliche tut das). LT bleibt bis dahin auf manuelle `User`-Anlage angewiesen.
|
||||||
3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt.
|
3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt.
|
||||||
4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen.
|
4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen.
|
||||||
5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen.
|
5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen.
|
||||||
|
|||||||
Reference in New Issue
Block a user