diff --git a/Dockerfile b/Dockerfile index c90356a..8cd3470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ ./ diff --git a/docker-compose.yml b/docker-compose.yml index 31c6633..032ca4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql b/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql new file mode 100644 index 0000000..09fd11c --- /dev/null +++ b/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 782c69f..ee3787d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 1a2e2dd..d0dd059 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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. diff --git a/src/auth/dto/team-login.dto.ts b/src/auth/dto/team-login.dto.ts index e4f31af..87ef525 100644 --- a/src/auth/dto/team-login.dto.ts +++ b/src/auth/dto/team-login.dto.ts @@ -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() diff --git a/src/auth/guest-auth.service.ts b/src/auth/guest-auth.service.ts index fc6eed6..4131c4b 100644 --- a/src/auth/guest-auth.service.ts +++ b/src/auth/guest-auth.service.ts @@ -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' }, + }, }); - await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest); + + 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, diff --git a/src/auth/team-auth.service.spec.ts b/src/auth/team-auth.service.spec.ts index bff85e3..3b17bb7 100644 --- a/src/auth/team-auth.service.spec.ts +++ b/src/auth/team-auth.service.spec.ts @@ -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 }) => 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); + }); }); diff --git a/src/auth/team-auth.service.ts b/src/auth/team-auth.service.ts index 935f781..e703619 100644 --- a/src/auth/team-auth.service.ts +++ b/src/auth/team-auth.service.ts @@ -38,19 +38,44 @@ export class TeamAuthService { this.secret = config.getOrThrow('TEAM_JWT_SECRET'); } - async login(email: string, password: string): Promise<{ accessToken: string }> { - const user = await this.prisma.user.findUnique({ - where: { email: email.toLowerCase() }, - include: { memberships: true }, + /// 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: credentials.email.toLowerCase() }, + }); + 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 }, }); - if (!user || !user.passwordHash) { - throw new UnauthorizedException('Invalid credentials'); + for (const m of memberships) { + if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) { + return { accessToken: this.sign(m.user.id) }; + } } - const ok = await bcrypt.compare(password, user.passwordHash); - if (!ok) { - throw new UnauthorizedException('Invalid credentials'); - } - return { accessToken: this.sign(user.id) }; + throw new UnauthorizedException('Invalid credentials'); } /// Redeems an invite token and creates the local Teamer account + its diff --git a/src/onboarding/onboarding.service.ts b/src/onboarding/onboarding.service.ts index 0a0dad1..87158ff 100644 --- a/src/onboarding/onboarding.service.ts +++ b/src/onboarding/onboarding.service.ts @@ -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 }; + } } diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts index 5e896a3..2fc88d0 100644 --- a/src/sync/sync.service.ts +++ b/src/sync/sync.service.ts @@ -9,6 +9,7 @@ const SYNCED_MODELS = [ 'User', 'Membership', 'TeamerInvite', + 'VerantwortlicheInvite', 'GuestAccount', 'Wahl', 'Workshop', diff --git a/src/wahl/wahl.service.ts b/src/wahl/wahl.service.ts index f1858ae..fe1f0d8 100644 --- a/src/wahl/wahl.service.ts +++ b/src/wahl/wahl.service.ts @@ -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 }, });