Compare commits

...
10 Commits
Author SHA1 Message Date
linusandClaude Sonnet 5 f42aead5ca build: Docker setup (compose: postgres + all-in-one api image)
- Dockerfile: 3-stage — Flutter web build, NestJS build, slim node runtime.
  Runtime copies dist + node_modules + prisma + the web bundle
  (WEB_CLIENT_DIR=/app/web), runs `prisma migrate deploy` then `node
  dist/main.js`. One container serves client + API on :3000.
- docker-compose.yml: postgres:16-alpine with a healthcheck + the api
  service; config from backend/.env (Compose v2 strips quotes),
  DATABASE_URL + GOOGLE_APPLICATION_CREDENTIALS overridden for the
  container, serviceAccount.json bind-mounted read-only.
- .dockerignore keeps node_modules/build/secrets out of the context.

Not run here (no Docker on this box); the stack also runs natively against
the local Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:08:43 +02:00
linusandClaude Sonnet 5 6662ca80d4 docs: FCM push verified end to end
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:02:39 +02:00
linusandClaude Sonnet 5 97c5807cb2 chore(backend): gitignore serviceAccount.json
FCM push is now verified end to end against the real konfi-castle-app
project: service-account JWT -> OAuth token (200), FCM messages:send
reached and processed (a bogus token gets a 400 INVALID_ARGUMENT and is
pruned from device_token). Real config lives only in the gitignored
backend/.env + backend/serviceAccount.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:02:18 +02:00
linusandClaude Sonnet 5 1c9887e0df chore(client): set FCM web-push VAPID key (public)
Client push-token acquisition is now fully configured; a real browser
session (logged-in user granting notification permission) is needed to
mint the first token. Backend delivery still needs the service-account JSON.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:57:07 +02:00
linusandClaude Sonnet 5 3f46088e9a chore(client): fill in Firebase web config (apiKey / appId)
Only the VAPID key (Web Push certificate) is still REPLACE_ME; the push
guard now keys off vapidKey so nothing prompts until it's set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:54:47 +02:00
linusandClaude Sonnet 5 d8aa30c606 docs: push notifications module + FCM client wiring
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:46:39 +02:00
linusandClaude Sonnet 5 530be36458 feat(client): FCM web push registration
web/index.html loads the Firebase compat SDK and defines
window.kcGetPushToken() — inits Firebase from window.KC_FIREBASE, asks for
notification permission, registers the service worker and returns an FCM
token (or null if not configured / denied). web/firebase-messaging-sw.js
shows background notifications.

browser_web.dart exposes getPushToken() over that JS function (stub returns
null off-web). After every successful login AppState fires
_registerForPush() -> POST /api/push/register, best-effort.

Config placeholders carry the known values (projectId konfi-castle-app,
messagingSenderId 307226979593); apiKey / appId / vapidKey still say
REPLACE_ME, so push stays inert until they're filled in — the app runs
either way. flutter analyze/test/build web green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:45:01 +02:00
linusandClaude Sonnet 5 cc663c7e17 feat(backend): push notifications module (FCM HTTP v1)
New global push/ module mirroring mail/ and files/storage/:
- PushProvider abstraction; default LogPushProvider (no delivery, logs),
  PUSH_PROVIDER=fcm switches to FcmPushProvider — Firebase Cloud Messaging
  HTTP v1, authenticated by a service-account JWT exchanged for an OAuth
  token (no extra dependency; jsonwebtoken does the signing). Prunes tokens
  FCM reports as invalid.
- DeviceToken model (token + platform, bound to a User or GuestAccount),
  migration + added to the sync log.
- POST /api/push/register + /unregister (any of the three token kinds).
- PushService.notifyChannel() resolves a channel's readable audience
  (DIREKT participants / LT / Gemeinde members + guests / whole KC for
  broadcast), looks up their device tokens (minus the sender), sends.
- ChatService.sendMessage() fires it best-effort after persisting.

New env: PUSH_PROVIDER, FCM_PROJECT_ID (default konfi-castle-app),
GOOGLE_APPLICATION_CREDENTIALS.

Verified against local Postgres: register a token, send a Gemeinde-group
chat message from another member -> log-push logs "would push ... to 1
device". Real FCM send needs the service-account JSON. npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:42:34 +02:00
linusandClaude Sonnet 5 55509eccb7 docs: LT Wahl controls, file upload, updated next steps
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:20:07 +02:00
linusandClaude Sonnet 5 a4ae7549ae feat: LT Wahl controls (open/close, Force-Zuteilung, CSV) + file upload
Backend:
- PATCH /api/wahl/:wahlId (isOpen) to open/close a Wahl.
- GET /api/wahl/:wahlId/teilnehmer (LT): participants with their priorities
  and any existing Force-Zuteilung.
- Verified against local Postgres: PATCH toggles isOpen, teilnehmer list
  returns, CSV export works. (A stale dev server on :3000 masked this at
  first — real routes are fine.)

Client (client/app/):
- Wahl detail: open/close switch, participant list with a "Zuteilen"
  (Force-Zuteilung) action, CSV export via a browser download
  (browser.downloadText).
- files_admin_screen.dart: LT file upload — browser.pickFile() +
  visibility picker -> multipart POST /api/files/:kcId; list existing
  files. Reachable from KcDetailScreen.
- browser_web.dart gains pickFile()/downloadText() (native <input file> +
  Blob), with throwing stubs for the VM.
- Dropped the file_picker package again (heavy transitive deps, and the
  native web input is enough); disk on this box is nearly full.

flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:19:35 +02:00
33 changed files with 1065 additions and 33 deletions
+10
View File
@@ -0,0 +1,10 @@
**/node_modules
**/dist
**/build
**/.dart_tool
**/coverage
.git
**/*.log
# Secrets: passed at runtime via env_file / bind mount, never baked in.
backend/.env
backend/serviceAccount.json
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
# --- 1. Flutter web build -------------------------------------------------
FROM ghcr.io/cirruslabs/flutter:stable AS web
WORKDIR /src
COPY client/app/pubspec.yaml client/app/pubspec.lock ./
RUN flutter pub get
COPY client/app/ ./
RUN flutter build web --release
# --- 2. Backend build --------------------------------------------------------
FROM node:20-bookworm-slim AS api-build
WORKDIR /src
COPY backend/package.json backend/package-lock.json ./
RUN npm ci
COPY backend/ ./
RUN npx prisma generate && npm run build
# --- 3. Runtime ------------------------------------------------------------
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Prisma needs OpenSSL at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=api-build /src/node_modules ./node_modules
COPY --from=api-build /src/dist ./dist
COPY --from=api-build /src/prisma ./prisma
# The Flutter web build; app.module reads WEB_CLIENT_DIR.
COPY --from=web /src/build/web ./web
ENV WEB_CLIENT_DIR=/app/web
EXPOSE 3000
# Apply pending migrations, then boot.
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
+30 -11
View File
@@ -5,6 +5,21 @@ events (KCs), replacing the WordPress plugin "Workshop-Wahlen". See
[plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md) [plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md)
for the full architecture and phased roadmap. for the full architecture and phased roadmap.
## Run with Docker
```bash
cp backend/.env.example backend/.env # fill in the secrets
# put the Firebase service account at backend/serviceAccount.json (optional; push)
docker compose up --build
```
`docker-compose.yml` starts PostgreSQL 16 and one `api` container (multi-stage
`Dockerfile`: Flutter web build → NestJS build → slim runtime). The container
runs `prisma migrate deploy` on start and serves the whole app — Flutter web
client + REST API — on <http://localhost:3000>. Requires Docker Compose v2.
Secrets are read from `backend/.env` and the service-account JSON is bind-
mounted read-only; neither is baked into the image.
## Structure ## Structure
- `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource - `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource
@@ -61,17 +76,21 @@ in (`backend/prisma/migrations/`); the backend has been run end to end
against a local PostgreSQL 16. `npm test` covers the assignment algorithm against a local PostgreSQL 16. `npm test` covers the assignment algorithm
and the new auth/onboarding services (56 tests). and the new auth/onboarding services (56 tests).
Phase 7 (Flutter client) in progress: `client/app/` is a single Flutter Phase 7 (Flutter client): `client/app/` is a single Flutter codebase with
codebase with the **web** target enabled — guest / local-Teamer / invite the **web** target enabled — guest / local-Teamer / invite login, the
login, the Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for
Leitungsteam/Verantwortliche, role-aware home, guest Workshop-Wahl (wishes + Leitungsteam/Verantwortliche, role-aware home, guest Workshop-Wahl (wishes +
result), file list, live WebSocket chat, and a Leitungsteam admin screen result), file list, live WebSocket chat, FCM web-push registration, and the
(KCs, Gemeinden, onboarding approvals). `flutter build web` / `flutter test` Leitungsteam admin screens: KCs, Gemeinden, onboarding approvals, full
pass; the backend serves the build at `/` (SPA fallback covers the OIDC Workshop-Wahl administration (create/open/close, workshops, Force-Zuteilung,
redirect `/v1/auth/callback`). Still to do: a live browser test of the OIDC run assignment, CSV export), Teamer accounts + invites, LT file upload, plus
round-trip, the Teamer-management and Verantwortlichen-self-registration the Verantwortlichen self-registration flow. `flutter build web` /
screens, LT Wahl administration, mobile/desktop targets, and push. `flutter test` pass; the backend serves the build at `/` (SPA fallback
covers the OIDC redirect `/v1/auth/callback`).
Running end to end needs the Authentik redirect registered + a test account, Still to do: a live browser test of the OIDC round-trip; mobile/desktop
plus Nextcloud/S3 credentials (see `backend/.env.example`). targets. Going live needs external config — the Authentik redirect + a test
account, Nextcloud/S3 credentials, SMTP, and the Firebase push secrets
(`apiKey`/`appId`/VAPID key + a service-account JSON). See
`backend/.env.example` and `client/app/web/index.html`.
+8
View File
@@ -31,6 +31,14 @@ SMTP_SECURE="false"
SMTP_USER="" SMTP_USER=""
SMTP_PASS="" SMTP_PASS=""
# Push: defaults to "log" (no delivery). Set PUSH_PROVIDER=fcm plus
# FCM_PROJECT_ID and GOOGLE_APPLICATION_CREDENTIALS (path to a Firebase
# service-account JSON with the "Firebase Cloud Messaging API" enabled) to
# send real notifications via FCM HTTP v1.
PUSH_PROVIDER="log"
FCM_PROJECT_ID="konfi-castle-app"
GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/serviceAccount.json"
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to # File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
# use an S3-compatible bucket instead (see S3_* vars below). # use an S3-compatible bucket instead (see S3_* vars below).
STORAGE_PROVIDER="webdav" STORAGE_PROVIDER="webdav"
+4
View File
@@ -3,3 +3,7 @@ dist
coverage coverage
.env .env
*.log *.log
# Firebase service account (secret)
serviceAccount.json
*.serviceAccount.json
+14 -7
View File
@@ -83,6 +83,13 @@ client's host - no separate web server is needed.
`MailService.sendTeamerInvite()` composes the personal-invite email with a `MailService.sendTeamerInvite()` composes the personal-invite email with a
link built from `APP_BASE_URL`. Delivery is best-effort — failures are link built from `APP_BASE_URL`. Delivery is best-effort — failures are
logged and swallowed, never blocking the invite. logged and swallowed, never blocking the invite.
- `push/` — global `PushProvider` abstraction; default `log`, `PUSH_PROVIDER=fcm`
uses FCM HTTP v1 (service-account JWT → OAuth token, no extra dep;
`FCM_PROJECT_ID`, `GOOGLE_APPLICATION_CREDENTIALS`). `DeviceToken` rows
(bound to a `User` or `GuestAccount`) via `POST /push/register` +
`/unregister`. `PushService.notifyChannel()` resolves a channel's readable
audience → their tokens (minus the sender) → send, pruning invalid ones;
`ChatService.sendMessage()` fires it best-effort.
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
@@ -119,11 +126,11 @@ client's host - no separate web server is needed.
- `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped, - `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
Leitungsteam roles are global across all KCs). Leitungsteam roles are global across all KCs).
All planned backend phases are implemented. `npm test` runs Jest unit tests All planned backend features are implemented (`prisma/migrations/` holds the
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`, schema history). `npm test` runs Jest unit tests (`ZuteilungService`,
`TeamAuthService`, `TeamerService`, `OnboardingService`,
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked). `resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
Remaining work: the Flutter clients (see repo root README), push Ops notes to go live: the Authentik provider must emit a `groups` claim for
notifications, and the first real Prisma migration (only `schema.prisma` the LT check; `MAIL_PROVIDER=smtp` + `SMTP_*` for invite emails;
exists so far). Ops notes: the Authentik provider must emit a `groups` claim `PUSH_PROVIDER=fcm` + a Firebase service-account JSON for push; and real
for the LT check, and `MAIL_PROVIDER=smtp` + `SMTP_*` must be set for invite Nextcloud/S3 credentials for file storage.
emails to actually leave the box.
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "DeviceToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"userId" TEXT,
"guestAccountId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DeviceToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DeviceToken_token_key" ON "DeviceToken"("token");
-- AddForeignKey
ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+18
View File
@@ -78,6 +78,7 @@ model User {
memberships Membership[] memberships Membership[]
messages ChatMessage[] messages ChatMessage[]
chatParticipations ChatParticipant[] chatParticipations ChatParticipant[]
deviceTokens DeviceToken[]
} }
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
@@ -111,6 +112,23 @@ model GuestAccount {
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
messages ChatMessage[] messages ChatMessage[]
teilnehmer Teilnehmer[] teilnehmer Teilnehmer[]
deviceTokens DeviceToken[]
}
/// A push-notification target (FCM registration token) bound to whoever
/// registered it — a team `User` or a `GuestAccount`. Replicated so a
/// notification can be sent from either server.
model DeviceToken {
id String @id @default(cuid())
token String @unique
platform String
userId String?
guestAccountId String?
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
} }
/// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer /// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer
+2
View File
@@ -5,6 +5,7 @@ import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { PushModule } from './push/push.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module'; import { KcModule } from './kc/kc.module';
import { GemeindeModule } from './gemeinde/gemeinde.module'; import { GemeindeModule } from './gemeinde/gemeinde.module';
@@ -35,6 +36,7 @@ const webRoot =
}), }),
PrismaModule, PrismaModule,
MailModule, MailModule,
PushModule,
SyncModule, SyncModule,
AuthModule, AuthModule,
KcModule, KcModule,
+24 -1
View File
@@ -4,16 +4,25 @@ import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from '../auth/authenticated-request'; import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service'; import { GuestJwtPayload } from '../auth/guest-auth.service';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { PushService } from '../push/push.service';
export type ChatCaller = export type ChatCaller =
| { kind: 'user'; user: AuthenticatedUser } | { kind: 'user'; user: AuthenticatedUser }
| { kind: 'guest'; guest: GuestJwtPayload }; | { kind: 'guest'; guest: GuestJwtPayload };
const CHANNEL_TITLES: Record<ChatChannelType, string> = {
[ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe',
[ChatChannelType.DIREKT]: 'Direktnachricht',
[ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam',
[ChatChannelType.BROADCAST]: 'Ankündigung',
};
@Injectable() @Injectable()
export class ChatService { export class ChatService {
constructor( constructor(
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly sync: SyncService, private readonly sync: SyncService,
private readonly push: PushService,
) {} ) {}
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) { async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
@@ -142,7 +151,7 @@ export class ChatService {
} }
async sendMessage(channelId: string, caller: ChatCaller, body: string) { async sendMessage(channelId: string, caller: ChatCaller, body: string) {
await this.assertCanWrite(channelId, caller); const channel = await this.assertCanWrite(channelId, caller);
const message = await this.prisma.chatMessage.create({ const message = await this.prisma.chatMessage.create({
data: { data: {
channelId, channelId,
@@ -152,6 +161,20 @@ export class ChatService {
}, },
}); });
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message); await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
void this.push.notifyChannel(
channelId,
{
title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE],
body: body.length > 140 ? `${body.slice(0, 137)}` : body,
data: { channelId },
},
{
userId: caller.kind === 'user' ? caller.user.userId : null,
guestId: caller.kind === 'guest' ? caller.guest.guestId : null,
},
);
return message; return message;
} }
@@ -0,0 +1,16 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
@IsNotEmpty()
token!: string;
@IsIn(['web', 'android', 'ios'])
platform!: 'web' | 'android' | 'ios';
}
export class UnregisterDeviceDto {
@IsString()
@IsNotEmpty()
token!: string;
}
+110
View File
@@ -0,0 +1,110 @@
import { readFileSync } from 'fs';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { PushNotification, PushProvider, PushResult } from './push-provider';
interface ServiceAccount {
client_email: string;
private_key: string;
token_uri: string;
}
/// Firebase Cloud Messaging HTTP v1. Auth is a service-account JWT exchanged
/// for an OAuth access token (no google-auth-library dependency — jsonwebtoken
/// is already here). Delivery failures are logged and swallowed.
export class FcmPushProvider implements PushProvider {
private readonly logger = new Logger('PushProvider');
private readonly projectId: string;
private readonly sa: ServiceAccount;
private accessToken: { value: string; expiresAt: number } | null = null;
constructor(config: ConfigService) {
this.projectId = config.getOrThrow<string>('FCM_PROJECT_ID');
const path = config.getOrThrow<string>('GOOGLE_APPLICATION_CREDENTIALS');
this.sa = JSON.parse(readFileSync(path, 'utf8')) as ServiceAccount;
}
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
if (tokens.length === 0) return { sent: 0, invalidTokens: [] };
let accessToken: string;
try {
accessToken = await this.getAccessToken();
} catch (err) {
this.logger.error(`FCM auth failed: ${(err as Error).message}`);
return { sent: 0, invalidTokens: [] };
}
const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`;
const invalidTokens: string[] = [];
let sent = 0;
await Promise.all(
tokens.map(async (token) => {
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token,
notification: { title: n.title, body: n.body },
data: n.data,
webpush: { fcmOptions: {} },
},
}),
});
if (res.ok) {
sent += 1;
} else if (res.status === 404 || res.status === 400) {
invalidTokens.push(token);
} else {
this.logger.warn(`FCM send ${res.status}: ${await res.text()}`);
}
} catch (err) {
this.logger.warn(`FCM send error: ${(err as Error).message}`);
}
}),
);
return { sent, invalidTokens };
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) {
return this.accessToken.value;
}
const now = Math.floor(Date.now() / 1000);
const assertion = jwt.sign(
{
iss: this.sa.client_email,
scope: 'https://www.googleapis.com/auth/firebase.messaging',
aud: this.sa.token_uri,
iat: now,
exp: now + 3600,
},
this.sa.private_key,
{ algorithm: 'RS256' },
);
const res = await fetch(this.sa.token_uri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!res.ok) {
throw new Error(`token exchange ${res.status}: ${await res.text()}`);
}
const json = (await res.json()) as { access_token: string; expires_in: number };
this.accessToken = {
value: json.access_token,
expiresAt: Date.now() + json.expires_in * 1000,
};
return json.access_token;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Logger } from '@nestjs/common';
import { PushNotification, PushProvider, PushResult } from './push-provider';
/// Default provider: doesn't send, just logs. Keeps the app working before
/// FCM credentials are configured.
export class LogPushProvider implements PushProvider {
private readonly logger = new Logger('PushProvider');
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
this.logger.log(
`[log-only] would push "${n.title}" to ${tokens.length} device(s): ${n.body}`,
);
return { sent: 0, invalidTokens: [] };
}
}
+20
View File
@@ -0,0 +1,20 @@
/// Abstraction over the push backend. Default is a no-send provider that only
/// logs; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging (HTTP v1).
export interface PushNotification {
title: string;
body: string;
data?: Record<string, string>;
}
export interface PushResult {
sent: number;
/// Tokens FCM reported as permanently invalid — the caller prunes them.
invalidTokens: string[];
}
export interface PushProvider {
/// Best-effort: never throws for delivery problems.
sendToTokens(tokens: string[], notification: PushNotification): Promise<PushResult>;
}
export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER');
+29
View File
@@ -0,0 +1,29 @@
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { PushService } from './push.service';
import { RegisterDeviceDto, UnregisterDeviceDto } from './dto/register-device.dto';
// Import the util directly (not via chat.service) to keep the module graph acyclic.
import { resolveChatCaller } from '../chat/caller.util';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
type PushRequest = AuthenticatedRequest & {
user?: AuthenticatedRequest['user'] | GuestJwtPayload;
};
@Controller('push')
export class PushController {
constructor(private readonly push: PushService) {}
@Post('register')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
register(@Body() dto: RegisterDeviceDto, @Req() req: PushRequest) {
return this.push.register(dto.token, dto.platform, resolveChatCaller(req.user!));
}
@Post('unregister')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
unregister(@Body() dto: UnregisterDeviceDto) {
return this.push.unregister(dto.token);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PUSH_PROVIDER } from './push-provider';
import { LogPushProvider } from './log-push.provider';
import { FcmPushProvider } from './fcm-push.provider';
import { PushService } from './push.service';
import { PushController } from './push.controller';
/// Global so ChatService can inject PushService. Provider defaults to
/// log-only; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging.
@Global()
@Module({
controllers: [PushController],
providers: [
PushService,
{
provide: PUSH_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
config.get<string>('PUSH_PROVIDER') === 'fcm'
? new FcmPushProvider(config)
: new LogPushProvider(),
},
],
exports: [PushService],
})
export class PushModule {}
+151
View File
@@ -0,0 +1,151 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ChatChannelType, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import type { ChatCaller } from '../chat/chat.service';
import { PUSH_PROVIDER, PushNotification, PushProvider } from './push-provider';
@Injectable()
export class PushService {
private readonly logger = new Logger(PushService.name);
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider,
) {}
/// Upsert a device token for the current caller (team user or guest).
async register(token: string, platform: string, caller: ChatCaller) {
const owner =
caller.kind === 'user'
? { userId: caller.user.userId, guestAccountId: null }
: { userId: null, guestAccountId: caller.guest.guestId };
const row = await this.prisma.deviceToken.upsert({
where: { token },
create: { token, platform, ...owner },
update: { platform, lastSeenAt: new Date(), ...owner },
});
await this.sync.capture('DeviceToken', SyncOperation.UPDATE, row.id, row);
return { ok: true };
}
async unregister(token: string) {
const existing = await this.prisma.deviceToken.findUnique({ where: { token } });
if (!existing) return { ok: true };
await this.prisma.deviceToken.delete({ where: { token } });
await this.sync.capture('DeviceToken', SyncOperation.DELETE, existing.id, {
id: existing.id,
});
return { ok: true };
}
/// Fan a chat message out as a push to everyone who can read the channel,
/// minus the sender. Best-effort — never throws into the caller.
async notifyChannel(
channelId: string,
notification: PushNotification,
exclude: { userId?: string | null; guestId?: string | null } = {},
): Promise<void> {
try {
const channel = await this.prisma.chatChannel.findUnique({
where: { id: channelId },
include: { participants: { select: { userId: true } } },
});
if (!channel) return;
const { userIds, guestIds } = await this.audience(channel);
const tokens = await this.prisma.deviceToken.findMany({
where: {
OR: [
userIds.length ? { userId: { in: userIds } } : undefined,
guestIds.length ? { guestAccountId: { in: guestIds } } : undefined,
].filter(Boolean) as object[],
NOT: {
OR: [
exclude.userId ? { userId: exclude.userId } : undefined,
exclude.guestId ? { guestAccountId: exclude.guestId } : undefined,
].filter(Boolean) as object[],
},
},
select: { token: true },
});
if (tokens.length === 0) return;
const { invalidTokens } = await this.provider.sendToTokens(
tokens.map((t) => t.token),
notification,
);
if (invalidTokens.length) {
await this.prisma.deviceToken.deleteMany({
where: { token: { in: invalidTokens } },
});
}
} catch (err) {
this.logger.warn(`notifyChannel failed: ${(err as Error).message}`);
}
}
private async audience(channel: {
kcId: string;
type: ChatChannelType;
gemeindeId: string | null;
participants: { userId: string }[];
}): Promise<{ userIds: string[]; guestIds: string[] }> {
if (channel.type === ChatChannelType.DIREKT) {
return { userIds: channel.participants.map((p) => p.userId), guestIds: [] };
}
const ltUsers = await this.prisma.user.findMany({
where: {
OR: [
{ isLeitungsteam: true },
{ memberships: { some: { kcId: channel.kcId, role: 'LEITUNGSTEAM' } } },
],
},
select: { id: true },
});
const ltIds = ltUsers.map((u) => u.id);
if (channel.type === ChatChannelType.LT_UEBERGREIFEND) {
return { userIds: ltIds, guestIds: [] };
}
if (channel.type === ChatChannelType.GEMEINDE_GRUPPE) {
const [members, guests] = await Promise.all([
this.prisma.membership.findMany({
where: {
kcId: channel.kcId,
gemeindeId: channel.gemeindeId,
status: 'ACTIVE',
},
select: { userId: true },
}),
this.prisma.guestAccount.findMany({
where: { kcId: channel.kcId, gemeindeId: channel.gemeindeId },
select: { id: true },
}),
]);
return {
userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])],
guestIds: guests.map((g) => g.id),
};
}
// BROADCAST: everyone in the KC.
const [members, guests] = await Promise.all([
this.prisma.membership.findMany({
where: { kcId: channel.kcId, status: 'ACTIVE' },
select: { userId: true },
}),
this.prisma.guestAccount.findMany({
where: { kcId: channel.kcId },
select: { id: true },
}),
]);
return {
userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])],
guestIds: guests.map((g) => g.id),
};
}
}
+1
View File
@@ -18,6 +18,7 @@ const SYNCED_MODELS = [
'File', 'File',
'ChatChannel', 'ChatChannel',
'ChatMessage', 'ChatMessage',
'DeviceToken',
] as const; ] as const;
export type SyncedModel = (typeof SYNCED_MODELS)[number]; export type SyncedModel = (typeof SYNCED_MODELS)[number];
+7
View File
@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class UpdateWahlDto {
@IsOptional()
@IsBoolean()
isOpen?: boolean;
}
+16
View File
@@ -3,6 +3,7 @@ import {
Controller, Controller,
Get, Get,
Param, Param,
Patch,
Post, Post,
Query, Query,
Req, Req,
@@ -14,6 +15,7 @@ import { Response } from 'express';
import { WahlService } from './wahl.service'; import { WahlService } from './wahl.service';
import { ZuteilungService } from './zuteilung.service'; import { ZuteilungService } from './zuteilung.service';
import { CreateWahlDto } from './dto/create-wahl.dto'; import { CreateWahlDto } from './dto/create-wahl.dto';
import { UpdateWahlDto } from './dto/update-wahl.dto';
import { CreateWorkshopDto } from './dto/create-workshop.dto'; import { CreateWorkshopDto } from './dto/create-workshop.dto';
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto'; import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto'; import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto';
@@ -45,6 +47,20 @@ export class WahlController {
return this.wahl.listWahlen(kcId); return this.wahl.listWahlen(kcId);
} }
@Patch(':wahlId')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
updateWahl(@Param('wahlId') wahlId: string, @Body() dto: UpdateWahlDto) {
return this.wahl.updateWahl(wahlId, { isOpen: dto.isOpen });
}
@Get(':wahlId/teilnehmer')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listTeilnehmer(@Param('wahlId') wahlId: string) {
return this.wahl.listTeilnehmer(wahlId);
}
@Post(':wahlId/workshops') @Post(':wahlId/workshops')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM) @Roles(Role.LEITUNGSTEAM)
+27
View File
@@ -22,6 +22,33 @@ export class WahlService {
return this.prisma.wahl.findMany({ where: { kcId } }); return this.prisma.wahl.findMany({ where: { kcId } });
} }
async updateWahl(wahlId: string, data: { isOpen?: boolean }) {
await this.getWahlOrThrow(wahlId);
const wahl = await this.prisma.wahl.update({ where: { id: wahlId }, data });
await this.sync.capture('Wahl', SyncOperation.UPDATE, wahl.id, wahl);
return wahl;
}
/// LT view of who took part in a Wahl, with their priorities and any
/// existing Force-Zuteilung.
async listTeilnehmer(wahlId: string) {
await this.getWahlOrThrow(wahlId);
const rows = await this.prisma.teilnehmer.findMany({
where: { wahlId },
orderBy: { guestAccount: { lastName: 'asc' } },
include: {
guestAccount: { select: { firstName: true, lastName: true } },
forceZuteilung: { select: { workshopId: true } },
},
});
return rows.map((t) => ({
id: t.id,
name: `${t.guestAccount.firstName} ${t.guestAccount.lastName}`.trim(),
prioritaeten: (t.prioritaeten as string[] | null) ?? [],
forcedWorkshopId: t.forceZuteilung?.workshopId ?? null,
}));
}
/// Guest-facing view: open Wahlen for the guest's KC, each with its /// Guest-facing view: open Wahlen for the guest's KC, each with its
/// workshops and the guest's own current priorities (null if not submitted). /// workshops and the guest's own current priorities (null if not submitted).
async guestOverview(kcId: string, guestAccountId: string) { async guestOverview(kcId: string, guestAccountId: string) {
+11 -2
View File
@@ -48,13 +48,22 @@ Teamer login only).
- Gemeinden (list/create); each opens **Teamer-Verwaltung** - Gemeinden (list/create); each opens **Teamer-Verwaltung**
(`teamer_admin_screen.dart`): local Teamer accounts + group-link / email (`teamer_admin_screen.dart`): local Teamer accounts + group-link / email
invites. invites.
- **Workshop-Wahlen** (`wahl_admin_screen.dart`): create Wahlen, add - **Workshop-Wahlen** (`wahl_admin_screen.dart`): create Wahlen, open/close
workshops, run the assignment, view the result table. them, add workshops, list participants + Force-Zuteilung, run the
assignment, view the result table, export the CSV (browser download).
- **Dateien** (`files_admin_screen.dart`): upload with a visibility tier
(native `<input type=file>`), list. Needs a configured Nextcloud/S3 on
the backend or the upload returns 500.
- pending Verantwortlichen self-registrations (approve / reject). - pending Verantwortlichen self-registrations (approve / reject).
- **Als Verantwortliche/r registrieren** - **Als Verantwortliche/r registrieren**
(`verantwortliche_register_screen.dart`) — shown on the home screen to a (`verantwortliche_register_screen.dart`) — shown on the home screen to a
logged-in Authentik user without a membership: enter a KC invite code, logged-in Authentik user without a membership: enter a KC invite code,
pick a Gemeinde, submit; a Leitungsteam member then approves. pick a Gemeinde, submit; a Leitungsteam member then approves.
- **Push (web)** — `web/index.html` loads the Firebase compat SDK and
`web/firebase-messaging-sw.js` handles background messages. After login
`AppState` calls `window.kcGetPushToken()` and registers the token
(`POST /push/register`). Inert until `apiKey` / `appId` / `vapidKey` are
filled into both files (see the `REPLACE_ME` placeholders).
- **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — two tabs: - **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — two tabs:
*Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3, *Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3,
`POST /wahl/:id/teilnehmer`) and *Ergebnis* (`GET /wahl/guest/results` `POST /wahl/:id/teilnehmer`) and *Ergebnis* (`GET /wahl/guest/results`
+80
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'browser.dart' as browser;
import 'oidc.dart'; import 'oidc.dart';
/// Backend base URL. Override at build/run time with /// Backend base URL. Override at build/run time with
@@ -183,6 +184,26 @@ class WorkshopAdmin {
); );
} }
class TeilnehmerRow {
TeilnehmerRow({
required this.id,
required this.name,
required this.prioritaeten,
required this.forcedWorkshopId,
});
final String id;
final String name;
final List<String> prioritaeten;
final String? forcedWorkshopId;
factory TeilnehmerRow.fromJson(Map<String, dynamic> j) => TeilnehmerRow(
id: j['id'] as String,
name: j['name'] as String? ?? '',
prioritaeten:
(j['prioritaeten'] as List<dynamic>? ?? []).map((e) => e as String).toList(),
forcedWorkshopId: j['forcedWorkshopId'] as String?,
);
}
class ZuteilungRow { class ZuteilungRow {
ZuteilungRow({ ZuteilungRow({
required this.name, required this.name,
@@ -393,6 +414,15 @@ class Api {
return _decode(res); return _decode(res);
} }
Future<dynamic> _patch(String path, Object? body) async {
final res = await _client.patch(
Uri.parse('$kApiBase$path'),
headers: _headers,
body: body == null ? null : jsonEncode(body),
);
return _decode(res);
}
dynamic _decode(http.Response res) { dynamic _decode(http.Response res) {
final text = res.body.isEmpty ? '{}' : res.body; final text = res.body.isEmpty ? '{}' : res.body;
dynamic parsed; dynamic parsed;
@@ -547,6 +577,42 @@ class Api {
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList(); return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
} }
Future<void> setWahlOpen(String wahlId, bool isOpen) =>
_patch('/wahl/$wahlId', {'isOpen': isOpen});
Future<List<TeilnehmerRow>> wahlTeilnehmer(String wahlId) async {
final list = await _get('/wahl/$wahlId/teilnehmer') as List<dynamic>;
return list.map((e) => TeilnehmerRow.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> forceZuteilung(String wahlId, String teilnehmerId, String workshopId) =>
_post('/wahl/$wahlId/force-zuteilung', {
'teilnehmerId': teilnehmerId,
'workshopId': workshopId,
});
Future<String> zuteilungCsv(String wahlId) async {
final res = await _get('/wahl/$wahlId/zuteilung/csv');
return res is String ? res : res.toString();
}
Future<void> uploadFile(
String kcId,
String filename,
List<int> bytes,
String visibility,
) async {
final req = http.MultipartRequest('POST', Uri.parse('$kApiBase/files/$kcId'))
..fields['visibility'] = visibility
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));
if (token != null) req.headers['Authorization'] = 'Bearer $token';
final streamed = await req.send();
final res = await http.Response.fromStream(streamed);
if (res.statusCode < 200 || res.statusCode >= 300) {
_decode(res); // throws ApiException with the server message
}
}
// --- Teamer administration (LT or the responsible Verantwortliche/r) --- // --- Teamer administration (LT or the responsible Verantwortliche/r) ---
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async { Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>; final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
@@ -597,6 +663,10 @@ class Api {
'gemeindeId': gemeindeId, 'gemeindeId': gemeindeId,
}) as Map<String, dynamic>; }) as Map<String, dynamic>;
// --- push ---
Future<void> registerDevice(String token, {String platform = 'web'}) =>
_post('/push/register', {'token': token, 'platform': platform});
// --- chat: REST for channels/history; live send/receive is the /chat WS --- // --- chat: REST for channels/history; live send/receive is the /chat WS ---
Future<List<ChatChannel>> channels(String kcId) async { Future<List<ChatChannel>> channels(String kcId) async {
final list = await _get('/chat/$kcId/channels') as List<dynamic>; final list = await _get('/chat/$kcId/channels') as List<dynamic>;
@@ -681,6 +751,16 @@ class AppState extends ChangeNotifier {
} }
_authError = null; _authError = null;
notifyListeners(); notifyListeners();
_registerForPush(); // best-effort, fire and forget
}
Future<void> _registerForPush() async {
try {
final pushToken = await browser.getPushToken();
if (pushToken != null) await _api.registerDevice(pushToken);
} catch (_) {
// push is optional
}
} }
Future<void> _clear(SharedPreferences prefs) async { Future<void> _clear(SharedPreferences prefs) async {
+8
View File
@@ -7,3 +7,11 @@ void removeSession(String key) => throw UnsupportedError(_msg);
Never redirect(String url) => throw UnsupportedError(_msg); Never redirect(String url) => throw UnsupportedError(_msg);
Map<String, String> currentQueryParameters() => const {}; Map<String, String> currentQueryParameters() => const {};
void clearQuery() {} void clearQuery() {}
Future<({String name, List<int> bytes})?> pickFile() async =>
throw UnsupportedError(_msg);
void downloadText(String filename, String content, {String mime = 'text/plain'}) =>
throw UnsupportedError(_msg);
/// No push on non-web platforms in this build.
Future<String?> getPushToken() async => null;
+55
View File
@@ -1,5 +1,22 @@
import 'dart:async';
import 'dart:js_interop';
import 'package:web/web.dart' as web; import 'package:web/web.dart' as web;
/// Provided by the inline Firebase bootstrap in web/index.html. Returns an FCM
/// registration token, or null if push isn't configured / permission denied.
@JS('kcGetPushToken')
external JSPromise<JSString?> _kcGetPushToken();
Future<String?> getPushToken() async {
try {
final result = await _kcGetPushToken().toDart;
return result?.toDart;
} catch (_) {
return null;
}
}
/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab /// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab
/// closes), and the little bit of `window` access the OIDC redirect needs. /// closes), and the little bit of `window` access the OIDC redirect needs.
@@ -20,3 +37,41 @@ Map<String, String> currentQueryParameters() =>
void clearQuery() { void clearQuery() {
web.window.history.replaceState(null, '', '/'); web.window.history.replaceState(null, '', '/');
} }
/// Opens the OS file picker and reads the chosen file's bytes.
Future<({String name, List<int> bytes})?> pickFile() {
final completer = Completer<({String name, List<int> bytes})?>();
final input = web.HTMLInputElement()..type = 'file';
input.onchange = ((web.Event _) {
final files = input.files;
if (files == null || files.length == 0) {
completer.complete(null);
return;
}
final file = files.item(0)!;
final reader = web.FileReader();
reader.onload = ((web.Event _) {
final buffer = (reader.result as JSArrayBuffer).toDart;
completer.complete((name: file.name, bytes: buffer.asUint8List()));
}).toJS;
reader.onerror = ((web.Event _) => completer.complete(null)).toJS;
reader.readAsArrayBuffer(file);
}).toJS;
input.click();
return completer.future;
}
/// Triggers a browser download of an in-memory string (e.g. the CSV export).
void downloadText(
String filename,
String content, {
String mime = 'text/csv;charset=utf-8',
}) {
final blob = web.Blob([content.toJS].toJS, web.BlobPropertyBag(type: mime));
final url = web.URL.createObjectURL(blob);
web.HTMLAnchorElement()
..href = url
..download = filename
..click();
web.URL.revokeObjectURL(url);
}
+11
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../api.dart'; import '../api.dart';
import '../main.dart'; import '../main.dart';
import 'files_admin_screen.dart';
import 'teamer_admin_screen.dart'; import 'teamer_admin_screen.dart';
import 'ui.dart'; import 'ui.dart';
import 'wahl_admin_screen.dart'; import 'wahl_admin_screen.dart';
@@ -146,6 +147,16 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
), ),
), ),
), ),
Card(
child: ListTile(
leading: const Icon(Icons.folder_shared),
title: const Text('Dateien'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => FilesAdminScreen(kcId: widget.kc.id)),
),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
SectionHeader('Gemeinden', action: TextButton.icon( SectionHeader('Gemeinden', action: TextButton.icon(
onPressed: _addGemeinde, onPressed: _addGemeinde,
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../browser.dart' as browser;
import '../main.dart';
import 'ui.dart';
/// LT file management for one KC: upload with a visibility tier + list.
class FilesAdminScreen extends StatefulWidget {
const FilesAdminScreen({super.key, required this.kcId});
final String kcId;
@override
State<FilesAdminScreen> createState() => _FilesAdminScreenState();
}
class _FilesAdminScreenState extends State<FilesAdminScreen> {
Future<List<FileEntry>>? _files;
bool _uploading = false;
Api get _api => AppScope.of(context).api;
static const _visibilities = {
'ALLE': 'Alle (inkl. Konfis)',
'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)',
'NUR_LT': 'Nur Leitungsteam',
};
@override
void didChangeDependencies() {
super.didChangeDependencies();
_files ??= _api.files(widget.kcId);
}
void _reload() => setState(() => _files = _api.files(widget.kcId));
Future<void> _upload() async {
final api = _api;
final picked = await browser.pickFile();
if (picked == null || !mounted) return;
final visibility = await showDialog<String>(
context: context,
builder: (_) => SimpleDialog(
title: Text('Sichtbarkeit für „${picked.name}'),
children: [
for (final e in _visibilities.entries)
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(e.key),
child: Text(e.value),
),
],
),
);
if (visibility == null || !mounted) return;
setState(() => _uploading = true);
try {
await api.uploadFile(widget.kcId, picked.name, picked.bytes, visibility);
if (mounted) _reload();
} catch (e) {
if (mounted) toast(context, '$e');
} finally {
if (mounted) setState(() => _uploading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dateien (LT)')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _uploading ? null : _upload,
icon: _uploading
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.upload_file),
label: const Text('Hochladen'),
),
body: FutureBuilder<List<FileEntry>>(
future: _files,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload);
final files = snap.data!;
if (files.isEmpty) {
return const Center(child: Text('Noch keine Dateien.'));
}
return ListView(
children: [
for (final f in files)
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: Text(f.filename),
subtitle: Text(_visibilities[f.visibility] ?? f.visibility),
),
],
);
},
),
);
}
}
+110 -3
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../api.dart'; import '../api.dart';
import '../browser.dart' as browser;
import '../main.dart'; import '../main.dart';
import 'ui.dart'; import 'ui.dart';
@@ -92,20 +93,80 @@ class WahlDetailScreen extends StatefulWidget {
class _WahlDetailScreenState extends State<WahlDetailScreen> { class _WahlDetailScreenState extends State<WahlDetailScreen> {
Future<List<WorkshopAdmin>>? _workshops; Future<List<WorkshopAdmin>>? _workshops;
Future<List<ZuteilungRow>>? _results; Future<List<ZuteilungRow>>? _results;
Future<List<TeilnehmerRow>>? _teilnehmer;
late bool _isOpen = widget.wahl.isOpen;
List<WorkshopAdmin> _workshopCache = const [];
bool _running = false; bool _running = false;
Api get _api => AppScope.of(context).api; Api get _api => AppScope.of(context).api;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_workshops ??= _api.workshopsForWahl(widget.wahl.id); _workshops ??= _api.workshopsForWahl(widget.wahl.id).then((w) {
_workshopCache = w;
return w;
});
_results ??= _api.zuteilungResults(widget.wahl.id); _results ??= _api.zuteilungResults(widget.wahl.id);
_teilnehmer ??= _api.wahlTeilnehmer(widget.wahl.id);
} }
void _reloadWorkshops() => void _reloadWorkshops() => setState(() {
setState(() => _workshops = _api.workshopsForWahl(widget.wahl.id)); _workshops = _api.workshopsForWahl(widget.wahl.id).then((w) {
_workshopCache = w;
return w;
});
});
void _reloadResults() => void _reloadResults() =>
setState(() => _results = _api.zuteilungResults(widget.wahl.id)); setState(() => _results = _api.zuteilungResults(widget.wahl.id));
void _reloadTeilnehmer() =>
setState(() => _teilnehmer = _api.wahlTeilnehmer(widget.wahl.id));
Future<void> _toggleOpen(bool value) async {
final api = _api;
setState(() => _isOpen = value);
try {
await api.setWahlOpen(widget.wahl.id, value);
} catch (e) {
if (mounted) {
setState(() => _isOpen = !value);
toast(context, '$e');
}
}
}
Future<void> _exportCsv() async {
final api = _api;
try {
final csv = await api.zuteilungCsv(widget.wahl.id);
browser.downloadText('zuteilung-${widget.wahl.name}.csv', csv);
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _forceFor(TeilnehmerRow t) async {
final api = _api;
final workshopId = await showDialog<String>(
context: context,
builder: (_) => SimpleDialog(
title: Text('Zuteilung für ${t.name}'),
children: [
for (final w in _workshopCache)
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(w.id),
child: Text(w.name),
),
],
),
);
if (workshopId == null || !mounted) return;
try {
await api.forceZuteilung(widget.wahl.id, t.id, workshopId);
if (mounted) _reloadTeilnehmer();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _addWorkshop() async { Future<void> _addWorkshop() async {
final api = _api; final api = _api;
@@ -142,6 +203,17 @@ class _WahlDetailScreenState extends State<WahlDetailScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Card(
child: SwitchListTile(
title: const Text('Wahl geöffnet'),
subtitle: Text(_isOpen
? 'Konfis können Wünsche abgeben'
: 'Geschlossen — keine neuen Einreichungen'),
value: _isOpen,
onChanged: _toggleOpen,
),
),
const SizedBox(height: 8),
SectionHeader('Workshops', action: TextButton.icon( SectionHeader('Workshops', action: TextButton.icon(
onPressed: _addWorkshop, onPressed: _addWorkshop,
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
@@ -170,12 +242,47 @@ class _WahlDetailScreenState extends State<WahlDetailScreen> {
}, },
), ),
const Divider(height: 40), const Divider(height: 40),
SectionHeader('Teilnehmer:innen'),
const SizedBox(height: 4),
FutureBuilder<List<TeilnehmerRow>>(
future: _teilnehmer,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final rows = snap.data!;
if (rows.isEmpty) return const Text('Noch keine Einreichungen.');
return Column(
children: [
for (final t in rows)
ListTile(
dense: true,
leading: const Icon(Icons.person),
title: Text(t.name),
subtitle: Text('Wünsche: ${t.prioritaeten.length}'
'${t.forcedWorkshopId != null ? ' · fest zugeteilt' : ''}'),
trailing: TextButton(
onPressed: () => _forceFor(t),
child: const Text('Zuteilen'),
),
),
],
);
},
),
const Divider(height: 40),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text('Zuteilung', child: Text('Zuteilung',
style: Theme.of(context).textTheme.titleMedium), style: Theme.of(context).textTheme.titleMedium),
), ),
IconButton(
tooltip: 'CSV exportieren',
onPressed: _exportCsv,
icon: const Icon(Icons.download),
),
FilledButton.icon( FilledButton.icon(
onPressed: _running ? null : _run, onPressed: _running ? null : _run,
icon: _running icon: _running
-1
View File
@@ -14,7 +14,6 @@ dependencies:
web_socket_channel: ^3.0.1 web_socket_channel: ^3.0.1
crypto: ^3.0.6 crypto: ^3.0.6
web: ^1.1.0 web: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+20
View File
@@ -0,0 +1,20 @@
// Background handler for FCM web push. Keep the config in sync with
// window.KC_FIREBASE in index.html (a service worker can't read window).
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: 'AIzaSyDxBpdmW8lUHSuix--3AsWJScGQy9o3G_M',
projectId: 'konfi-castle-app',
messagingSenderId: '307226979593',
appId: '1:307226979593:web:66898b2c78b2d110ef77d8',
});
firebase.messaging().onBackgroundMessage(function (payload) {
const n = payload.notification || {};
self.registration.showNotification(n.title || 'KC-App', {
body: n.body || '',
icon: '/icons/Icon-192.png',
data: payload.data || {},
});
});
+33
View File
@@ -31,6 +31,39 @@
<title>KC-App</title> <title>KC-App</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<!-- Firebase Cloud Messaging (web push). `vapidKey` is the *public* half of
the Web Push certificate key pair; the private half stays in Firebase.
Backend sending still needs a service-account JSON + PUSH_PROVIDER=fcm. -->
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js"></script>
<script>
window.KC_FIREBASE = {
apiKey: "AIzaSyDxBpdmW8lUHSuix--3AsWJScGQy9o3G_M",
authDomain: "konfi-castle-app.firebaseapp.com",
projectId: "konfi-castle-app",
storageBucket: "konfi-castle-app.firebasestorage.app",
messagingSenderId: "307226979593",
appId: "1:307226979593:web:66898b2c78b2d110ef77d8",
measurementId: "G-NK8K5VV40D",
vapidKey: "BEAHrnIzkTBGSEws1J_HRvsTtc6nqvmW4MvFMTfljmjuunqo4yiJoUcq8_jKIt_hbm4diOt0czG28l-dpvGK8T8"
};
window.kcGetPushToken = async function () {
try {
var cfg = window.KC_FIREBASE;
if (!cfg.vapidKey || cfg.vapidKey === "REPLACE_ME" || !("Notification" in window)) return null;
if (!firebase.apps.length) firebase.initializeApp(cfg);
var permission = await Notification.requestPermission();
if (permission !== "granted") return null;
var registration = await navigator.serviceWorker.register("firebase-messaging-sw.js");
var messaging = firebase.messaging();
return await messaging.getToken({ vapidKey: cfg.vapidKey, serviceWorkerRegistration: registration });
} catch (e) {
console.warn("[push] init failed", e);
return null;
}
};
</script>
</head> </head>
<body> <body>
<!-- <!--
+40
View File
@@ -0,0 +1,40 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: kcapp
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"]
interval: 5s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile
depends_on:
db:
condition: service_healthy
# All non-DB config comes from backend/.env (needs Docker Compose v2,
# which strips surrounding quotes). DATABASE_URL and the FCM credential
# path are overridden below for the container.
env_file:
- backend/.env
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public
PORT: "3000"
GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json
APP_BASE_URL: http://localhost:3000
ports:
- "3000:3000"
volumes:
# Firebase service account — kept out of the image, mounted read-only.
- ./backend/serviceAccount.json:/app/serviceAccount.json:ro
volumes:
pgdata:
+7 -4
View File
@@ -44,6 +44,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
| 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) | | 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` | | 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 | | 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 |
| Push | Provider-Abstraktion (`PushProvider`), Default **log-only**, umschaltbar auf **FCM HTTP v1** via `PUSH_PROVIDER=fcm` (Service-Account-JWT → OAuth-Token, ohne extra Dependency). Client: Firebase-Compat-SDK im `index.html` + `firebase-messaging-sw.js`, Token via `POST /api/push/register` | Gleiches Muster wie Storage/E-Mail; Push ist best-effort und blockiert das Senden nie |
| 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 | | 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-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 | | Sync-Konflikte | Keine Konfliktauflösung nötig | Nutzer bestätigte explizit: lokaler Server ist während eines laufenden Events alleinige Quelle der Wahrheit |
@@ -51,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
### Bekannte Einschränkungen / offene Punkte ### Bekannte Einschränkungen / offene Punkte
- **Datei-Bytes werden nicht repliziert** nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Datei-Bytes werden nicht repliziert** nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
- **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Push-Benachrichtigungen**: `push/`-Modul (FCM HTTP v1) + `DeviceToken`-Modell + `POST /api/push/register`/`unregister`; `ChatService.sendMessage` fächert die Nachricht best-effort an die Kanal-Zielgruppe. Default-Provider **log-only**. Für echten Versand fehlen: die Firebase-Web-Secrets (`apiKey`, `appId`, VAPID-Key) in `client/app/web/index.html` + `firebase-messaging-sw.js`, sowie backend-seitig ein Service-Account-JSON + `PUSH_PROVIDER=fcm`. APNs (natives iOS) ist nicht separat gebaut — läuft über FCM, sobald ein iOS-Target existiert.
- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt.
- **Authentik-Provisionierung**: 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`.) Der Client-seitige PKCE-Flow (`oidc.dart`) ist gebaut, aber der volle Browser-Roundtrip ist noch nicht live getestet — dafür muss der genutzte Redirect (`http://localhost:3000/v1/auth/callback` bzw. die Prod-URL) am Authentik-Provider hinterlegt sein und ein Testaccount existieren. - **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`.) Der Client-seitige PKCE-Flow (`oidc.dart`) ist gebaut, aber der volle Browser-Roundtrip ist noch nicht live getestet — dafür muss der genutzte Redirect (`http://localhost:3000/v1/auth/callback` bzw. die Prod-URL) am Authentik-Provider hinterlegt sein und ein Testaccount existieren.
- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
@@ -70,6 +71,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
| `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) | | `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 (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` | | `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` | | | `mail/` | Globale `MailProvider`-Abstraktion (log-only Default, SMTP via `MAIL_PROVIDER=smtp`); `MailService` baut die Invite-Mail inkl. Link aus `APP_BASE_URL` | |
| `push/` | Globale `PushProvider`-Abstraktion (log-only Default, FCM HTTP v1 via `PUSH_PROVIDER=fcm`); `PushService.notifyChannel` löst die Kanal-Zielgruppe auf → `DeviceToken`s → Versand, prunt ungültige Tokens; von `ChatService.sendMessage` best-effort ausgelöst | `POST /api/push/register`, `POST /api/push/unregister` |
| `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), `GET /api/wahl/guest/overview` + `GET /api/wahl/guest/results` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `GET /api/wahl/guest/overview` + `GET /api/wahl/guest/results` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` |
| `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` |
| `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` | | `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` |
@@ -116,8 +118,9 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok. 3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok.
3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang). 3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang).
3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise. 3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise.
3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests` + PENDING-Anfrage → `approve``ACTIVE`; Wahl-Admin (`POST /api/wahl`, `POST /api/wahl/:id/workshops`, `POST .../zuteilung/run`, `GET .../zuteilung`); Teamer-Admin (`POST/GET /api/gemeinde/:id/teamer`, `POST /api/gemeinde/:id/teamer-invites`); `GET /api/onboarding/kc/:code`. 3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests` + PENDING-Anfrage → `approve``ACTIVE`; Wahl-Admin (`POST /api/wahl`, `POST /api/wahl/:id/workshops`, `POST .../zuteilung/run`, `GET .../zuteilung`, `PATCH /api/wahl/:id` `isOpen`, `GET /api/wahl/:id/teilnehmer`, CSV-Export); Teamer-Admin (`POST/GET /api/gemeinde/:id/teamer`, `POST /api/gemeinde/:id/teamer-invites`); `GET /api/onboarding/kc/:code`. (Datei-Upload `POST /api/files/:kcId` liefert 500 ohne konfiguriertes Nextcloud/S3 — erwartet.)
3f. **Echte Authentik verifiziert**: mit einem Password-Grant-Token für ein `KC-APP-LT`-Mitglied (`hermes`) gegen `https://sso.konfi-castle.com``GET /api/auth/me` liefert `isLeitungsteam: true` (JWKS-Prüfung, Trailing-Slash-Issuer, JIT-`User`, `groups`→LT), `POST /api/kc` → 201. Placeholder-E-Mail-Fallback, da `hermes` keine E-Mail hat. `AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"`. **Noch offen:** nur der In-Browser-Redirect-Roundtrip (Authentik-Loginseite → Code-Tausch). 3f. **Echte Authentik verifiziert**: mit einem Password-Grant-Token für ein `KC-APP-LT`-Mitglied (`hermes`) gegen `https://sso.konfi-castle.com``GET /api/auth/me` liefert `isLeitungsteam: true` (JWKS-Prüfung, Trailing-Slash-Issuer, JIT-`User`, `groups`→LT), `POST /api/kc` → 201. Placeholder-E-Mail-Fallback, da `hermes` keine E-Mail hat. `AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"`. **Noch offen:** nur der In-Browser-Redirect-Roundtrip (Authentik-Loginseite → Code-Tausch).
3g. Push: **echter FCM-Versand verifiziert** gegen `konfi-castle-app` (`PUSH_PROVIDER=fcm` + Service-Account-JSON): Chat-Nachricht → `PushService.notifyChannel``FcmPushProvider` → Service-Account-JWT → OAuth-Token (200) → `messages:send` erreicht die API; ein Bogus-Token bekommt `400 INVALID_ARGUMENT` und wird aus `device_token` geprunt. Client-Config komplett (`apiKey`/`appId`/`vapidKey` in `index.html` + `firebase-messaging-sw.js`). **Noch offen:** ein echter Browser muss einmal „Benachrichtigungen erlauben" und einen echten Token liefern.
4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 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/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
- `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
@@ -130,8 +133,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
## 8. Nächste Schritte ## 8. Nächste Schritte
1. Flutter-Client: ✅ Authentik-PKCE-Login (`oidc.dart`), LT-Admin (KC/Gemeinde), **Wahl-Verwaltung** (Wahlen/Workshops/Zuteilung ausführen + Ergebnis), **Teamer-Verwaltung** (Konten + Invites), **Verantwortlichen-Selbstregistrierung**. 🔜 Browser-OIDC-Roundtrip einmal live testen (Redirect + Testaccount durchklicken); Wahl schließen/öffnen + Force-Zuteilung im UI; Datei-Upload für LT; dann Mobile/Desktop-Targets. 1. Flutter-Client: ✅ Authentik-PKCE-Login (`oidc.dart`), LT-Admin (KC/Gemeinde), **Wahl-Verwaltung** (Wahlen/Workshops/Zuteilung + Ergebnis, öffnen/schließen, **Force-Zuteilung**, **CSV-Export** als Browser-Download), **Teamer-Verwaltung** (Konten + Invites), **Verantwortlichen-Selbstregistrierung**, **LT-Datei-Upload** (nativer `<input file>` + Sichtbarkeitsstufe). 🔜 Browser-OIDC-Roundtrip einmal live durchklicken; Mobile/Desktop-Targets (`flutter create --platforms=…`, Toolchains fehlen); Push.
2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.) 2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.)
3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT. 3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT.
4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 4. Push: ✅ konfiguriert & Backend-Versand verifiziert. Offen: echten Browser-Token einmal durchtesten (Notification-Permission → Zustellung).
5. Echte Authentik- + Nextcloud/S3-Infra anbinden und die in Abschnitt 7 offenen E2E-Verifikationsschritte durchführen (lokales Postgres + Migration sind erledigt). 5. Echte Authentik- + Nextcloud/S3-Infra anbinden und die in Abschnitt 7 offenen E2E-Verifikationsschritte durchführen (lokales Postgres + Migration sind erledigt).