add now features

This commit is contained in:
Matthias Hochmeister
2026-03-01 11:50:27 +01:00
parent 73ab6cea07
commit 681acd8203
25 changed files with 1518 additions and 4 deletions

View File

@@ -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
#
# ============================================================================

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<LoginFlowResult> {
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<LoginFlowCredentials | null> {
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<ConversationsResult> {
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 };

View File

@@ -273,6 +273,55 @@ class UserService {
}
}
async updateNextcloudCredentials(userId: string, loginName: string, appPassword: string): Promise<void> {
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<void> {
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
*/

View File

@@ -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

View File

@@ -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 (
<>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" noWrap>
{conversation.displayName}
</Typography>
{conversation.unreadMessages > 0 && (
<Chip
label={conversation.unreadMessages}
size="small"
color="primary"
sx={{ height: 20, fontSize: '0.7rem', minWidth: 24 }}
/>
)}
</Box>
{conversation.lastMessage && (
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{ mt: 0.25 }}
>
{conversation.lastMessage.author}: {conversation.lastMessage.text}
</Typography>
)}
</Box>
{relativeTime && (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
const NextcloudTalkWidget: React.FC = () => {
const queryClient = useQueryClient();
const [isConnecting, setIsConnecting] = useState(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const popupRef = useRef<Window | null>(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 (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 3,
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Forum color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Nextcloud Talk
</Typography>
{connected && totalUnread > 0 && (
<Chip
label={`${totalUnread} ungelesen`}
size="small"
color="primary"
variant="outlined"
/>
)}
{connected && (
<Tooltip title="Verbindung trennen">
<IconButton size="small" onClick={handleDisconnect}>
<LinkOff fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isLoading && (
<Box>
{[1, 2, 3].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="60%" height={22} />
<Skeleton variant="text" width="90%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Nextcloud nicht erreichbar
</Typography>
)}
{!isLoading && !isError && !connected && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 3, gap: 2 }}>
{isConnecting ? (
<>
<CircularProgress size={32} />
<Typography variant="body2" color="text.secondary">
Warte auf Bestätigung...
</Typography>
</>
) : (
<>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
Verbinde dein Nextcloud-Konto, um Chats zu sehen.
</Typography>
<Button
variant="outlined"
startIcon={<Cloud />}
onClick={handleConnect}
>
Mit Nextcloud verbinden
</Button>
</>
)}
</Box>
)}
{!isLoading && !isError && connected && conversations.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine aktiven Chats
</Typography>
)}
{!isLoading && !isError && connected && conversations.length > 0 && (
<Box>
{conversations.map((conversation, index) => (
<ConversationRow
key={conversation.token}
conversation={conversation}
showDivider={index < conversations.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default NextcloudTalkWidget;

View File

@@ -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() {
)}
</Grid>
{/* Nextcloud Talk Widget */}
<Grid item xs={12} md={6} lg={4}>
{dataLoading ? (
<SkeletonCard variant="basic" />
) : (
<Fade in={true} timeout={600} style={{ transitionDelay: '520ms' }}>
<Box>
<NextcloudTalkWidget />
</Box>
</Fade>
)}
</Grid>
{/* Activity Feed */}
<Grid item xs={12}>
{dataLoading ? (

View File

@@ -0,0 +1,33 @@
import { api } from './api';
import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData } from '../types/nextcloud.types';
interface ApiResponse<T> {
success: boolean;
data: T;
}
export const nextcloudApi = {
getConversations(): Promise<NextcloudTalkData> {
return api
.get<ApiResponse<NextcloudTalkData>>('/api/nextcloud/talk')
.then((r) => r.data.data);
},
connect(): Promise<NextcloudConnectData> {
return api
.post<ApiResponse<NextcloudConnectData>>('/api/nextcloud/talk/connect')
.then((r) => r.data.data);
},
poll(pollToken: string, pollEndpoint: string): Promise<NextcloudPollData> {
return api
.post<ApiResponse<NextcloudPollData>>('/api/nextcloud/talk/poll', { pollToken, pollEndpoint })
.then((r) => r.data.data);
},
disconnect(): Promise<void> {
return api
.delete('/api/nextcloud/talk/connect')
.then(() => undefined);
},
};

View File

@@ -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;
}

View File

@@ -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

11
sync/.env.example Normal file
View File

@@ -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=

14
sync/Dockerfile Normal file
View File

@@ -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"]

22
sync/package.json Normal file
View File

@@ -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"
}
}

173
sync/src/db.ts Normal file
View File

@@ -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<string, string> = {
// 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<void> {
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();
}
}

66
sync/src/index.ts Normal file
View File

@@ -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<void> {
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<void> {
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);
});

251
sync/src/scraper.ts Normal file
View File

@@ -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<void> {
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<FdiskMember[]> {
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<FdiskAusbildung[]> {
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;
}

25
sync/src/types.ts Normal file
View File

@@ -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;
}

15
sync/tsconfig.json Normal file
View File

@@ -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"]
}