feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes
- 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>
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
# --- 1. Backend build ---------------------------------------------------------
|
||||
FROM node:20-bookworm-slim AS api-build
|
||||
WORKDIR /src
|
||||
# Prisma detects the OpenSSL version at `generate` time to pick the matching
|
||||
# query engine binary; without OpenSSL present here it silently defaults to
|
||||
# openssl-1.1.x, which then fails to load in the runtime stage (openssl 3.0.x).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY backend/ ./
|
||||
|
||||
+4
-2
@@ -7,6 +7,8 @@ services:
|
||||
POSTGRES_DB: kcapp
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"]
|
||||
interval: 5s
|
||||
@@ -29,9 +31,9 @@ services:
|
||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public
|
||||
PORT: "3000"
|
||||
GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json
|
||||
APP_BASE_URL: http://localhost:3000
|
||||
APP_BASE_URL: http://localhost:3010
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3010:3000"
|
||||
volumes:
|
||||
# Firebase service account — kept out of the image, mounted read-only.
|
||||
- ./backend/serviceAccount.json:/app/serviceAccount.json:ro
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Teilnehmer_wahlId_guestAccountId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Wahl" ADD COLUMN "beschreibung" TEXT,
|
||||
ADD COLUMN "phasenAnzahl" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Workshop" ADD COLUMN "beschreibung" TEXT,
|
||||
ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Teilnehmer" ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerantwortlicheInvite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kcId" TEXT NOT NULL,
|
||||
"gemeindeId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"maxUses" INTEGER,
|
||||
"usedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdByUserId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "VerantwortlicheInvite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerantwortlicheInvite_token_key" ON "VerantwortlicheInvite"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_phase_key" ON "Teilnehmer"("wahlId", "guestAccountId", "phase");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
+40
-2
@@ -24,6 +24,7 @@ model Kc {
|
||||
guests GuestAccount[]
|
||||
localUsers User[]
|
||||
teamerInvites TeamerInvite[]
|
||||
verantwortlicheInvites VerantwortlicheInvite[]
|
||||
}
|
||||
|
||||
/// A local congregation/community participating in one Kc.
|
||||
@@ -37,6 +38,7 @@ model Gemeinde {
|
||||
memberships Membership[]
|
||||
guests GuestAccount[]
|
||||
teamerInvites TeamerInvite[]
|
||||
verantwortlicheInvites VerantwortlicheInvite[]
|
||||
|
||||
@@unique([kcId, name])
|
||||
}
|
||||
@@ -152,13 +154,42 @@ model TeamerInvite {
|
||||
gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
/// Invitation issued by a Leitungsteam member so a person can register as
|
||||
/// Gemeinde Verantwortliche/r for a specific Gemeinde via their
|
||||
/// Konfi-Castle-ID (Authentik) — skips the self-registration approval step
|
||||
/// since a Leitungsteam member is vouching for them directly. A group link
|
||||
/// leaves `email` null and may be redeemed up to `maxUses` times (null =
|
||||
/// unlimited); a personal invite pins `email` and defaults to a single use.
|
||||
model VerantwortlicheInvite {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
gemeindeId String
|
||||
token String @unique
|
||||
email String?
|
||||
maxUses Int?
|
||||
usedCount Int @default(0)
|
||||
expiresAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdByUserId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
|
||||
/// `phasenAnzahl` mirrors the WP plugin's `anzahl_einheiten`: a Wahl can run
|
||||
/// several independent phases (e.g. morning/afternoon), each with its own
|
||||
/// workshops, its own guest submission, and its own assignment run — a guest
|
||||
/// submits once per phase, not once for the whole Wahl.
|
||||
model Wahl {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
name String
|
||||
datumsSchluessel String
|
||||
teil String
|
||||
beschreibung String?
|
||||
phasenAnzahl Int @default(1)
|
||||
isOpen Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -168,10 +199,14 @@ model Wahl {
|
||||
forceZuteilungen ForceZuteilung[]
|
||||
}
|
||||
|
||||
/// A workshop offered in one phase of a Wahl. `phase` is 1-based and must be
|
||||
/// <= the owning Wahl's `phasenAnzahl`.
|
||||
model Workshop {
|
||||
id String @id @default(cuid())
|
||||
wahlId String
|
||||
phase Int @default(1)
|
||||
name String
|
||||
beschreibung String?
|
||||
kapazitaet Int
|
||||
minTeilnehmer Int @default(0)
|
||||
|
||||
@@ -180,10 +215,13 @@ model Workshop {
|
||||
forceZuteilungen ForceZuteilung[]
|
||||
}
|
||||
|
||||
/// A participant's submitted choices for a Wahl.
|
||||
/// A participant's submitted choices for one phase of a Wahl. A guest submits
|
||||
/// separately per phase (matching the WP plugin), so the same guest can have
|
||||
/// one row per (wahlId, phase).
|
||||
model Teilnehmer {
|
||||
id String @id @default(cuid())
|
||||
wahlId String
|
||||
phase Int @default(1)
|
||||
guestAccountId String
|
||||
prioritaeten Json
|
||||
createdAt DateTime @default(now())
|
||||
@@ -193,7 +231,7 @@ model Teilnehmer {
|
||||
zuteilung Zuteilung?
|
||||
forceZuteilung ForceZuteilung?
|
||||
|
||||
@@unique([wahlId, guestAccountId])
|
||||
@@unique([wahlId, guestAccountId, phase])
|
||||
}
|
||||
|
||||
/// Manual override set by LT before running the assignment algorithm; takes precedence.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { GuestAuthService } from './guest-auth.service';
|
||||
import { TeamAuthService } from './team-auth.service';
|
||||
@@ -49,10 +49,14 @@ export class AuthController {
|
||||
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
|
||||
}
|
||||
|
||||
/// Password login for local Gemeinde Teamer accounts.
|
||||
/// Password login for local Gemeinde Teamer accounts — by Gemeinde name
|
||||
/// (the normal path) or by email (legacy/personal accounts).
|
||||
@Post('team-login')
|
||||
teamLogin(@Body() dto: TeamLoginDto) {
|
||||
return this.teamAuth.login(dto.email, dto.password);
|
||||
if (!dto.email && !dto.gemeindeName) {
|
||||
throw new BadRequestException('email or gemeindeName is required');
|
||||
}
|
||||
return this.teamAuth.login({ email: dto.email, gemeindeName: dto.gemeindeName }, dto.password);
|
||||
}
|
||||
|
||||
/// Self-registration for a Gemeinde Teamer via an invite token/link.
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/// Team login accepts EITHER an email (legacy/personal Verantwortliche
|
||||
/// accounts) OR a Gemeinde name (the normal Teamer login path, since a
|
||||
/// Teamer thinks of their login as "meine Gemeinde", not their email).
|
||||
/// At least one of email/gemeindeName is required; enforced in the
|
||||
/// controller rather than a custom validator to keep this DTO simple.
|
||||
export class TeamLoginDto {
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
gemeindeName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -20,6 +20,11 @@ export class GuestAuthService {
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
/// Redeems a KC invite code for a guest/Konfi session. If a guest account
|
||||
/// with the same (trimmed, case-insensitive) name already exists for this
|
||||
/// KC, reuses it instead of creating a new one — this is what lets a Konfi
|
||||
/// "log back in" with the same code + name and keep their chat history /
|
||||
/// Workshop-Wahl submission instead of losing it to a fresh blank account.
|
||||
async createGuest(
|
||||
inviteCode: string,
|
||||
firstName: string,
|
||||
@@ -30,10 +35,23 @@ export class GuestAuthService {
|
||||
throw new NotFoundException('Unknown or inactive KC invite code');
|
||||
}
|
||||
|
||||
const guest = await this.prisma.guestAccount.create({
|
||||
data: { kcId: kc.id, firstName, lastName },
|
||||
const trimmedFirst = firstName.trim();
|
||||
const trimmedLast = lastName.trim();
|
||||
|
||||
let guest = await this.prisma.guestAccount.findFirst({
|
||||
where: {
|
||||
kcId: kc.id,
|
||||
firstName: { equals: trimmedFirst, mode: 'insensitive' },
|
||||
lastName: { equals: trimmedLast, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
|
||||
if (!guest) {
|
||||
guest = await this.prisma.guestAccount.create({
|
||||
data: { kcId: kc.id, firstName: trimmedFirst, lastName: trimmedLast },
|
||||
});
|
||||
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
|
||||
}
|
||||
|
||||
const payload: GuestJwtPayload = {
|
||||
guestId: guest.id,
|
||||
|
||||
@@ -28,6 +28,10 @@ interface InviteRow {
|
||||
function makeService(seed: {
|
||||
invites?: InviteRow[];
|
||||
users?: { id: string; email: string; passwordHash: string | null }[];
|
||||
memberships?: {
|
||||
gemeindeName: string;
|
||||
user: { id: string; passwordHash: string | null };
|
||||
}[];
|
||||
}) {
|
||||
const invites = [...(seed.invites ?? [])];
|
||||
const users = [...(seed.users ?? [])].map((u) => ({
|
||||
@@ -39,6 +43,7 @@ function makeService(seed: {
|
||||
memberships: [] as unknown[],
|
||||
...u,
|
||||
}));
|
||||
const memberships = seed.memberships ?? [];
|
||||
|
||||
const prisma = {
|
||||
user: {
|
||||
@@ -64,6 +69,21 @@ function makeService(seed: {
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: `m-1`, ...data }),
|
||||
),
|
||||
findMany: jest.fn(
|
||||
({
|
||||
where,
|
||||
}: {
|
||||
where: { gemeinde: { name: { equals: string; mode: string } } };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
memberships
|
||||
.filter(
|
||||
(m) =>
|
||||
m.gemeindeName.toLowerCase() === where.gemeinde.name.equals.toLowerCase(),
|
||||
)
|
||||
.map((m) => ({ user: m.user })),
|
||||
),
|
||||
),
|
||||
},
|
||||
teamerInvite: {
|
||||
findUnique: jest.fn(({ where }: { where: { token: string } }) =>
|
||||
@@ -195,18 +215,18 @@ describe('TeamAuthService.registerFromInvite', () => {
|
||||
describe('TeamAuthService.login', () => {
|
||||
it('rejects an unknown email', async () => {
|
||||
const { service } = makeService({ users: [] });
|
||||
await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(
|
||||
service.login({ email: 'nobody@example.org' }, 'x'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects a user without a password hash (Authentik-only account)', async () => {
|
||||
const { service } = makeService({
|
||||
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
|
||||
});
|
||||
await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(
|
||||
service.login({ email: 'lt@example.org' }, 'x'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects a wrong password', async () => {
|
||||
@@ -215,18 +235,74 @@ describe('TeamAuthService.login', () => {
|
||||
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
|
||||
],
|
||||
});
|
||||
await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
await expect(
|
||||
service.login({ email: 't@example.org' }, 'wrong'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('issues a token for correct credentials', async () => {
|
||||
it('issues a token for correct credentials by email', async () => {
|
||||
const { service } = makeService({
|
||||
users: [
|
||||
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
|
||||
],
|
||||
});
|
||||
const res = await service.login('T@example.org', 'right');
|
||||
const res = await service.login({ email: 'T@example.org' }, 'right');
|
||||
expect(res.accessToken).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('rejects when neither email nor gemeindeName is given', async () => {
|
||||
const { service } = makeService({});
|
||||
await expect(service.login({}, 'x')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects an unknown Gemeinde name', async () => {
|
||||
const { service } = makeService({});
|
||||
await expect(
|
||||
service.login({ gemeindeName: 'Nirgendwo' }, 'x'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('logs in by Gemeinde name, matching case-insensitively and trimmed', async () => {
|
||||
const { service } = makeService({
|
||||
memberships: [
|
||||
{
|
||||
gemeindeName: 'Musterstadt',
|
||||
user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.login({ gemeindeName: ' musterstadt ' }, 'right');
|
||||
expect(res.accessToken).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('tries every Teamer account for a Gemeinde until one password matches', async () => {
|
||||
const { service } = makeService({
|
||||
memberships: [
|
||||
{
|
||||
gemeindeName: 'Musterstadt',
|
||||
user: { id: 'u-1', passwordHash: bcrypt.hashSync('wrong-one', 10) },
|
||||
},
|
||||
{
|
||||
gemeindeName: 'Musterstadt',
|
||||
user: { id: 'u-2', passwordHash: bcrypt.hashSync('right', 10) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.login({ gemeindeName: 'Musterstadt' }, 'right');
|
||||
expect(res.accessToken).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('rejects a Gemeinde login when no account password matches', async () => {
|
||||
const { service } = makeService({
|
||||
memberships: [
|
||||
{
|
||||
gemeindeName: 'Musterstadt',
|
||||
user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) },
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
service.login({ gemeindeName: 'Musterstadt' }, 'wrong'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,21 +38,46 @@ export class TeamAuthService {
|
||||
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<{ accessToken: string }> {
|
||||
/// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal
|
||||
/// path, since a Teamer thinks of their login as "meine Gemeinde" rather
|
||||
/// than an email address. A Gemeinde can have several Teamer accounts, so
|
||||
/// a name lookup tries the password against every active GEMEINDE_TEAMER
|
||||
/// membership for that Gemeinde (case-insensitive, trimmed name) until one
|
||||
/// matches, rather than assuming a 1:1 Gemeinde-to-account mapping.
|
||||
async login(
|
||||
credentials: { email?: string; gemeindeName?: string },
|
||||
password: string,
|
||||
): Promise<{ accessToken: string }> {
|
||||
if (credentials.email) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
include: { memberships: true },
|
||||
where: { email: credentials.email.toLowerCase() },
|
||||
});
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
return { accessToken: this.sign(user.id) };
|
||||
}
|
||||
|
||||
const gemeindeName = credentials.gemeindeName?.trim();
|
||||
if (!gemeindeName) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const memberships = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
role: Role.GEMEINDE_TEAMER,
|
||||
status: 'ACTIVE',
|
||||
gemeinde: { name: { equals: gemeindeName, mode: 'insensitive' } },
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
for (const m of memberships) {
|
||||
if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) {
|
||||
return { accessToken: this.sign(m.user.id) };
|
||||
}
|
||||
}
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
/// Redeems an invite token and creates the local Teamer account + its
|
||||
/// GEMEINDE_TEAMER membership for the invite's Gemeinde.
|
||||
async registerFromInvite(input: {
|
||||
|
||||
@@ -5,16 +5,15 @@ import {
|
||||
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. 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.
|
||||
/// Self-service onboarding for Gemeinde Verantwortliche, plus the
|
||||
/// Leitungsteam-initiated shortcut that skips the approval step entirely.
|
||||
@Injectable()
|
||||
export class OnboardingService {
|
||||
constructor(
|
||||
@@ -133,4 +132,145 @@ export class OnboardingService {
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const SYNCED_MODELS = [
|
||||
'User',
|
||||
'Membership',
|
||||
'TeamerInvite',
|
||||
'VerantwortlicheInvite',
|
||||
'GuestAccount',
|
||||
'Wahl',
|
||||
'Workshop',
|
||||
|
||||
@@ -172,7 +172,7 @@ export class WahlService {
|
||||
throw new ForbiddenException('Wahl is closed');
|
||||
}
|
||||
const teilnehmer = await this.prisma.teilnehmer.upsert({
|
||||
where: { wahlId_guestAccountId: { wahlId, guestAccountId } },
|
||||
where: { wahlId_guestAccountId_phase: { wahlId, guestAccountId, phase: 1 } },
|
||||
create: { wahlId, guestAccountId, prioritaeten },
|
||||
update: { prioritaeten },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user