From 681acd82034c284cf971a09a7a41f2d14d5085fe Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Sun, 1 Mar 2026 11:50:27 +0100 Subject: [PATCH] add now features --- .env.example | 11 + Makefile | 45 ++- backend/src/app.ts | 2 + backend/src/config/environment.ts | 2 + .../src/controllers/nextcloud.controller.ts | 85 ++++++ .../migrations/012_create_ausbildung.sql | 30 ++ .../013_add_nextcloud_credentials.sql | 6 + backend/src/models/user.model.ts | 2 + backend/src/routes/nextcloud.routes.ts | 12 + backend/src/services/nextcloud.service.ts | 152 ++++++++++ backend/src/services/user.service.ts | 49 +++ docker-compose.yml | 23 ++ .../dashboard/NextcloudTalkWidget.tsx | 281 ++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 14 + frontend/src/services/nextcloud.ts | 33 ++ frontend/src/types/nextcloud.types.ts | 29 ++ ...02-28-nextcloud-talk-integration-design.md | 169 +++++++++++ sync/.env.example | 11 + sync/Dockerfile | 14 + sync/package.json | 22 ++ sync/src/db.ts | 173 +++++++++++ sync/src/index.ts | 66 ++++ sync/src/scraper.ts | 251 ++++++++++++++++ sync/src/types.ts | 25 ++ sync/tsconfig.json | 15 + 25 files changed, 1518 insertions(+), 4 deletions(-) create mode 100644 backend/src/controllers/nextcloud.controller.ts create mode 100644 backend/src/database/migrations/012_create_ausbildung.sql create mode 100644 backend/src/database/migrations/013_add_nextcloud_credentials.sql create mode 100644 backend/src/routes/nextcloud.routes.ts create mode 100644 backend/src/services/nextcloud.service.ts create mode 100644 frontend/src/components/dashboard/NextcloudTalkWidget.tsx create mode 100644 frontend/src/services/nextcloud.ts create mode 100644 frontend/src/types/nextcloud.types.ts create mode 100644 plans/2026-02-28-nextcloud-talk-integration-design.md create mode 100644 sync/.env.example create mode 100644 sync/Dockerfile create mode 100644 sync/package.json create mode 100644 sync/src/db.ts create mode 100644 sync/src/index.ts create mode 100644 sync/src/scraper.ts create mode 100644 sync/src/types.ts create mode 100644 sync/tsconfig.json diff --git a/.env.example b/.env.example index d5554f8..70e00a1 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,15 @@ AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback # Default: openid profile email # AUTHENTIK_SCOPES=openid profile email +# ============================================================================ +# NEXTCLOUD CONFIGURATION +# ============================================================================ + +# Nextcloud base URL +# The URL of your Nextcloud instance +# Used by the backend for Nextcloud integration +NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at + # ============================================================================ # LOGGING CONFIGURATION (Optional) # ============================================================================ @@ -213,6 +222,7 @@ AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback # AUTHENTIK_URL=http://localhost:9000 # AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/ # AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback +# NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at # LOG_LEVEL=debug # # ============================================================================ @@ -236,6 +246,7 @@ AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback # AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at # AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/ # AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback +# NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at # LOG_LEVEL=info # # ============================================================================ diff --git a/Makefile b/Makefile index 9855332..8902d01 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev prod stop logs logs-dev logs-prod rebuild rebuild-dev clean install test +.PHONY: help dev prod stop logs logs-dev logs-prod rebuild rebuild-dev clean install test migrate sync-now sync-logs # Default target help: @@ -10,14 +10,19 @@ help: @echo " make rebuild-dev - Rebuild development services" @echo "" @echo "Production:" - @echo " make prod - Deploy production environment" + @echo " make prod - Deploy production environment (includes migrations)" + @echo " make migrate - Run database migrations against production DB" @echo " make logs-prod - Show production logs" @echo " make rebuild - Rebuild production services" @echo "" + @echo "FDISK Sync:" + @echo " make sync-now - Trigger an immediate sync (runs in existing container)" + @echo " make sync-logs - Follow sync container logs" + @echo "" @echo "General:" @echo " make stop - Stop all services" @echo " make clean - Remove all containers and volumes" - @echo " make install - Install dependencies for backend and frontend" + @echo " make install - Install dependencies for backend, frontend and sync" @echo " make test - Run tests" @echo "" @@ -48,17 +53,46 @@ prod: @echo "Starting production deployment..." docker compose -f docker-compose.yml up -d --build @echo "" + @echo "Running database migrations..." + @$(MAKE) migrate + @echo "" @echo "Production services are running!" +migrate: + @if [ ! -f .env ]; then \ + echo "Error: .env file not found! Run 'cp .env.example .env' first."; \ + exit 1; \ + fi + @echo "Waiting for database to be ready..." + @until docker compose -f docker-compose.yml exec -T postgres \ + pg_isready -U "$${POSTGRES_USER:-prod_user}" -d "$${POSTGRES_DB:-feuerwehr_prod}" \ + > /dev/null 2>&1; do \ + printf '.'; sleep 2; \ + done + @echo "" + @echo "Running migrations..." + docker compose -f docker-compose.yml exec -T backend npm run migrate + @echo "Migrations complete!" + logs-prod: docker compose -f docker-compose.yml logs -f logs: - @make logs-prod + @$(MAKE) logs-prod rebuild: docker compose -f docker-compose.yml up -d --build --force-recreate +# FDISK Sync +sync-now: + @echo "Triggering immediate FDISK sync..." + docker compose -f docker-compose.yml exec -T fdisk-sync kill -USR1 1 2>/dev/null || \ + docker compose -f docker-compose.yml restart fdisk-sync + @echo "Sync triggered. Follow with: make sync-logs" + +sync-logs: + docker compose -f docker-compose.yml logs -f fdisk-sync + # General commands stop: @echo "Stopping all services..." @@ -87,6 +121,9 @@ install: @echo "Installing frontend dependencies..." cd frontend && npm install @echo "" + @echo "Installing sync dependencies..." + cd sync && npm install + @echo "" @echo "Dependencies installed!" # Run tests diff --git a/backend/src/app.ts b/backend/src/app.ts index 22066b4..d327f28 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -74,6 +74,7 @@ import trainingRoutes from './routes/training.routes'; import vehicleRoutes from './routes/vehicle.routes'; import incidentRoutes from './routes/incident.routes'; import equipmentRoutes from './routes/equipment.routes'; +import nextcloudRoutes from './routes/nextcloud.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -83,6 +84,7 @@ app.use('/api/training', trainingRoutes); app.use('/api/vehicles', vehicleRoutes); app.use('/api/incidents', incidentRoutes); app.use('/api/equipment', equipmentRoutes); +app.use('/api/nextcloud/talk', nextcloudRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts index 5d94542..7160af4 100644 --- a/backend/src/config/environment.ts +++ b/backend/src/config/environment.ts @@ -32,6 +32,7 @@ interface EnvironmentConfig { clientSecret: string; redirectUri: string; }; + nextcloudUrl: string; } const environment: EnvironmentConfig = { @@ -61,6 +62,7 @@ const environment: EnvironmentConfig = { clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'your_client_secret_here', redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback', }, + nextcloudUrl: process.env.NEXTCLOUD_URL || '', }; export default environment; diff --git a/backend/src/controllers/nextcloud.controller.ts b/backend/src/controllers/nextcloud.controller.ts new file mode 100644 index 0000000..f7c57c7 --- /dev/null +++ b/backend/src/controllers/nextcloud.controller.ts @@ -0,0 +1,85 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import nextcloudService from '../services/nextcloud.service'; +import userService from '../services/user.service'; +import logger from '../utils/logger'; + +const PollRequestSchema = z.object({ + pollEndpoint: z.string().url(), + pollToken: z.string().min(1), +}); + +class NextcloudController { + async initiateConnect(_req: Request, res: Response): Promise { + try { + const data = await nextcloudService.initiateLoginFlow(); + res.status(200).json({ success: true, data }); + } catch (error) { + logger.error('initiateConnect error', { error }); + res.status(500).json({ success: false, message: 'Nextcloud-Verbindung konnte nicht gestartet werden' }); + } + } + + async pollConnect(req: Request, res: Response): Promise { + try { + const parsed = PollRequestSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + const result = await nextcloudService.pollLoginFlow(parsed.data.pollEndpoint, parsed.data.pollToken); + + if (!result) { + res.status(200).json({ success: true, data: { completed: false } }); + return; + } + + await userService.updateNextcloudCredentials(req.user!.id, result.loginName, result.appPassword); + res.status(200).json({ success: true, data: { completed: true } }); + } catch (error) { + logger.error('pollConnect error', { error }); + res.status(500).json({ success: false, message: 'Nextcloud-Abfrage fehlgeschlagen' }); + } + } + + async getConversations(req: Request, res: Response): Promise { + try { + const credentials = await userService.getNextcloudCredentials(req.user!.id); + if (!credentials) { + res.status(200).json({ success: true, data: { connected: false } }); + return; + } + + const { totalUnread, conversations } = await nextcloudService.getConversations( + credentials.loginName, + credentials.appPassword, + ); + res.status(200).json({ success: true, data: { connected: true, totalUnread, conversations } }); + } catch (error: any) { + if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(200).json({ success: true, data: { connected: false } }); + return; + } + logger.error('getConversations error', { error }); + res.status(500).json({ success: false, message: 'Nextcloud-Gespräche konnten nicht geladen werden' }); + } + } + + async disconnect(req: Request, res: Response): Promise { + try { + await userService.clearNextcloudCredentials(req.user!.id); + res.status(200).json({ success: true, data: null }); + } catch (error) { + logger.error('disconnect error', { error }); + res.status(500).json({ success: false, message: 'Nextcloud-Trennung fehlgeschlagen' }); + } + } +} + +export default new NextcloudController(); diff --git a/backend/src/database/migrations/012_create_ausbildung.sql b/backend/src/database/migrations/012_create_ausbildung.sql new file mode 100644 index 0000000..abf6a2c --- /dev/null +++ b/backend/src/database/migrations/012_create_ausbildung.sql @@ -0,0 +1,30 @@ +-- Add FDISK Standesbuch-Nr to mitglieder_profile for sync matching +ALTER TABLE mitglieder_profile + ADD COLUMN IF NOT EXISTS fdisk_standesbuch_nr VARCHAR(32) UNIQUE; + +CREATE INDEX IF NOT EXISTS idx_mitglieder_fdisk_standesbuch_nr + ON mitglieder_profile(fdisk_standesbuch_nr); + +-- Qualifications synced from FDISK +CREATE TABLE IF NOT EXISTS ausbildung ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kursname VARCHAR(255) NOT NULL, + kurs_datum DATE, + ablaufdatum DATE, + ort VARCHAR(255), + status VARCHAR(32) NOT NULL DEFAULT 'abgeschlossen' CHECK (status IN ( + 'abgeschlossen', 'in_bearbeitung', 'abgelaufen' + )), + -- Composite key from FDISK to prevent duplicates on re-sync + fdisk_sync_key VARCHAR(255), + bemerkung TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_ausbildung_user_fdisk_key UNIQUE (user_id, fdisk_sync_key) +); + +CREATE INDEX IF NOT EXISTS idx_ausbildung_user_id ON ausbildung(user_id); + +CREATE TRIGGER update_ausbildung_updated_at BEFORE UPDATE ON ausbildung +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/src/database/migrations/013_add_nextcloud_credentials.sql b/backend/src/database/migrations/013_add_nextcloud_credentials.sql new file mode 100644 index 0000000..351213f --- /dev/null +++ b/backend/src/database/migrations/013_add_nextcloud_credentials.sql @@ -0,0 +1,6 @@ +-- Migration 013: Add Nextcloud credentials to users +-- Stores per-user Nextcloud login name and app password for Nextcloud API access. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS nextcloud_login_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS nextcloud_app_password TEXT; diff --git a/backend/src/models/user.model.ts b/backend/src/models/user.model.ts index d654c24..70b9fba 100644 --- a/backend/src/models/user.model.ts +++ b/backend/src/models/user.model.ts @@ -9,6 +9,8 @@ export interface User { profile_picture_url?: string; refresh_token?: string; refresh_token_expires_at?: Date; + nextcloud_login_name?: string; + nextcloud_app_password?: string; is_active: boolean; last_login_at?: Date; created_at: Date; diff --git a/backend/src/routes/nextcloud.routes.ts b/backend/src/routes/nextcloud.routes.ts new file mode 100644 index 0000000..1c6f8f5 --- /dev/null +++ b/backend/src/routes/nextcloud.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import nextcloudController from '../controllers/nextcloud.controller'; +import { authenticate } from '../middleware/auth.middleware'; + +const router = Router(); + +router.get('/', authenticate, nextcloudController.getConversations.bind(nextcloudController)); +router.post('/connect', authenticate, nextcloudController.initiateConnect.bind(nextcloudController)); +router.post('/poll', authenticate, nextcloudController.pollConnect.bind(nextcloudController)); +router.delete('/connect', authenticate, nextcloudController.disconnect.bind(nextcloudController)); + +export default router; diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts new file mode 100644 index 0000000..c89428c --- /dev/null +++ b/backend/src/services/nextcloud.service.ts @@ -0,0 +1,152 @@ +import axios from 'axios'; +import environment from '../config/environment'; +import logger from '../utils/logger'; + +interface NextcloudLastMessage { + text: string; + author: string; + timestamp: number; +} + +interface NextcloudConversation { + token: string; + displayName: string; + unreadMessages: number; + lastActivity: number; + lastMessage: NextcloudLastMessage | null; + type: number; + url: string; +} + +interface ConversationsResult { + totalUnread: number; + conversations: NextcloudConversation[]; +} + +interface LoginFlowResult { + loginUrl: string; + pollToken: string; + pollEndpoint: string; +} + +interface LoginFlowCredentials { + loginName: string; + appPassword: string; +} + +async function initiateLoginFlow(): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl) { + throw new Error('NEXTCLOUD_URL is not configured'); + } + + try { + const response = await axios.post(`${baseUrl}/index.php/login/v2`); + return { + loginUrl: response.data.login, + pollToken: response.data.poll.token, + pollEndpoint: response.data.poll.endpoint, + }; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error('Nextcloud Login Flow v2 initiation failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + } + logger.error('NextcloudService.initiateLoginFlow failed', { error }); + throw new Error('Failed to initiate Nextcloud login flow'); + } +} + +async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise { + try { + const response = await axios.post(pollEndpoint, `token=${pollToken}`, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return { + loginName: response.data.loginName, + appPassword: response.data.appPassword, + }; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return null; + } + if (axios.isAxiosError(error)) { + logger.error('Nextcloud Login Flow v2 poll failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + } + logger.error('NextcloudService.pollLoginFlow failed', { error }); + throw new Error('Failed to poll Nextcloud login flow'); + } +} + +async function getConversations(loginName: string, appPassword: string): Promise { + const baseUrl = environment.nextcloudUrl; + if (!baseUrl) { + throw new Error('NEXTCLOUD_URL is not configured'); + } + + try { + const response = await axios.get( + `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room?format=json`, + { + headers: { + 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + }, + }, + ); + + const rooms: any[] = response.data?.ocs?.data ?? []; + + const filtered = rooms.filter((r: any) => r.type !== 4); + + const totalUnread = filtered.reduce( + (sum: number, r: any) => sum + (r.unreadMessages ?? 0), + 0, + ); + + const sorted = [...filtered].sort( + (a: any, b: any) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0), + ); + + const conversations: NextcloudConversation[] = sorted.slice(0, 3).map((r: any) => ({ + token: r.token, + displayName: r.displayName, + unreadMessages: r.unreadMessages ?? 0, + lastActivity: r.lastActivity ?? 0, + lastMessage: r.lastMessage + ? { + text: r.lastMessage.message ?? '', + author: r.lastMessage.actorDisplayName ?? '', + timestamp: r.lastMessage.timestamp ?? 0, + } + : null, + type: r.type, + url: `${baseUrl}/call/${r.token}`, + })); + + return { totalUnread, conversations }; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + const err = new Error('Nextcloud app password is invalid'); + (err as any).code = 'NEXTCLOUD_AUTH_INVALID'; + throw err; + } + if (axios.isAxiosError(error)) { + logger.error('Nextcloud API request failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`); + } + logger.error('NextcloudService.getConversations failed', { error }); + throw new Error('Failed to fetch Nextcloud conversations'); + } +} + +export default { initiateLoginFlow, pollLoginFlow, getConversations }; diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 38aa86b..d597559 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -273,6 +273,55 @@ class UserService { } } + async updateNextcloudCredentials(userId: string, loginName: string, appPassword: string): Promise { + try { + await pool.query( + `UPDATE users SET nextcloud_login_name = $2, nextcloud_app_password = $3 WHERE id = $1`, + [userId, loginName, appPassword], + ); + logger.debug('Updated Nextcloud credentials', { userId }); + } catch (error) { + logger.error('Error updating Nextcloud credentials', { error, userId }); + throw new Error('Failed to update Nextcloud credentials'); + } + } + + async getNextcloudCredentials(userId: string): Promise<{ loginName: string; appPassword: string } | null> { + try { + const result = await pool.query( + `SELECT nextcloud_login_name, nextcloud_app_password FROM users WHERE id = $1`, + [userId], + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + if (!row.nextcloud_login_name || !row.nextcloud_app_password) { + return null; + } + + return { loginName: row.nextcloud_login_name, appPassword: row.nextcloud_app_password }; + } catch (error) { + logger.error('Error getting Nextcloud credentials', { error, userId }); + throw new Error('Failed to get Nextcloud credentials'); + } + } + + async clearNextcloudCredentials(userId: string): Promise { + try { + await pool.query( + `UPDATE users SET nextcloud_login_name = NULL, nextcloud_app_password = NULL WHERE id = $1`, + [userId], + ); + logger.debug('Cleared Nextcloud credentials', { userId }); + } catch (error) { + logger.error('Error clearing Nextcloud credentials', { error, userId }); + throw new Error('Failed to clear Nextcloud credentials'); + } + } + /** * Sync Authentik groups for a user */ diff --git a/docker-compose.yml b/docker-compose.yml index e858db8..2938331 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:?AUTHENTIK_CLIENT_ID is required} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:?AUTHENTIK_CLIENT_SECRET is required} AUTHENTIK_REDIRECT_URI: ${AUTHENTIK_REDIRECT_URI:-https://start.feuerwehr-rems.at/auth/callback} + NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.feuerwehr-rems.at} ports: - "${BACKEND_PORT:-3000}:3000" depends_on: @@ -87,6 +88,28 @@ services: start_period: 30s restart: unless-stopped + fdisk-sync: + build: + context: ./sync + dockerfile: Dockerfile + container_name: feuerwehr_fdisk_sync + environment: + FDISK_USERNAME: ${FDISK_USERNAME:?FDISK_USERNAME is required} + FDISK_PASSWORD: ${FDISK_PASSWORD:?FDISK_PASSWORD is required} + FDISK_BASE_URL: ${FDISK_BASE_URL:-https://app.fdisk.at} + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${POSTGRES_DB:-feuerwehr_prod} + DB_USER: ${POSTGRES_USER:-prod_user} + DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + TZ: Europe/Vienna + depends_on: + postgres: + condition: service_healthy + networks: + - dashboard-backend + restart: unless-stopped + volumes: postgres_data_prod: driver: local diff --git a/frontend/src/components/dashboard/NextcloudTalkWidget.tsx b/frontend/src/components/dashboard/NextcloudTalkWidget.tsx new file mode 100644 index 0000000..37300d9 --- /dev/null +++ b/frontend/src/components/dashboard/NextcloudTalkWidget.tsx @@ -0,0 +1,281 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { + Card, + CardContent, + Typography, + Box, + Chip, + Divider, + Skeleton, + Button, + CircularProgress, + IconButton, + Tooltip, +} from '@mui/material'; +import { Forum, Cloud, LinkOff } from '@mui/icons-material'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { formatDistanceToNow } from 'date-fns'; +import { de } from 'date-fns/locale'; +import { nextcloudApi } from '../../services/nextcloud'; +import type { NextcloudConversation } from '../../types/nextcloud.types'; + +const POLL_INTERVAL = 2000; +const POLL_TIMEOUT = 5 * 60 * 1000; + +const ConversationRow: React.FC<{ conversation: NextcloudConversation; showDivider: boolean }> = ({ + conversation, + showDivider, +}) => { + const handleClick = () => { + window.open(conversation.url, '_blank', 'noopener,noreferrer'); + }; + + const relativeTime = conversation.lastMessage + ? formatDistanceToNow(new Date(conversation.lastMessage.timestamp * 1000), { + addSuffix: true, + locale: de, + }) + : null; + + return ( + <> + + + + + {conversation.displayName} + + {conversation.unreadMessages > 0 && ( + + )} + + {conversation.lastMessage && ( + + {conversation.lastMessage.author}: {conversation.lastMessage.text} + + )} + + {relativeTime && ( + + {relativeTime} + + )} + + {showDivider && } + + ); +}; + +const NextcloudTalkWidget: React.FC = () => { + const queryClient = useQueryClient(); + const [isConnecting, setIsConnecting] = useState(false); + const pollIntervalRef = useRef | null>(null); + const popupRef = useRef(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['nextcloud-talk'], + queryFn: () => nextcloudApi.getConversations(), + refetchInterval: 30000, + retry: 1, + }); + + const connected = data?.connected ?? false; + const conversations = data?.conversations?.slice(0, 5) ?? []; + const totalUnread = data?.totalUnread ?? 0; + + const stopPolling = useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + popupRef.current = null; + setIsConnecting(false); + }, []); + + useEffect(() => { + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + }; + }, []); + + const handleConnect = async () => { + try { + setIsConnecting(true); + const { loginUrl, pollToken, pollEndpoint } = await nextcloudApi.connect(); + + const popup = window.open(loginUrl, '_blank', 'width=600,height=700'); + popupRef.current = popup; + + const startTime = Date.now(); + + pollIntervalRef.current = setInterval(async () => { + if (Date.now() - startTime > POLL_TIMEOUT) { + stopPolling(); + return; + } + + if (popup && popup.closed) { + stopPolling(); + return; + } + + try { + const result = await nextcloudApi.poll(pollToken, pollEndpoint); + if (result.completed) { + stopPolling(); + queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] }); + } + } catch { + // Polling error — keep trying until timeout + } + }, POLL_INTERVAL); + } catch { + setIsConnecting(false); + } + }; + + const handleDisconnect = async () => { + try { + await nextcloudApi.disconnect(); + queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] }); + } catch { + // Disconnect failed silently + } + }; + + return ( + + + + + + Nextcloud Talk + + {connected && totalUnread > 0 && ( + + )} + {connected && ( + + + + + + )} + + + {isLoading && ( + + {[1, 2, 3].map((n) => ( + + + + + ))} + + )} + + {isError && ( + + Nextcloud nicht erreichbar + + )} + + {!isLoading && !isError && !connected && ( + + {isConnecting ? ( + <> + + + Warte auf Bestätigung... + + + ) : ( + <> + + Verbinde dein Nextcloud-Konto, um Chats zu sehen. + + + + )} + + )} + + {!isLoading && !isError && connected && conversations.length === 0 && ( + + Keine aktiven Chats + + )} + + {!isLoading && !isError && connected && conversations.length > 0 && ( + + {conversations.map((conversation, index) => ( + + ))} + + )} + + + ); +}; + +export default NextcloudTalkWidget; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ef91630..a2a59dd 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -19,6 +19,7 @@ import UserProfile from '../components/dashboard/UserProfile'; import NextcloudCard from '../components/dashboard/NextcloudCard'; import VikunjaCard from '../components/dashboard/VikunjaCard'; import BookstackCard from '../components/dashboard/BookstackCard'; +import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget'; import StatsCard from '../components/dashboard/StatsCard'; import ActivityFeed from '../components/dashboard/ActivityFeed'; import InspectionAlerts from '../components/vehicles/InspectionAlerts'; @@ -207,6 +208,19 @@ function Dashboard() { )} + {/* Nextcloud Talk Widget */} + + {dataLoading ? ( + + ) : ( + + + + + + )} + + {/* Activity Feed */} {dataLoading ? ( diff --git a/frontend/src/services/nextcloud.ts b/frontend/src/services/nextcloud.ts new file mode 100644 index 0000000..f641d14 --- /dev/null +++ b/frontend/src/services/nextcloud.ts @@ -0,0 +1,33 @@ +import { api } from './api'; +import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData } from '../types/nextcloud.types'; + +interface ApiResponse { + success: boolean; + data: T; +} + +export const nextcloudApi = { + getConversations(): Promise { + return api + .get>('/api/nextcloud/talk') + .then((r) => r.data.data); + }, + + connect(): Promise { + return api + .post>('/api/nextcloud/talk/connect') + .then((r) => r.data.data); + }, + + poll(pollToken: string, pollEndpoint: string): Promise { + return api + .post>('/api/nextcloud/talk/poll', { pollToken, pollEndpoint }) + .then((r) => r.data.data); + }, + + disconnect(): Promise { + return api + .delete('/api/nextcloud/talk/connect') + .then(() => undefined); + }, +}; diff --git a/frontend/src/types/nextcloud.types.ts b/frontend/src/types/nextcloud.types.ts new file mode 100644 index 0000000..13c9ecb --- /dev/null +++ b/frontend/src/types/nextcloud.types.ts @@ -0,0 +1,29 @@ +export interface NextcloudConversation { + token: string; + displayName: string; + unreadMessages: number; + lastActivity: number; + lastMessage: { + text: string; + author: string; + timestamp: number; + } | null; + type: number; + url: string; +} + +export interface NextcloudTalkData { + connected: boolean; + totalUnread?: number; + conversations?: NextcloudConversation[]; +} + +export interface NextcloudConnectData { + loginUrl: string; + pollToken: string; + pollEndpoint: string; +} + +export interface NextcloudPollData { + completed: boolean; +} diff --git a/plans/2026-02-28-nextcloud-talk-integration-design.md b/plans/2026-02-28-nextcloud-talk-integration-design.md new file mode 100644 index 0000000..77ac987 --- /dev/null +++ b/plans/2026-02-28-nextcloud-talk-integration-design.md @@ -0,0 +1,169 @@ +# Nextcloud Talk Integration — Design Document + +## Overview + +Add a read-only Nextcloud Talk widget to the Feuerwehr Dashboard showing the user's 2-3 most recent chats, unread message counts, and clickable links to open conversations in Nextcloud Talk. Users connect their Nextcloud account once via Login Flow v2, which generates an app password stored in the database. + +## Architecture + +### Auth Flow (Login Flow v2 — App Passwords) + +Each user connects their Nextcloud account through the Nextcloud Login Flow v2. This generates an app-specific password that the backend stores and uses for all subsequent Nextcloud API calls via HTTP Basic Auth. No Authentik session cookies are involved. + +**Data flow:** +``` +Frontend → Dashboard Backend (JWT auth) → Nextcloud OCS API (Basic Auth with stored app password) +``` + +1. User clicks "Mit Nextcloud verbinden" in the widget +2. Frontend calls `POST /api/nextcloud/connect` to initiate Login Flow v2 +3. Backend requests a login token from Nextcloud and returns the `login` URL + `poll` endpoint/token +4. Frontend opens the Nextcloud login URL in a new tab; user authorizes the app +5. Frontend polls `POST /api/nextcloud/poll` until Nextcloud returns credentials +6. Backend stores the app password (encrypted) and Nextcloud username in the user's DB record +7. Subsequent `GET /api/nextcloud/talk/rooms` calls use the stored credentials with Basic Auth +8. User can disconnect via `DELETE /api/nextcloud/connect`, which revokes the app password + +### Components to Build + +- **Backend:** Four endpoints under `/api/nextcloud` +- **Frontend:** One widget component (`NextcloudTalkWidget`) with connect/disconnect flow +- **Database:** Migration adding `nextcloud_username` and `nextcloud_app_password` columns to users table +- **Config:** One env var: `NEXTCLOUD_URL` + +--- + +## Backend: Nextcloud Endpoints + +### `GET /api/nextcloud/talk/rooms` + +Fetches the user's recent conversations using their stored Nextcloud credentials. + +1. Reads the user's `nextcloud_username` and `nextcloud_app_password` from the database +2. Calls Nextcloud OCS API with Basic Auth and `OCS-APIRequest: true` header +3. Filters out `type: 4` (changelog) conversations +4. Sorts by `lastActivity` descending, limits to top 3 +5. Calculates `totalUnread` sum across all conversations +6. Pre-builds deep link URLs + +**Response shape:** +```typescript +{ + success: true, + data: { + totalUnread: 12, + conversations: [ + { + token: "abc123", + displayName: "Einsatzgruppe 1", + unreadMessages: 5, + lastActivity: 1709142000, + lastMessage: { + text: "Einsatz morgen um 8:00", + author: "Max Mustermann", + timestamp: 1709142000 + }, + type: 2, + url: "https://cloud.feuerwehr-rems.at/call/abc123" + } + ] + } +} +``` + +### `POST /api/nextcloud/connect` + +Initiates Login Flow v2 by requesting a login token from Nextcloud. Returns the login URL (for the user to open in a new tab) and the poll endpoint/token (for the frontend to poll). + +### `POST /api/nextcloud/poll` + +Polls the Nextcloud Login Flow v2 endpoint. When the user has authorized the app, Nextcloud returns the app password and username. The backend stores these credentials in the user's DB record. + +### `DELETE /api/nextcloud/connect` + +Disconnects the Nextcloud account: revokes the app password via the Nextcloud provisioning API and removes the stored credentials from the database. + +**Error handling:** +- No stored credentials → `401` with `nextcloud_not_connected` error +- Invalid/revoked app password → `401` +- Nextcloud unreachable → `502` + +--- + +## Frontend: NextcloudTalkWidget + +### State: Not Connected + +``` +┌─────────────────────────────────┐ +│ Nextcloud Talk │ +├─────────────────────────────────┤ +│ │ +│ Mit Nextcloud verbinden │ ← Button triggers Login Flow v2 +│ │ +└─────────────────────────────────┘ +``` + +Clicking the button calls `POST /api/nextcloud/connect`, opens the returned login URL in a new tab, and starts polling `POST /api/nextcloud/poll` until credentials are obtained. + +### State: Connected + +``` +┌─────────────────────────────────┐ +│ Nextcloud Talk (12) │ ← Header + total unread badge +├─────────────────────────────────┤ +│ Einsatzgruppe 1 (5) │ ← Chat name + unread count +│ Max: Einsatz morgen um 8:00 │ ← Last message preview +│─────────────────────────────────│ +│ Ausbildung (4) │ +│ Anna: Termin verschoben │ +│─────────────────────────────────│ +│ Kommando (3) │ +│ Peter: Protokoll angehängt │ +├─────────────────────────────────┤ +│ Trennen ⋮ │ ← Disconnect option +└─────────────────────────────────┘ + Each row clickable → opens /call/{token} in new tab +``` + +**Behavior:** +- React Query with 30s refetch interval +- SkeletonCard during loading +- Error state: "Nextcloud nicht erreichbar" +- Clickable rows with hover effect, open deep link in new tab +- MUI Badge/Chip for unread counts, hidden when zero +- Relative timestamps via date-fns (e.g., "vor 5 Min.") +- MUI Grid item: `xs={12} md={6} lg={4}` + +--- + +## Configuration + +**New env var:** +``` +NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at +``` + +Added to `.env.example`, `docker-compose.yml`, and `backend/src/config/environment.ts`. + +--- + +## Files to Create/Modify + +### New Files +- `backend/src/routes/nextcloud.routes.ts` — Route definitions (4 endpoints) +- `backend/src/controllers/nextcloud.controller.ts` — Request handlers +- `backend/src/services/nextcloud.service.ts` — Login Flow v2 + OCS API logic +- `backend/src/database/migrations/013_add_nextcloud_credentials.sql` — Add `nextcloud_username` and `nextcloud_app_password` columns +- `frontend/src/components/dashboard/NextcloudTalkWidget.tsx` — Widget with connect/disconnect flow +- `frontend/src/services/nextcloud.ts` — Frontend API service (connect, poll, disconnect, getRooms) +- `frontend/src/types/nextcloud.types.ts` — TypeScript interfaces + +### Modified Files +- `backend/src/app.ts` — Register nextcloud routes +- `backend/src/config/environment.ts` — Add NEXTCLOUD_URL +- `backend/src/models/user.model.ts` — Add `nextcloud_username` and `nextcloud_app_password` fields to User interface +- `backend/src/services/user.service.ts` — Add methods to store/retrieve/clear Nextcloud credentials +- `frontend/src/pages/Dashboard.tsx` — Add widget to grid +- `.env.example` — Add NEXTCLOUD_URL +- `docker-compose.yml` — Pass NEXTCLOUD_URL to backend diff --git a/sync/.env.example b/sync/.env.example new file mode 100644 index 0000000..85d7147 --- /dev/null +++ b/sync/.env.example @@ -0,0 +1,11 @@ +# FDISK credentials +FDISK_USERNAME= +FDISK_PASSWORD= +FDISK_BASE_URL=https://app.fdisk.at + +# Database (same as backend) +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=feuerwehr_prod +DB_USER=prod_user +DB_PASSWORD= diff --git a/sync/Dockerfile b/sync/Dockerfile new file mode 100644 index 0000000..ae396f5 --- /dev/null +++ b/sync/Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/playwright:v1.47.0-jammy + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +# Install Chromium for Playwright +RUN npx playwright install chromium --with-deps + +COPY . . +RUN npm run build + +CMD ["node", "dist/index.js"] diff --git a/sync/package.json b/sync/package.json new file mode 100644 index 0000000..84dc83e --- /dev/null +++ b/sync/package.json @@ -0,0 +1,22 @@ +{ + "name": "fdisk-sync", + "version": "1.0.0", + "description": "FDISK → Feuerwehr Dashboard sync service", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "@playwright/test": "^1.47.0", + "pg": "^8.11.0", + "dotenv": "^16.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/pg": "^8.10.0", + "ts-node": "^10.9.0", + "typescript": "^5.0.0" + } +} diff --git a/sync/src/db.ts b/sync/src/db.ts new file mode 100644 index 0000000..b626076 --- /dev/null +++ b/sync/src/db.ts @@ -0,0 +1,173 @@ +import { Pool } from 'pg'; +import { FdiskMember, FdiskAusbildung } from './types'; + +function log(msg: string) { + console.log(`[db] ${new Date().toISOString()} ${msg}`); +} + +/** + * Map FDISK Dienstgrad (abbreviation or full name) to the DB enum value. + * Returns null if no match found — the field will be left unchanged. + */ +function mapDienstgrad(raw: string): string | null { + const map: Record = { + // Abbreviations + 'fa': 'Feuerwehranwärter', + 'fm': 'Feuerwehrmann', + 'ff': 'Feuerwehrfrau', + 'ofm': 'Oberfeuerwehrmann', + 'off': 'Oberfeuerwehrfrau', + 'hfm': 'Hauptfeuerwehrmann', + 'hff': 'Hauptfeuerwehrfrau', + 'lm': 'Löschmeister', + 'olm': 'Oberlöschmeister', + 'hlm': 'Hauptlöschmeister', + 'bm': 'Brandmeister', + 'obm': 'Oberbrandmeister', + 'hbm': 'Hauptbrandmeister', + 'bi': 'Brandinspektor', + 'obi': 'Oberbrandinspektor', + 'boi': 'Brandoberinspektor', + 'bam': 'Brandamtmann', + // Full names (pass-through if already matching) + 'feuerwehranwärter': 'Feuerwehranwärter', + 'feuerwehrmann': 'Feuerwehrmann', + 'feuerwehrfrau': 'Feuerwehrfrau', + 'oberfeuerwehrmann': 'Oberfeuerwehrmann', + 'oberfeuerwehrfrau': 'Oberfeuerwehrfrau', + 'hauptfeuerwehrmann': 'Hauptfeuerwehrmann', + 'hauptfeuerwehrfrau': 'Hauptfeuerwehrfrau', + 'löschmeister': 'Löschmeister', + 'oberlöschmeister': 'Oberlöschmeister', + 'hauptlöschmeister': 'Hauptlöschmeister', + 'brandmeister': 'Brandmeister', + 'oberbrandmeister': 'Oberbrandmeister', + 'hauptbrandmeister': 'Hauptbrandmeister', + 'brandinspektor': 'Brandinspektor', + 'oberbrandinspektor': 'Oberbrandinspektor', + 'brandoberinspektor': 'Brandoberinspektor', + 'brandamtmann': 'Brandamtmann', + }; + return map[raw.trim().toLowerCase()] ?? null; +} + +export async function syncToDatabase( + pool: Pool, + members: FdiskMember[], + ausbildungen: FdiskAusbildung[] +): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + let updated = 0; + let skipped = 0; + + for (const member of members) { + // Find the matching mitglieder_profile by fdisk_standesbuch_nr first, + // then fall back to matching by name (given_name + family_name) + const profileResult = await client.query<{ user_id: string }>( + `SELECT mp.user_id + FROM mitglieder_profile mp + WHERE mp.fdisk_standesbuch_nr = $1`, + [member.standesbuchNr] + ); + + let userId: string | null = null; + + if (profileResult.rows.length > 0) { + userId = profileResult.rows[0].user_id; + } else { + // Fallback: match by name (case-insensitive) + const nameResult = await client.query<{ id: string }>( + `SELECT u.id + FROM users u + JOIN mitglieder_profile mp ON mp.user_id = u.id + WHERE LOWER(u.given_name) = LOWER($1) + AND LOWER(u.family_name) = LOWER($2) + LIMIT 1`, + [member.vorname, member.zuname] + ); + + if (nameResult.rows.length > 0) { + userId = nameResult.rows[0].id; + // Store the Standesbuch-Nr now that we found a match + await client.query( + `UPDATE mitglieder_profile SET fdisk_standesbuch_nr = $1 WHERE user_id = $2`, + [member.standesbuchNr, userId] + ); + log(`Linked ${member.vorname} ${member.zuname} → Standesbuch-Nr ${member.standesbuchNr}`); + } + } + + if (!userId) { + skipped++; + continue; + } + + // Update mitglieder_profile with FDISK data + const dienstgrad = mapDienstgrad(member.dienstgrad); + + await client.query( + `UPDATE mitglieder_profile SET + fdisk_standesbuch_nr = $1, + status = $2, + eintrittsdatum = COALESCE($3::date, eintrittsdatum), + austrittsdatum = $4::date, + geburtsdatum = COALESCE($5::date, geburtsdatum), + ${dienstgrad ? 'dienstgrad = $6,' : ''} + updated_at = NOW() + WHERE user_id = ${dienstgrad ? '$7' : '$6'}`, + dienstgrad + ? [member.standesbuchNr, member.status, member.eintrittsdatum, member.abmeldedatum, member.geburtsdatum, dienstgrad, userId] + : [member.standesbuchNr, member.status, member.eintrittsdatum, member.abmeldedatum, member.geburtsdatum, userId] + ); + + updated++; + } + + log(`Members: ${updated} updated, ${skipped} skipped (no dashboard account)`); + + // Upsert Ausbildungen + let ausbildungUpserted = 0; + let ausbildungSkipped = 0; + + for (const ausb of ausbildungen) { + // Find user_id by standesbuch_nr + const result = await client.query<{ user_id: string }>( + `SELECT user_id FROM mitglieder_profile WHERE fdisk_standesbuch_nr = $1`, + [ausb.standesbuchNr] + ); + + if (result.rows.length === 0) { + ausbildungSkipped++; + continue; + } + + const userId = result.rows[0].user_id; + + await client.query( + `INSERT INTO ausbildung (user_id, kursname, kurs_datum, ablaufdatum, ort, bemerkung, fdisk_sync_key) + VALUES ($1, $2, $3::date, $4::date, $5, $6, $7) + ON CONFLICT (user_id, fdisk_sync_key) DO UPDATE SET + kursname = EXCLUDED.kursname, + kurs_datum = EXCLUDED.kurs_datum, + ablaufdatum = EXCLUDED.ablaufdatum, + ort = EXCLUDED.ort, + bemerkung = EXCLUDED.bemerkung, + updated_at = NOW()`, + [userId, ausb.kursname, ausb.kursDatum, ausb.ablaufdatum, ausb.ort, ausb.bemerkung, ausb.syncKey] + ); + + ausbildungUpserted++; + } + + await client.query('COMMIT'); + log(`Ausbildungen: ${ausbildungUpserted} upserted, ${ausbildungSkipped} skipped`); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} diff --git a/sync/src/index.ts b/sync/src/index.ts new file mode 100644 index 0000000..d31ae2e --- /dev/null +++ b/sync/src/index.ts @@ -0,0 +1,66 @@ +import 'dotenv/config'; +import { Pool } from 'pg'; +import { scrapeAll } from './scraper'; +import { syncToDatabase } from './db'; + +function log(msg: string) { + console.log(`[sync] ${new Date().toISOString()} ${msg}`); +} + +function requireEnv(name: string): string { + const val = process.env[name]; + if (!val) throw new Error(`Missing required environment variable: ${name}`); + return val; +} + +/** Returns milliseconds until the next midnight (00:00:00) in local time. */ +function msUntilMidnight(): number { + const now = new Date(); + const midnight = new Date(now); + midnight.setDate(now.getDate() + 1); + midnight.setHours(0, 0, 0, 0); + return midnight.getTime() - now.getTime(); +} + +async function runSync(): Promise { + const username = requireEnv('FDISK_USERNAME'); + const password = requireEnv('FDISK_PASSWORD'); + + const pool = new Pool({ + host: requireEnv('DB_HOST'), + port: parseInt(process.env.DB_PORT ?? '5432'), + database: requireEnv('DB_NAME'), + user: requireEnv('DB_USER'), + password: requireEnv('DB_PASSWORD'), + }); + + try { + log('Starting FDISK sync'); + const { members, ausbildungen } = await scrapeAll(username, password); + await syncToDatabase(pool, members, ausbildungen); + log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen`); + } finally { + await pool.end(); + } +} + +async function main(): Promise { + log('FDISK sync service started'); + + // Run once immediately on startup so the first sync doesn't wait until midnight + await runSync().catch(err => log(`ERROR during initial sync: ${err.message}`)); + + // Then schedule at midnight every day + while (true) { + const delay = msUntilMidnight(); + const nextRun = new Date(Date.now() + delay); + log(`Next sync scheduled at ${nextRun.toLocaleString()} (in ${Math.round(delay / 60000)} min)`); + await new Promise(r => setTimeout(r, delay)); + await runSync().catch(err => log(`ERROR during scheduled sync: ${err.message}`)); + } +} + +main().catch(err => { + console.error(`[sync] Fatal error: ${err.message}`); + process.exit(1); +}); diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts new file mode 100644 index 0000000..e6466af --- /dev/null +++ b/sync/src/scraper.ts @@ -0,0 +1,251 @@ +import { chromium, Page } from '@playwright/test'; +import { FdiskMember, FdiskAusbildung } from './types'; + +const BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at'; +const LOGIN_URL = `${BASE_URL}/fdisk/`; +const MEMBERS_URL = `${BASE_URL}/fdisk/module/vws/Start.aspx`; + +function log(msg: string) { + console.log(`[scraper] ${new Date().toISOString()} ${msg}`); +} + +/** + * Parse a date string from FDISK (DD.MM.YYYY) to ISO format (YYYY-MM-DD). + * Returns null if empty or unparseable. + */ +function parseDate(raw: string | null | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (!match) return null; + return `${match[3]}-${match[2]}-${match[1]}`; +} + +/** + * Extract text content from a cell, trimmed, or null if empty. + */ +function cellText(text: string | undefined | null): string | null { + const t = (text ?? '').trim(); + return t || null; +} + +export async function scrapeAll(username: string, password: string): Promise<{ + members: FdiskMember[]; + ausbildungen: FdiskAusbildung[]; +}> { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }); + const page = await context.newPage(); + + try { + await login(page, username, password); + const members = await scrapeMembers(page); + log(`Found ${members.length} members`); + + const ausbildungen: FdiskAusbildung[] = []; + for (const member of members) { + if (!member.detailUrl) continue; + try { + const quals = await scrapeMemberAusbildung(page, member); + ausbildungen.push(...quals); + log(` ${member.vorname} ${member.zuname}: ${quals.length} Ausbildungen`); + // polite delay between requests + await page.waitForTimeout(500); + } catch (err) { + log(` WARN: could not scrape Ausbildung for ${member.vorname} ${member.zuname}: ${err}`); + } + } + + return { members, ausbildungen }; + } finally { + await browser.close(); + } +} + +async function login(page: Page, username: string, password: string): Promise { + log(`Navigating to ${LOGIN_URL}`); + await page.goto(LOGIN_URL, { waitUntil: 'networkidle' }); + + // ASP.NET WebForms login — try common selector patterns + // Adjust these selectors if login fails + const usernameField = page.locator('input[type="text"], input[name*="user"], input[name*="User"], input[id*="user"], input[id*="User"]').first(); + const passwordField = page.locator('input[type="password"]').first(); + + await usernameField.fill(username); + await passwordField.fill(password); + + // Submit — look for a login/submit button + const submitButton = page.locator('input[type="submit"], button[type="submit"]').first(); + await Promise.all([ + page.waitForNavigation({ waitUntil: 'networkidle' }), + submitButton.click(), + ]); + + // Verify we're logged in by checking we're not still on the login page + const currentUrl = page.url(); + if (currentUrl.includes('login') || currentUrl.includes('Login') || currentUrl === LOGIN_URL) { + throw new Error(`Login failed — still on login page: ${currentUrl}`); + } + log(`Logged in successfully, redirected to: ${currentUrl}`); +} + +async function scrapeMembers(page: Page): Promise { + log(`Navigating to members list: ${MEMBERS_URL}`); + await page.goto(MEMBERS_URL, { waitUntil: 'networkidle' }); + + // Wait for the member table to appear + // ASP.NET GridView renders as an HTML table — find the data table + await page.waitForSelector('table', { timeout: 15000 }); + + // Find the main data table (likely the one with the most rows) + // Columns: Status, St.-Nr., Dienstgrad, Vorname, Zuname, Geburtsdatum, SVNR, Eintrittsdatum, Abmeldedatum + const rows = await page.$$eval('table tr', (rows) => { + return rows.map(row => { + const cells = Array.from(row.querySelectorAll('td')); + const link = row.querySelector('a'); + return { + cells: cells.map(c => c.textContent?.trim() ?? ''), + href: link?.href ?? null, + onclick: link?.getAttribute('onclick') ?? row.getAttribute('onclick') ?? null, + }; + }); + }); + + // Find the header row to determine column positions + const headerRow = await page.$eval('table tr:first-child', (row) => { + const cells = Array.from(row.querySelectorAll('th, td')); + return cells.map(c => c.textContent?.trim().toLowerCase() ?? ''); + }); + + // Detect column indices from headers + const colIdx = { + status: headerRow.findIndex(h => h.includes('status')), + standesbuchNr: headerRow.findIndex(h => h.includes('st.-nr') || h.includes('stnr') || h.includes('nr')), + dienstgrad: headerRow.findIndex(h => h.includes('dienstgrad')), + vorname: headerRow.findIndex(h => h.includes('vorname')), + zuname: headerRow.findIndex(h => h.includes('zuname') || h.includes('nachname')), + geburtsdatum: headerRow.findIndex(h => h.includes('geburt')), + svnr: headerRow.findIndex(h => h.includes('svnr') || h.includes('sv-nr')), + eintrittsdatum: headerRow.findIndex(h => h.includes('eintritt')), + abmeldedatum: headerRow.findIndex(h => h.includes('abmeld')), + }; + + log(`Detected columns: ${JSON.stringify(colIdx)}`); + + // Fallback to positional columns if detection failed + // Based on screenshot: Status(0), St.-Nr.(1), Dienstgrad(2), Vorname(3), Zuname(4), + // Geburtsdatum(5), SVNR(6), Eintrittsdatum(7), Abmeldedatum(8) + if (colIdx.standesbuchNr === -1) colIdx.standesbuchNr = 1; + if (colIdx.dienstgrad === -1) colIdx.dienstgrad = 2; + if (colIdx.vorname === -1) colIdx.vorname = 3; + if (colIdx.zuname === -1) colIdx.zuname = 4; + if (colIdx.geburtsdatum === -1) colIdx.geburtsdatum = 5; + if (colIdx.svnr === -1) colIdx.svnr = 6; + if (colIdx.eintrittsdatum === -1) colIdx.eintrittsdatum = 7; + if (colIdx.abmeldedatum === -1) colIdx.abmeldedatum = 8; + + const members: FdiskMember[] = []; + + for (const row of rows) { + const { cells, href, onclick } = row; + // Skip header rows and empty rows + if (cells.length < 5) continue; + const stnr = cellText(cells[colIdx.standesbuchNr]); + const vorname = cellText(cells[colIdx.vorname]); + const zuname = cellText(cells[colIdx.zuname]); + if (!stnr || !vorname || !zuname) continue; + + const abmeldedatum = parseDate(cells[colIdx.abmeldedatum]); + + members.push({ + standesbuchNr: stnr, + dienstgrad: cellText(cells[colIdx.dienstgrad]) ?? '', + vorname, + zuname, + geburtsdatum: parseDate(cells[colIdx.geburtsdatum]), + svnr: cellText(cells[colIdx.svnr]), + eintrittsdatum: parseDate(cells[colIdx.eintrittsdatum]), + abmeldedatum, + status: abmeldedatum ? 'ausgetreten' : 'aktiv', + detailUrl: href, + }); + } + + return members; +} + +async function scrapeMemberAusbildung(page: Page, member: FdiskMember): Promise { + if (!member.detailUrl) return []; + + await page.goto(member.detailUrl, { waitUntil: 'networkidle' }); + + // Look for Ausbildungsliste section — it's likely a table or list + // Try to find it by heading text + const ausbildungSection = page.locator('text=Ausbildung, text=Ausbildungsliste').first(); + const hasSec = await ausbildungSection.isVisible().catch(() => false); + + if (!hasSec) { + // Try navigating to an Ausbildung tab/link if present + const ausbildungLink = page.locator('a:has-text("Ausbildung")').first(); + const hasLink = await ausbildungLink.isVisible().catch(() => false); + if (hasLink) { + await Promise.all([ + page.waitForNavigation({ waitUntil: 'networkidle' }).catch(() => {}), + ausbildungLink.click(), + ]); + } + } + + // Parse the qualification table + // Expected columns: Kursname, Datum, Ablaufdatum, Ort, Bemerkung (may vary) + const tables = await page.$$('table'); + const ausbildungen: FdiskAusbildung[] = []; + + for (const table of tables) { + const rows = await table.$$eval('tr', (rows) => { + return rows.map(row => ({ + cells: Array.from(row.querySelectorAll('td, th')).map(c => c.textContent?.trim() ?? ''), + })); + }); + + if (rows.length < 2) continue; + + // Detect if this looks like an Ausbildung table + const header = rows[0].cells.map(c => c.toLowerCase()); + const isAusbildungTable = + header.some(h => h.includes('kurs') || h.includes('ausbildung') || h.includes('bezeichnung')); + + if (!isAusbildungTable) continue; + + const kursnameIdx = header.findIndex(h => h.includes('kurs') || h.includes('ausbildung') || h.includes('bezeichnung')); + const datumIdx = header.findIndex(h => h.includes('datum') || h.includes('abschluss')); + const ablaufIdx = header.findIndex(h => h.includes('ablauf') || h.includes('gültig')); + const ortIdx = header.findIndex(h => h.includes('ort')); + const bemIdx = header.findIndex(h => h.includes('bem') || h.includes('info')); + + for (const row of rows.slice(1)) { + const kursname = cellText(row.cells[kursnameIdx >= 0 ? kursnameIdx : 0]); + if (!kursname) continue; + + const kursDatum = parseDate(datumIdx >= 0 ? row.cells[datumIdx] : null); + const syncKey = `${member.standesbuchNr}::${kursname}::${kursDatum ?? ''}`; + + ausbildungen.push({ + standesbuchNr: member.standesbuchNr, + kursname, + kursDatum, + ablaufdatum: parseDate(ablaufIdx >= 0 ? row.cells[ablaufIdx] : null), + ort: ortIdx >= 0 ? cellText(row.cells[ortIdx]) : null, + bemerkung: bemIdx >= 0 ? cellText(row.cells[bemIdx]) : null, + syncKey, + }); + } + + break; // only process the first Ausbildung table found + } + + return ausbildungen; +} diff --git a/sync/src/types.ts b/sync/src/types.ts new file mode 100644 index 0000000..d05a39b --- /dev/null +++ b/sync/src/types.ts @@ -0,0 +1,25 @@ +export interface FdiskMember { + standesbuchNr: string; + dienstgrad: string; + vorname: string; + zuname: string; + geburtsdatum: string | null; + svnr: string | null; + eintrittsdatum: string | null; + abmeldedatum: string | null; + /** 'aktiv' if no Abmeldedatum, 'ausgetreten' otherwise */ + status: 'aktiv' | 'ausgetreten'; + /** URL or identifier to navigate to the member detail page */ + detailUrl: string | null; +} + +export interface FdiskAusbildung { + standesbuchNr: string; + kursname: string; + kursDatum: string | null; + ablaufdatum: string | null; + ort: string | null; + bemerkung: string | null; + /** Unique key built from standesbuchNr + kursname + kursDatum for deduplication */ + syncKey: string; +} diff --git a/sync/tsconfig.json b/sync/tsconfig.json new file mode 100644 index 0000000..df3ba65 --- /dev/null +++ b/sync/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}