Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f42aead5ca | ||
|
|
6662ca80d4 | ||
|
|
97c5807cb2 | ||
|
|
1c9887e0df | ||
|
|
3f46088e9a | ||
|
|
d8aa30c606 | ||
|
|
530be36458 | ||
|
|
cc663c7e17 | ||
|
|
55509eccb7 | ||
|
|
a4ae7549ae |
@@ -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
@@ -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"]
|
||||
@@ -5,6 +5,21 @@ events (KCs), replacing the WordPress plugin "Workshop-Wahlen". See
|
||||
[plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md)
|
||||
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
|
||||
|
||||
- `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
|
||||
and the new auth/onboarding services (56 tests).
|
||||
|
||||
Phase 7 (Flutter client) in progress: `client/app/` is a single Flutter
|
||||
codebase with the **web** target enabled — guest / local-Teamer / invite
|
||||
login, the Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for
|
||||
Phase 7 (Flutter client): `client/app/` is a single Flutter codebase with
|
||||
the **web** target enabled — guest / local-Teamer / invite login, the
|
||||
Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for
|
||||
Leitungsteam/Verantwortliche, role-aware home, guest Workshop-Wahl (wishes +
|
||||
result), file list, live WebSocket chat, and a Leitungsteam admin screen
|
||||
(KCs, Gemeinden, onboarding approvals). `flutter build web` / `flutter test`
|
||||
pass; the backend serves the build at `/` (SPA fallback covers the OIDC
|
||||
redirect `/v1/auth/callback`). Still to do: a live browser test of the OIDC
|
||||
round-trip, the Teamer-management and Verantwortlichen-self-registration
|
||||
screens, LT Wahl administration, mobile/desktop targets, and push.
|
||||
result), file list, live WebSocket chat, FCM web-push registration, and the
|
||||
Leitungsteam admin screens: KCs, Gemeinden, onboarding approvals, full
|
||||
Workshop-Wahl administration (create/open/close, workshops, Force-Zuteilung,
|
||||
run assignment, CSV export), Teamer accounts + invites, LT file upload, plus
|
||||
the Verantwortlichen self-registration flow. `flutter build web` /
|
||||
`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,
|
||||
plus Nextcloud/S3 credentials (see `backend/.env.example`).
|
||||
Still to do: a live browser test of the OIDC round-trip; mobile/desktop
|
||||
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`.
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ SMTP_SECURE="false"
|
||||
SMTP_USER=""
|
||||
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
|
||||
# use an S3-compatible bucket instead (see S3_* vars below).
|
||||
STORAGE_PROVIDER="webdav"
|
||||
|
||||
@@ -3,3 +3,7 @@ dist
|
||||
coverage
|
||||
.env
|
||||
*.log
|
||||
|
||||
# Firebase service account (secret)
|
||||
serviceAccount.json
|
||||
*.serviceAccount.json
|
||||
|
||||
+14
-7
@@ -83,6 +83,13 @@ client's host - no separate web server is needed.
|
||||
`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.
|
||||
- `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
|
||||
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
||||
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,
|
||||
Leitungsteam roles are global across all KCs).
|
||||
|
||||
All planned backend phases are implemented. `npm test` runs Jest unit tests
|
||||
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
|
||||
All planned backend features are implemented (`prisma/migrations/` holds the
|
||||
schema history). `npm test` runs Jest unit tests (`ZuteilungService`,
|
||||
`TeamAuthService`, `TeamerService`, `OnboardingService`,
|
||||
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
|
||||
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.
|
||||
Ops notes to go live: the Authentik provider must emit a `groups` claim for
|
||||
the LT check; `MAIL_PROVIDER=smtp` + `SMTP_*` for invite emails;
|
||||
`PUSH_PROVIDER=fcm` + a Firebase service-account JSON for push; and real
|
||||
Nextcloud/S3 credentials for file storage.
|
||||
|
||||
@@ -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;
|
||||
@@ -78,6 +78,7 @@ model User {
|
||||
memberships Membership[]
|
||||
messages ChatMessage[]
|
||||
chatParticipations ChatParticipant[]
|
||||
deviceTokens DeviceToken[]
|
||||
}
|
||||
|
||||
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||
@@ -107,10 +108,27 @@ model GuestAccount {
|
||||
lastName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
teilnehmer Teilnehmer[]
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { PushModule } from './push/push.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { KcModule } from './kc/kc.module';
|
||||
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||
@@ -35,6 +36,7 @@ const webRoot =
|
||||
}),
|
||||
PrismaModule,
|
||||
MailModule,
|
||||
PushModule,
|
||||
SyncModule,
|
||||
AuthModule,
|
||||
KcModule,
|
||||
|
||||
@@ -4,16 +4,25 @@ import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { PushService } from '../push/push.service';
|
||||
|
||||
export type ChatCaller =
|
||||
| { kind: 'user'; user: AuthenticatedUser }
|
||||
| { 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()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
private readonly push: PushService,
|
||||
) {}
|
||||
|
||||
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
||||
@@ -142,7 +151,7 @@ export class ChatService {
|
||||
}
|
||||
|
||||
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({
|
||||
data: {
|
||||
channelId,
|
||||
@@ -152,6 +161,20 @@ export class ChatService {
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const SYNCED_MODELS = [
|
||||
'File',
|
||||
'ChatChannel',
|
||||
'ChatMessage',
|
||||
'DeviceToken',
|
||||
] as const;
|
||||
export type SyncedModel = (typeof SYNCED_MODELS)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateWahlDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isOpen?: boolean;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
@@ -14,6 +15,7 @@ import { Response } from 'express';
|
||||
import { WahlService } from './wahl.service';
|
||||
import { ZuteilungService } from './zuteilung.service';
|
||||
import { CreateWahlDto } from './dto/create-wahl.dto';
|
||||
import { UpdateWahlDto } from './dto/update-wahl.dto';
|
||||
import { CreateWorkshopDto } from './dto/create-workshop.dto';
|
||||
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
|
||||
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto';
|
||||
@@ -45,6 +47,20 @@ export class WahlController {
|
||||
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')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
|
||||
@@ -22,6 +22,33 @@ export class WahlService {
|
||||
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
|
||||
/// workshops and the guest's own current priorities (null if not submitted).
|
||||
async guestOverview(kcId: string, guestAccountId: string) {
|
||||
|
||||
+11
-2
@@ -48,13 +48,22 @@ Teamer login only).
|
||||
- Gemeinden (list/create); each opens **Teamer-Verwaltung**
|
||||
(`teamer_admin_screen.dart`): local Teamer accounts + group-link / email
|
||||
invites.
|
||||
- **Workshop-Wahlen** (`wahl_admin_screen.dart`): create Wahlen, add
|
||||
workshops, run the assignment, view the result table.
|
||||
- **Workshop-Wahlen** (`wahl_admin_screen.dart`): create Wahlen, open/close
|
||||
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).
|
||||
- **Als Verantwortliche/r registrieren**
|
||||
(`verantwortliche_register_screen.dart`) — shown on the home screen to a
|
||||
logged-in Authentik user without a membership: enter a KC invite code,
|
||||
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:
|
||||
*Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3,
|
||||
`POST /wahl/:id/teilnehmer`) and *Ergebnis* (`GET /wahl/guest/results` —
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'browser.dart' as browser;
|
||||
import 'oidc.dart';
|
||||
|
||||
/// 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 {
|
||||
ZuteilungRow({
|
||||
required this.name,
|
||||
@@ -393,6 +414,15 @@ class Api {
|
||||
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) {
|
||||
final text = res.body.isEmpty ? '{}' : res.body;
|
||||
dynamic parsed;
|
||||
@@ -547,6 +577,42 @@ class Api {
|
||||
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) ---
|
||||
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
|
||||
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
|
||||
@@ -597,6 +663,10 @@ class Api {
|
||||
'gemeindeId': gemeindeId,
|
||||
}) 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 ---
|
||||
Future<List<ChatChannel>> channels(String kcId) async {
|
||||
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
||||
@@ -681,6 +751,16 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
_authError = null;
|
||||
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 {
|
||||
|
||||
@@ -7,3 +7,11 @@ void removeSession(String key) => throw UnsupportedError(_msg);
|
||||
Never redirect(String url) => throw UnsupportedError(_msg);
|
||||
Map<String, String> currentQueryParameters() => const {};
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
|
||||
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
|
||||
/// closes), and the little bit of `window` access the OIDC redirect needs.
|
||||
|
||||
@@ -20,3 +37,41 @@ Map<String, String> currentQueryParameters() =>
|
||||
void clearQuery() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
import 'files_admin_screen.dart';
|
||||
import 'teamer_admin_screen.dart';
|
||||
import 'ui.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),
|
||||
SectionHeader('Gemeinden', action: TextButton.icon(
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../browser.dart' as browser;
|
||||
import '../main.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
@@ -92,20 +93,80 @@ class WahlDetailScreen extends StatefulWidget {
|
||||
class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
||||
Future<List<WorkshopAdmin>>? _workshops;
|
||||
Future<List<ZuteilungRow>>? _results;
|
||||
Future<List<TeilnehmerRow>>? _teilnehmer;
|
||||
late bool _isOpen = widget.wahl.isOpen;
|
||||
List<WorkshopAdmin> _workshopCache = const [];
|
||||
bool _running = false;
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
@override
|
||||
void 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);
|
||||
_teilnehmer ??= _api.wahlTeilnehmer(widget.wahl.id);
|
||||
}
|
||||
|
||||
void _reloadWorkshops() =>
|
||||
setState(() => _workshops = _api.workshopsForWahl(widget.wahl.id));
|
||||
void _reloadWorkshops() => setState(() {
|
||||
_workshops = _api.workshopsForWahl(widget.wahl.id).then((w) {
|
||||
_workshopCache = w;
|
||||
return w;
|
||||
});
|
||||
});
|
||||
void _reloadResults() =>
|
||||
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 {
|
||||
final api = _api;
|
||||
@@ -142,6 +203,17 @@ class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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(
|
||||
onPressed: _addWorkshop,
|
||||
icon: const Icon(Icons.add),
|
||||
@@ -170,12 +242,47 @@ class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
||||
},
|
||||
),
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('Zuteilung',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'CSV exportieren',
|
||||
onPressed: _exportCsv,
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _running ? null : _run,
|
||||
icon: _running
|
||||
|
||||
@@ -14,7 +14,6 @@ dependencies:
|
||||
web_socket_channel: ^3.0.1
|
||||
crypto: ^3.0.6
|
||||
web: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -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 || {},
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,39 @@
|
||||
|
||||
<title>KC-App</title>
|
||||
<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>
|
||||
<body>
|
||||
<!--
|
||||
|
||||
@@ -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:
|
||||
@@ -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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -51,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -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) |
|
||||
| `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` | – |
|
||||
| `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)` |
|
||||
| `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` |
|
||||
@@ -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.
|
||||
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.
|
||||
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).
|
||||
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):
|
||||
- `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.
|
||||
@@ -130,8 +133,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
|
||||
|
||||
## 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.)
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user