From e9463c1c66c0473d0e2e2c14846e22c61f3d0290 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 5 Mar 2026 18:07:18 +0100 Subject: [PATCH] add vikunja integration --- .env.example | 13 ++ backend/src/app.ts | 2 + backend/src/config/environment.ts | 8 + backend/src/controllers/vikunja.controller.ts | 94 ++++++++++ backend/src/routes/vikunja.routes.ts | 12 ++ backend/src/services/vikunja.service.ts | 112 +++++++++++ .../dashboard/VikunjaMyTasksWidget.tsx | 175 ++++++++++++++++++ .../dashboard/VikunjaOverdueNotifier.tsx | 20 ++ .../dashboard/VikunjaQuickAddWidget.tsx | 155 ++++++++++++++++ frontend/src/components/dashboard/index.ts | 3 + frontend/src/pages/Dashboard.tsx | 24 +++ frontend/src/services/vikunja.ts | 42 +++++ frontend/src/types/vikunja.types.ts | 23 +++ 13 files changed, 683 insertions(+) create mode 100644 backend/src/controllers/vikunja.controller.ts create mode 100644 backend/src/routes/vikunja.routes.ts create mode 100644 backend/src/services/vikunja.service.ts create mode 100644 frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx create mode 100644 frontend/src/components/dashboard/VikunjaOverdueNotifier.tsx create mode 100644 frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx create mode 100644 frontend/src/services/vikunja.ts create mode 100644 frontend/src/types/vikunja.types.ts diff --git a/.env.example b/.env.example index 7d5a2e2..335cb25 100644 --- a/.env.example +++ b/.env.example @@ -176,6 +176,19 @@ BOOKSTACK_TOKEN_ID=your_bookstack_token_id # WARNING: Keep this secret! BOOKSTACK_TOKEN_SECRET=your_bookstack_token_secret +# ============================================================================ +# VIKUNJA CONFIGURATION +# ============================================================================ + +# Vikunja base URL +# The URL of your Vikunja instance (without trailing slash) +VIKUNJA_URL=https://tasks.feuerwehr-rems.at + +# Vikunja API Token +# Create via Vikunja user settings → API Tokens +# WARNING: Keep this secret! +VIKUNJA_API_TOKEN=your_vikunja_api_token + # ============================================================================ # LOGGING CONFIGURATION (Optional) # ============================================================================ diff --git a/backend/src/app.ts b/backend/src/app.ts index f308222..cde779b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -82,6 +82,7 @@ import eventsRoutes from './routes/events.routes'; import bookingRoutes from './routes/booking.routes'; import notificationRoutes from './routes/notification.routes'; import bookstackRoutes from './routes/bookstack.routes'; +import vikunjaRoutes from './routes/vikunja.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -97,6 +98,7 @@ app.use('/api/events', eventsRoutes); app.use('/api/bookings', bookingRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/bookstack', bookstackRoutes); +app.use('/api/vikunja', vikunjaRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts index b617f6c..53de72a 100644 --- a/backend/src/config/environment.ts +++ b/backend/src/config/environment.ts @@ -38,6 +38,10 @@ interface EnvironmentConfig { tokenId: string; tokenSecret: string; }; + vikunja: { + url: string; + apiToken: string; + }; } const environment: EnvironmentConfig = { @@ -73,6 +77,10 @@ const environment: EnvironmentConfig = { tokenId: process.env.BOOKSTACK_TOKEN_ID || '', tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '', }, + vikunja: { + url: process.env.VIKUNJA_URL || '', + apiToken: process.env.VIKUNJA_API_TOKEN || '', + }, }; export default environment; diff --git a/backend/src/controllers/vikunja.controller.ts b/backend/src/controllers/vikunja.controller.ts new file mode 100644 index 0000000..9247831 --- /dev/null +++ b/backend/src/controllers/vikunja.controller.ts @@ -0,0 +1,94 @@ +import { Request, Response } from 'express'; +import vikunjaService from '../services/vikunja.service'; +import notificationService from '../services/notification.service'; +import environment from '../config/environment'; +import logger from '../utils/logger'; + +class VikunjaController { + async getMyTasks(_req: Request, res: Response): Promise { + if (!environment.vikunja.url) { + res.status(200).json({ success: true, data: [], configured: false }); + return; + } + try { + const tasks = await vikunjaService.getMyTasks(); + res.status(200).json({ success: true, data: tasks, configured: true }); + } catch (error) { + logger.error('VikunjaController.getMyTasks error', { error }); + res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' }); + } + } + + async getOverdueTasks(req: Request, res: Response): Promise { + if (!environment.vikunja.url) { + res.status(200).json({ success: true, data: [], configured: false }); + return; + } + try { + const tasks = await vikunjaService.getOverdueTasks(); + + // Side-effect: create notifications for each overdue task (dedup via DB constraint) + if (req.user?.id && tasks.length > 0) { + const userId = req.user.id; + for (const task of tasks) { + await notificationService.createNotification({ + user_id: userId, + typ: 'vikunja_task', + titel: 'Überfällige Aufgabe', + nachricht: task.title, + schwere: 'warnung', + link: environment.vikunja.url ? `${environment.vikunja.url}/tasks/${task.id}` : undefined, + quell_id: String(task.id), + quell_typ: 'vikunja_task', + }); + } + } + + res.status(200).json({ success: true, data: tasks, configured: true }); + } catch (error) { + logger.error('VikunjaController.getOverdueTasks error', { error }); + res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' }); + } + } + + async getProjects(_req: Request, res: Response): Promise { + if (!environment.vikunja.url) { + res.status(200).json({ success: true, data: [], configured: false }); + return; + } + try { + const projects = await vikunjaService.getProjects(); + res.status(200).json({ success: true, data: projects, configured: true }); + } catch (error) { + logger.error('VikunjaController.getProjects error', { error }); + res.status(500).json({ success: false, message: 'Vikunja-Projekte konnten nicht geladen werden' }); + } + } + + async createTask(req: Request, res: Response): Promise { + if (!environment.vikunja.url) { + res.status(503).json({ success: false, message: 'Vikunja ist nicht eingerichtet' }); + return; + } + const { projectId, title, dueDate } = req.body as { + projectId?: number; + title?: string; + dueDate?: string; + }; + + if (!projectId || !title || title.trim().length === 0) { + res.status(400).json({ success: false, message: 'Projekt und Titel sind erforderlich' }); + return; + } + + try { + const task = await vikunjaService.createTask(projectId, title.trim(), dueDate); + res.status(201).json({ success: true, data: task }); + } catch (error) { + logger.error('VikunjaController.createTask error', { error }); + res.status(500).json({ success: false, message: 'Aufgabe konnte nicht erstellt werden' }); + } + } +} + +export default new VikunjaController(); diff --git a/backend/src/routes/vikunja.routes.ts b/backend/src/routes/vikunja.routes.ts new file mode 100644 index 0000000..d2df9b9 --- /dev/null +++ b/backend/src/routes/vikunja.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import vikunjaController from '../controllers/vikunja.controller'; +import { authenticate } from '../middleware/auth.middleware'; + +const router = Router(); + +router.get('/tasks', authenticate, vikunjaController.getMyTasks.bind(vikunjaController)); +router.get('/overdue', authenticate, vikunjaController.getOverdueTasks.bind(vikunjaController)); +router.get('/projects', authenticate, vikunjaController.getProjects.bind(vikunjaController)); +router.post('/tasks', authenticate, vikunjaController.createTask.bind(vikunjaController)); + +export default router; diff --git a/backend/src/services/vikunja.service.ts b/backend/src/services/vikunja.service.ts new file mode 100644 index 0000000..0b6fa91 --- /dev/null +++ b/backend/src/services/vikunja.service.ts @@ -0,0 +1,112 @@ +import axios from 'axios'; +import environment from '../config/environment'; +import logger from '../utils/logger'; + +export interface VikunjaTask { + id: number; + title: string; + done: boolean; + due_date: string | null; + priority: number; + project_id: number; +} + +export interface VikunjaProject { + id: number; + title: string; +} + +function buildHeaders(): Record { + return { + 'Authorization': `Bearer ${environment.vikunja.apiToken}`, + 'Content-Type': 'application/json', + }; +} + +async function getMyTasks(): Promise { + const { vikunja } = environment; + if (!vikunja.url) { + throw new Error('VIKUNJA_URL is not configured'); + } + + try { + const response = await axios.get( + `${vikunja.url}/api/v1/tasks/all`, + { headers: buildHeaders() }, + ); + return (response.data ?? []).filter((t) => !t.done); + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error('Vikunja getMyTasks failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + } + logger.error('VikunjaService.getMyTasks failed', { error }); + throw new Error('Failed to fetch Vikunja tasks'); + } +} + +async function getOverdueTasks(): Promise { + const tasks = await getMyTasks(); + const now = new Date(); + return tasks.filter((t) => { + if (!t.due_date) return false; + return new Date(t.due_date) < now; + }); +} + +async function getProjects(): Promise { + const { vikunja } = environment; + if (!vikunja.url) { + throw new Error('VIKUNJA_URL is not configured'); + } + + try { + const response = await axios.get( + `${vikunja.url}/api/v1/projects`, + { headers: buildHeaders() }, + ); + return response.data ?? []; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error('Vikunja getProjects failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + } + logger.error('VikunjaService.getProjects failed', { error }); + throw new Error('Failed to fetch Vikunja projects'); + } +} + +async function createTask(projectId: number, title: string, dueDate?: string): Promise { + const { vikunja } = environment; + if (!vikunja.url) { + throw new Error('VIKUNJA_URL is not configured'); + } + + try { + const body: Record = { title }; + if (dueDate) { + body.due_date = dueDate; + } + const response = await axios.put( + `${vikunja.url}/api/v1/projects/${projectId}/tasks`, + body, + { headers: buildHeaders() }, + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error('Vikunja createTask failed', { + status: error.response?.status, + statusText: error.response?.statusText, + }); + } + logger.error('VikunjaService.createTask failed', { error }); + throw new Error('Failed to create Vikunja task'); + } +} + +export default { getMyTasks, getOverdueTasks, getProjects, createTask }; diff --git a/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx b/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx new file mode 100644 index 0000000..632aa6a --- /dev/null +++ b/frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { + Card, + CardContent, + Typography, + Box, + Divider, + Skeleton, + Chip, +} from '@mui/material'; +import { AssignmentInd } from '@mui/icons-material'; +import { useQuery } from '@tanstack/react-query'; +import { format, isPast } from 'date-fns'; +import { de } from 'date-fns/locale'; +import { vikunjaApi } from '../../services/vikunja'; +import type { VikunjaTask } from '../../types/vikunja.types'; + +const PRIORITY_LABELS: Record = { + 0: { label: 'Keine', color: 'default' }, + 1: { label: 'Niedrig', color: 'default' }, + 2: { label: 'Mittel', color: 'default' }, + 3: { label: 'Hoch', color: 'warning' }, + 4: { label: 'Kritisch', color: 'error' }, + 5: { label: 'Dringend', color: 'error' }, +}; + +const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: string }> = ({ + task, + showDivider, + vikunjaUrl, +}) => { + const handleClick = () => { + window.open(`${vikunjaUrl}/tasks/${task.id}`, '_blank', 'noopener,noreferrer'); + }; + + const dueDateStr = task.due_date + ? format(new Date(task.due_date), 'dd. MMM', { locale: de }) + : null; + const overdue = task.due_date ? isPast(new Date(task.due_date)) : false; + const priority = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0]; + + return ( + <> + + + + {task.title} + + + {dueDateStr && ( + + {dueDateStr} + + )} + {task.priority > 0 && ( + + )} + + + + {showDivider && } + + ); +}; + +const VikunjaMyTasksWidget: React.FC = () => { + const { data, isLoading, isError } = useQuery({ + queryKey: ['vikunja-my-tasks'], + queryFn: () => vikunjaApi.getMyTasks(), + refetchInterval: 5 * 60 * 1000, + retry: 1, + }); + + const configured = data?.configured ?? true; + const tasks = data?.data ?? []; + + if (!configured) { + return ( + + + + + + Meine Aufgaben + + + + Vikunja nicht eingerichtet + + + + ); + } + + return ( + + + + + + Meine Aufgaben + + {!isLoading && !isError && tasks.length > 0 && ( + + )} + + + {isLoading && ( + + {[1, 2, 3].map((n) => ( + + + + + ))} + + )} + + {isError && ( + + Vikunja nicht erreichbar + + )} + + {!isLoading && !isError && tasks.length === 0 && ( + + Keine offenen Aufgaben + + )} + + {!isLoading && !isError && tasks.length > 0 && ( + + {tasks.map((task, index) => ( + + ))} + + )} + + + ); +}; + +export default VikunjaMyTasksWidget; diff --git a/frontend/src/components/dashboard/VikunjaOverdueNotifier.tsx b/frontend/src/components/dashboard/VikunjaOverdueNotifier.tsx new file mode 100644 index 0000000..de5afce --- /dev/null +++ b/frontend/src/components/dashboard/VikunjaOverdueNotifier.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { vikunjaApi } from '../../services/vikunja'; + +/** + * Invisible component — polls /api/vikunja/overdue every 10 minutes. + * The backend creates notifications as a side-effect when polled. + */ +const VikunjaOverdueNotifier: React.FC = () => { + useQuery({ + queryKey: ['vikunja-overdue'], + queryFn: () => vikunjaApi.getOverdueTasks(), + refetchInterval: 10 * 60 * 1000, + retry: 1, + }); + + return null; +}; + +export default VikunjaOverdueNotifier; diff --git a/frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx b/frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx new file mode 100644 index 0000000..e3aba5e --- /dev/null +++ b/frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx @@ -0,0 +1,155 @@ +import React, { useState } from 'react'; +import { + Card, + CardContent, + Typography, + Box, + TextField, + Button, + MenuItem, + Select, + FormControl, + InputLabel, + Skeleton, + SelectChangeEvent, +} from '@mui/material'; +import { AddTask } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { vikunjaApi } from '../../services/vikunja'; +import { useNotification } from '../../contexts/NotificationContext'; + +const VikunjaQuickAddWidget: React.FC = () => { + const [title, setTitle] = useState(''); + const [projectId, setProjectId] = useState(''); + const [dueDate, setDueDate] = useState(''); + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + + const { data: projectsData, isLoading: projectsLoading } = useQuery({ + queryKey: ['vikunja-projects'], + queryFn: () => vikunjaApi.getProjects(), + refetchInterval: 10 * 60 * 1000, + retry: 1, + }); + + const configured = projectsData?.configured ?? true; + const projects = projectsData?.data ?? []; + + const mutation = useMutation({ + mutationFn: () => + vikunjaApi.createTask( + projectId as number, + title, + dueDate || undefined, + ), + onSuccess: () => { + showSuccess('Aufgabe erstellt'); + setTitle(''); + setDueDate(''); + setProjectId(''); + queryClient.invalidateQueries({ queryKey: ['vikunja-my-tasks'] }); + }, + onError: () => { + showError('Aufgabe konnte nicht erstellt werden'); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim() || projectId === '') return; + mutation.mutate(); + }; + + if (!configured) { + return ( + + + + + + Aufgabe erstellen + + + + Vikunja nicht eingerichtet + + + + ); + } + + return ( + + + + + Aufgabe erstellen + + + {projectsLoading ? ( + + + + + + ) : ( + + + Projekt + + + + setTitle(e.target.value)} + inputProps={{ maxLength: 250 }} + /> + + setDueDate(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + )} + + + ); +}; + +export default VikunjaQuickAddWidget; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 2fdd9d4..8007fb7 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -6,3 +6,6 @@ export { default as PersonalWarningsBanner } from './PersonalWarningsBanner'; export { default as UpcomingEventsWidget } from './UpcomingEventsWidget'; export { default as BookStackRecentWidget } from './BookStackRecentWidget'; export { default as BookStackSearchWidget } from './BookStackSearchWidget'; +export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget'; +export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget'; +export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier'; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c0c3cbf..38c53f4 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -15,6 +15,9 @@ import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCa import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard'; import BookStackRecentWidget from '../components/dashboard/BookStackRecentWidget'; import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget'; +import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget'; +import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget'; +import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier'; function Dashboard() { const { user } = useAuth(); const canViewAtemschutz = user?.groups?.some(g => @@ -124,6 +127,27 @@ function Dashboard() { + + {/* Vikunja — My Tasks Widget */} + + + + + + + + + {/* Vikunja — Quick Add Widget */} + + + + + + + + + {/* Vikunja — Overdue Notifier (invisible, polling component) */} + diff --git a/frontend/src/services/vikunja.ts b/frontend/src/services/vikunja.ts new file mode 100644 index 0000000..967041c --- /dev/null +++ b/frontend/src/services/vikunja.ts @@ -0,0 +1,42 @@ +import { api } from './api'; +import type { + VikunjaTasksResponse, + VikunjaProjectsResponse, + VikunjaTask, +} from '../types/vikunja.types'; + +interface ApiResponse { + success: boolean; + data: T; + configured: boolean; +} + +export const vikunjaApi = { + getMyTasks(): Promise { + return api + .get>('/api/vikunja/tasks') + .then((r) => ({ configured: r.data.configured, data: r.data.data })); + }, + + getOverdueTasks(): Promise { + return api + .get>('/api/vikunja/overdue') + .then((r) => ({ configured: r.data.configured, data: r.data.data })); + }, + + getProjects(): Promise { + return api + .get>('/api/vikunja/projects') + .then((r) => ({ configured: r.data.configured, data: r.data.data })); + }, + + createTask(projectId: number, title: string, dueDate?: string): Promise<{ data: VikunjaTask }> { + return api + .post<{ success: boolean; data: VikunjaTask }>('/api/vikunja/tasks', { + projectId, + title, + dueDate, + }) + .then((r) => ({ data: r.data.data })); + }, +}; diff --git a/frontend/src/types/vikunja.types.ts b/frontend/src/types/vikunja.types.ts new file mode 100644 index 0000000..855ca95 --- /dev/null +++ b/frontend/src/types/vikunja.types.ts @@ -0,0 +1,23 @@ +export interface VikunjaTask { + id: number; + title: string; + done: boolean; + due_date: string | null; + priority: number; + project_id: number; +} + +export interface VikunjaProject { + id: number; + title: string; +} + +export interface VikunjaTasksResponse { + configured: boolean; + data: VikunjaTask[]; +} + +export interface VikunjaProjectsResponse { + configured: boolean; + data: VikunjaProject[]; +}