feat: client monorepo (Flutter app) + web fallback redesign #1
@@ -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
@@ -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.
|
||||
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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 {}
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -43,6 +43,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
||||
| Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik |
|
||||
| Auth (Gemeinde Teamer) | Lokale Accounts: `User` mit `passwordHash`+`kcId`, `authentikSub` bleibt leer; eigenes JWT (`TEAM_JWT_SECRET`, Payload `typ:'team'`), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) | Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle) |
|
||||
| Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` |
|
||||
| E-Mail | Provider-Abstraktion (`MailProvider`), Default **log-only** (kein Versand), umschaltbar auf SMTP via `MAIL_PROVIDER=smtp` (`nodemailer`) | Spiegelt das Storage-Muster; E-Mail ist best-effort und darf den Invite-Flow nie blockieren |
|
||||
| Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS |
|
||||
| Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist |
|
||||
| Sync-Konflikte | Keine Konfliktauflösung nötig | Nutzer bestätigte explizit: lokaler Server ist während eines laufenden Events alleinige Quelle der Wahrheit |
|
||||
@@ -54,7 +55,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
||||
- **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**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind 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.
|
||||
- **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden.
|
||||
- **E-Mail-Versand**: `mail/`-Modul mit `MailProvider`-Abstraktion. Persönliche `teamer-invites` (mit `email`) werden verschickt; Default-Provider ist **log-only** (schreibt nur ins Log), echter Versand erst mit `MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`. Onboarding-Benachrichtigungen an LT gibt es noch nicht.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,7 +68,8 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
||||
| `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` |
|
||||
| `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 (persönliche Invites werden per `MailService` best-effort verschickt, `emailSent` im Response); 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` |
|
||||
| `mail/` | Globale `MailProvider`-Abstraktion (log-only Default, SMTP via `MAIL_PROVIDER=smtp`); `MailService` baut die Invite-Mail inkl. Link aus `APP_BASE_URL` | – |
|
||||
| `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` |
|
||||
| `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` |
|
||||
@@ -110,10 +112,10 @@ 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).
|
||||
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).
|
||||
4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 55 Tests):
|
||||
4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests):
|
||||
- `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/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, E-Mail-Versand nur bei persönlichem Invite + Best-effort bei Transport-Fehler, 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.
|
||||
- `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims, LT-Flag-Abgleich (rauf/runter) aus dem `groups`-Claim, virtuelle LT-`Membership` in `toAuthenticatedUser`, Race-Recovery (P2002 → Re-Read), Fehler-Weiterreichung.
|
||||
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).
|
||||
@@ -124,6 +126,6 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
|
||||
|
||||
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-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), und die LT-Gruppe auf `AUTHENTIK_LEITUNGSTEAM_GROUP` abstimmen — sonst greift der LT-Abgleich nicht. (Reine Ops-/Config-Aufgabe, Code ist fertig.)
|
||||
3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt.
|
||||
3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und die Invite-Mail-Templates finalisieren (aktuell nur Plain-Text); optional Onboarding-Benachrichtigungen an LT.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user