feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1

Merged
linus merged 28 commits from feat/backend-phases-0-6 into main 2026-09-12 11:26:16 +00:00
12 changed files with 243 additions and 13 deletions
Showing only changes of commit da76f8dc96 - Show all commits
+14
View File
@@ -17,6 +17,20 @@ TEAM_JWT_SECRET="change-me-too"
PORT=3000
# Public base URL of the app, used to build links in outgoing emails.
APP_BASE_URL="http://localhost:3000"
# Email: defaults to "log" (writes what it would send to the log, no
# delivery). Set MAIL_PROVIDER=smtp plus the SMTP_* vars + MAIL_FROM to
# actually send Gemeinde-Teamer invite emails.
MAIL_PROVIDER="log"
MAIL_FROM="KC-App <no-reply@example.org>"
SMTP_HOST="smtp.example.org"
SMTP_PORT=587
SMTP_SECURE="false"
SMTP_USER=""
SMTP_PASS=""
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
# use an S3-compatible bucket instead (see S3_* vars below).
STORAGE_PROVIDER="webdav"
+16 -6
View File
@@ -7,7 +7,8 @@ architecture context).
```bash
npm install
cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / GUEST_JWT_SECRET / TEAM_JWT_SECRET
cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP /
# GUEST_JWT_SECRET / TEAM_JWT_SECRET / APP_BASE_URL (+ MAIL_* for real email)
npx prisma generate
npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev
@@ -64,7 +65,9 @@ client's host - no separate web server is needed.
`DELETE teamer-invites/:inviteId`. Callable by Leitungsteam (any Gemeinde)
or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`,
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. A personal
invite (with `email`) is mailed via `MailService`; the response carries
`emailSent`. Group-link invites (no `email`) are shared by hand.
- `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
@@ -74,6 +77,12 @@ client's host - no separate web server is needed.
`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.
- `mail/` — global `MailProvider` abstraction (mirrors `files/storage/`):
default `log` provider only logs what it would send; `MAIL_PROVIDER=smtp`
uses a real `nodemailer` SMTP transport (`SMTP_*`, `MAIL_FROM`).
`MailService.sendTeamerInvite()` composes the personal-invite email with a
link built from `APP_BASE_URL`. Delivery is best-effort — failures are
logged and swallowed, never blocking the invite.
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
@@ -113,7 +122,8 @@ client's host - no separate web server is needed.
All planned backend phases are implemented. `npm test` runs Jest unit tests
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
Remaining work: the Flutter clients (see repo root README), invite email
delivery, push notifications, and the first real Prisma migration (only
`schema.prisma` exists so far). Ops note: the Authentik provider must emit a
`groups` claim in the access token for the LT check to work.
Remaining work: the Flutter clients (see repo root README), push
notifications, and the first real Prisma migration (only `schema.prisma`
exists so far). Ops notes: the Authentik provider must emit a `groups` claim
for the LT check, and `MAIL_PROVIDER=smtp` + `SMTP_*` must be set for invite
emails to actually leave the box.
+21
View File
@@ -27,6 +27,7 @@
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"multer": "^2.0.1",
"nodemailer": "^7.0.13",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
@@ -44,6 +45,7 @@
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.9",
"@types/nodemailer": "^6.4.24",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
@@ -2985,6 +2987,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.24",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
"integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
@@ -8374,6 +8386,15 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+2
View File
@@ -39,6 +39,7 @@
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"multer": "^2.0.1",
"nodemailer": "^7.0.13",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
@@ -56,6 +57,7 @@
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.9",
"@types/nodemailer": "^6.4.24",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
+2
View File
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module';
import { MailModule } from './mail/mail.module';
import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module';
import { GemeindeModule } from './gemeinde/gemeinde.module';
@@ -23,6 +24,7 @@ import { SyncModule } from './sync/sync.module';
exclude: ['/api*'],
}),
PrismaModule,
MailModule,
SyncModule,
AuthModule,
KcModule,
+15
View File
@@ -0,0 +1,15 @@
import { Logger } from '@nestjs/common';
import { MailMessage, MailProvider } from './mail-provider';
/// Default provider: doesn't send anything, just logs that it would have.
/// Keeps the invite flow working before SMTP is configured.
export class LogMailProvider implements MailProvider {
private readonly logger = new Logger('MailProvider');
async send(message: MailMessage): Promise<boolean> {
this.logger.log(
`[log-only] would send "${message.subject}" to ${message.to}: ${message.text}`,
);
return false;
}
}
+18
View File
@@ -0,0 +1,18 @@
/// Abstraction over the outbound email backend. Default is a no-send provider
/// that only logs (fine for dev and for deployments that don't do email yet);
/// MAIL_PROVIDER=smtp switches to a real SMTP transport.
export interface MailMessage {
to: string;
subject: string;
text: string;
html?: string;
}
export interface MailProvider {
/// Resolves true if the message was handed off to the transport, false if
/// it was dropped (e.g. the log provider). Never throws for delivery
/// problems — callers treat email as best-effort.
send(message: MailMessage): Promise<boolean>;
}
export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
+25
View File
@@ -0,0 +1,25 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MAIL_PROVIDER } from './mail-provider';
import { LogMailProvider } from './log-mail.provider';
import { SmtpMailProvider } from './smtp-mail.provider';
import { MailService } from './mail.service';
/// Global so any feature module can inject MailService. Provider defaults to
/// log-only; MAIL_PROVIDER=smtp switches to a real SMTP transport.
@Global()
@Module({
providers: [
MailService,
{
provide: MAIL_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
config.get<string>('MAIL_PROVIDER') === 'smtp'
? new SmtpMailProvider(config)
: new LogMailProvider(),
},
],
exports: [MailService],
})
export class MailModule {}
+43
View File
@@ -0,0 +1,43 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MAIL_PROVIDER, MailProvider } from './mail-provider';
@Injectable()
export class MailService {
private readonly appBaseUrl: string;
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
config: ConfigService,
) {
this.appBaseUrl = (config.get<string>('APP_BASE_URL') ?? 'http://localhost:3000').replace(
/\/$/,
'',
);
}
/// Sends a personal Gemeinde-Teamer invite. Returns whether it was handed
/// to the transport (false for the log-only provider or on failure).
sendTeamerInvite(opts: {
to: string;
kcName: string;
gemeindeName: string;
token: string;
expiresAt: Date | null;
}): Promise<boolean> {
const link = `${this.appBaseUrl}/?teamerInviteToken=${encodeURIComponent(opts.token)}`;
const expiry = opts.expiresAt
? `\n\nDer Link gilt bis ${opts.expiresAt.toISOString()}.`
: '';
return this.provider.send({
to: opts.to,
subject: `Einladung als Teamer:in ${opts.gemeindeName} (${opts.kcName})`,
text:
`Hallo,\n\ndu wurdest als Teamer:in für die Gemeinde "${opts.gemeindeName}" ` +
`beim ${opts.kcName} eingeladen.\n\n` +
`Konto anlegen: ${link}\n\n` +
`Falls der Link nicht funktioniert, nutze diesen Einladungscode: ${opts.token}` +
`${expiry}\n`,
});
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import { MailMessage, MailProvider } from './mail-provider';
/// SMTP transport (MAIL_PROVIDER=smtp). Delivery failures are logged and
/// swallowed — callers treat email as best-effort.
export class SmtpMailProvider implements MailProvider {
private readonly logger = new Logger('MailProvider');
private readonly from: string;
private readonly transport: nodemailer.Transporter;
constructor(config: ConfigService) {
this.from = config.getOrThrow<string>('MAIL_FROM');
this.transport = nodemailer.createTransport({
host: config.getOrThrow<string>('SMTP_HOST'),
port: Number(config.get<string>('SMTP_PORT') ?? 587),
secure: config.get<string>('SMTP_SECURE') === 'true',
auth: config.get<string>('SMTP_USER')
? {
user: config.getOrThrow<string>('SMTP_USER'),
pass: config.getOrThrow<string>('SMTP_PASS'),
}
: undefined,
});
}
async send(message: MailMessage): Promise<boolean> {
try {
await this.transport.sendMail({
from: this.from,
to: message.to,
subject: message.subject,
text: message.text,
html: message.html,
});
return true;
} catch (err) {
this.logger.error(
`Failed to send "${message.subject}" to ${message.to}: ${(err as Error).message}`,
);
return false;
}
}
}
+22 -6
View File
@@ -31,6 +31,7 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?:
const prisma = {
gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) },
kc: { findUnique: jest.fn().mockResolvedValue({ name: 'KC 2026' }) },
user: {
findUnique: jest.fn(({ where }: { where: { email: string } }) =>
Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null),
@@ -56,8 +57,9 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?:
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const service = new TeamerService(prisma as never, sync as never);
return { service, prisma, sync, created };
const mail = { sendTeamerInvite: jest.fn().mockResolvedValue(true) };
const service = new TeamerService(prisma as never, sync as never, mail as never);
return { service, prisma, sync, mail, created };
}
describe('TeamerService scope check', () => {
@@ -120,20 +122,34 @@ describe('TeamerService.createTeamer', () => {
});
describe('TeamerService.createInvite', () => {
it('defaults a group link to unlimited uses and no expiry', async () => {
const { service } = makeService();
it('defaults a group link to unlimited uses, no expiry, and sends no email', async () => {
const { service, mail } = makeService();
const inv = await service.createInvite(LT, 'gem-1', {});
expect(inv.email).toBeNull();
expect(inv.maxUses).toBeNull();
expect(inv.expiresAt).toBeNull();
expect(inv.token).toEqual(expect.any(String));
expect(inv.emailSent).toBe(false);
expect(mail.sendTeamerInvite).not.toHaveBeenCalled();
});
it('defaults a personal invite to a single use and lowercases the email', async () => {
const { service } = makeService();
it('defaults a personal invite to a single use, lowercases the email, and mails it', async () => {
const { service, mail } = makeService();
const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' });
expect(inv.email).toBe('new@example.org');
expect(inv.maxUses).toBe(1);
expect(inv.emailSent).toBe(true);
expect(mail.sendTeamerInvite).toHaveBeenCalledWith(
expect.objectContaining({ to: 'new@example.org', gemeindeName: 'Nord', kcName: 'KC 2026' }),
);
});
it('still returns the invite when the mail transport drops it', async () => {
const { service, mail } = makeService();
mail.sendTeamerInvite.mockResolvedValueOnce(false);
const inv = await service.createInvite(LT, 'gem-1', { email: 'x@example.org' });
expect(inv.emailSent).toBe(false);
expect(inv.token).toEqual(expect.any(String));
});
it('turns expiresInHours into a concrete expiry', async () => {
+20 -1
View File
@@ -9,6 +9,7 @@ import { Role, SyncOperation } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { MailService } from '../mail/mail.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
@@ -30,6 +31,7 @@ export class TeamerService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
private readonly mail: MailService,
) {}
async createTeamer(
@@ -118,7 +120,24 @@ export class TeamerService {
},
});
await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite);
return invite;
// Personal invites go out by email (best-effort); group links are shared
// by the Verantwortliche/r directly.
let emailSent = false;
if (email) {
const kc = await this.prisma.kc.findUnique({
where: { id: gemeinde.kcId },
select: { name: true },
});
emailSent = await this.mail.sendTeamerInvite({
to: email,
kcName: kc?.name ?? '',
gemeindeName: gemeinde.name,
token: invite.token,
expiresAt: invite.expiresAt,
});
}
return { ...invite, emailSent };
}
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {