add vikunja integration

This commit is contained in:
Matthias Hochmeister
2026-03-05 18:07:18 +01:00
parent fb5acd3d52
commit e9463c1c66
13 changed files with 683 additions and 0 deletions

View File

@@ -176,6 +176,19 @@ BOOKSTACK_TOKEN_ID=your_bookstack_token_id
# WARNING: Keep this secret! # WARNING: Keep this secret!
BOOKSTACK_TOKEN_SECRET=your_bookstack_token_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) # LOGGING CONFIGURATION (Optional)
# ============================================================================ # ============================================================================

View File

@@ -82,6 +82,7 @@ import eventsRoutes from './routes/events.routes';
import bookingRoutes from './routes/booking.routes'; import bookingRoutes from './routes/booking.routes';
import notificationRoutes from './routes/notification.routes'; import notificationRoutes from './routes/notification.routes';
import bookstackRoutes from './routes/bookstack.routes'; import bookstackRoutes from './routes/bookstack.routes';
import vikunjaRoutes from './routes/vikunja.routes';
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes); app.use('/api/user', userRoutes);
@@ -97,6 +98,7 @@ app.use('/api/events', eventsRoutes);
app.use('/api/bookings', bookingRoutes); app.use('/api/bookings', bookingRoutes);
app.use('/api/notifications', notificationRoutes); app.use('/api/notifications', notificationRoutes);
app.use('/api/bookstack', bookstackRoutes); app.use('/api/bookstack', bookstackRoutes);
app.use('/api/vikunja', vikunjaRoutes);
// 404 handler // 404 handler
app.use(notFoundHandler); app.use(notFoundHandler);

View File

@@ -38,6 +38,10 @@ interface EnvironmentConfig {
tokenId: string; tokenId: string;
tokenSecret: string; tokenSecret: string;
}; };
vikunja: {
url: string;
apiToken: string;
};
} }
const environment: EnvironmentConfig = { const environment: EnvironmentConfig = {
@@ -73,6 +77,10 @@ const environment: EnvironmentConfig = {
tokenId: process.env.BOOKSTACK_TOKEN_ID || '', tokenId: process.env.BOOKSTACK_TOKEN_ID || '',
tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '', tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '',
}, },
vikunja: {
url: process.env.VIKUNJA_URL || '',
apiToken: process.env.VIKUNJA_API_TOKEN || '',
},
}; };
export default environment; export default environment;

View File

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

View File

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

View File

@@ -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<string, string> {
return {
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
'Content-Type': 'application/json',
};
}
async function getMyTasks(): Promise<VikunjaTask[]> {
const { vikunja } = environment;
if (!vikunja.url) {
throw new Error('VIKUNJA_URL is not configured');
}
try {
const response = await axios.get<VikunjaTask[]>(
`${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<VikunjaTask[]> {
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<VikunjaProject[]> {
const { vikunja } = environment;
if (!vikunja.url) {
throw new Error('VIKUNJA_URL is not configured');
}
try {
const response = await axios.get<VikunjaProject[]>(
`${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<VikunjaTask> {
const { vikunja } = environment;
if (!vikunja.url) {
throw new Error('VIKUNJA_URL is not configured');
}
try {
const body: Record<string, unknown> = { title };
if (dueDate) {
body.due_date = dueDate;
}
const response = await axios.put<VikunjaTask>(
`${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 };

View File

@@ -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<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
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 (
<>
<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 }}>
<Typography variant="subtitle2" noWrap>
{task.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
{dueDateStr && (
<Typography
variant="caption"
color={overdue ? 'error' : 'text.secondary'}
>
{dueDateStr}
</Typography>
)}
{task.priority > 0 && (
<Chip
label={priority.label}
size="small"
color={priority.color}
sx={{ height: 18, fontSize: '0.65rem' }}
/>
)}
</Box>
</Box>
</Box>
{showDivider && <Divider />}
</>
);
};
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 (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AssignmentInd color="disabled" />
<Typography variant="h6" color="text.secondary">
Meine Aufgaben
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht eingerichtet
</Typography>
</CardContent>
</Card>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AssignmentInd color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Meine Aufgaben
</Typography>
{!isLoading && !isError && tasks.length > 0 && (
<Chip label={tasks.length} size="small" color="primary" />
)}
</Box>
{isLoading && (
<Box>
{[1, 2, 3].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="70%" height={22} />
<Skeleton variant="text" width="40%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht erreichbar
</Typography>
)}
{!isLoading && !isError && tasks.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine offenen Aufgaben
</Typography>
)}
{!isLoading && !isError && tasks.length > 0 && (
<Box>
{tasks.map((task, index) => (
<TaskRow
key={task.id}
task={task}
showDivider={index < tasks.length - 1}
vikunjaUrl={import.meta.env.VITE_VIKUNJA_URL ?? ''}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default VikunjaMyTasksWidget;

View File

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

View File

@@ -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<number | ''>('');
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 (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AddTask color="disabled" />
<Typography variant="h6" color="text.secondary">
Aufgabe erstellen
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht eingerichtet
</Typography>
</CardContent>
</Card>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AddTask color="primary" />
<Typography variant="h6">Aufgabe erstellen</Typography>
</Box>
{projectsLoading ? (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth size="small">
<InputLabel>Projekt</InputLabel>
<Select
value={projectId}
label="Projekt"
onChange={(e: SelectChangeEvent<number | ''>) =>
setProjectId(e.target.value as number | '')
}
>
{projects.map((p) => (
<MenuItem key={p.id} value={p.id}>
{p.title}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
size="small"
label="Titel"
value={title}
onChange={(e) => setTitle(e.target.value)}
inputProps={{ maxLength: 250 }}
/>
<TextField
fullWidth
size="small"
label="Fälligkeitsdatum (optional)"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!title.trim() || projectId === '' || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
);
};
export default VikunjaQuickAddWidget;

View File

@@ -6,3 +6,6 @@ export { default as PersonalWarningsBanner } from './PersonalWarningsBanner';
export { default as UpcomingEventsWidget } from './UpcomingEventsWidget'; export { default as UpcomingEventsWidget } from './UpcomingEventsWidget';
export { default as BookStackRecentWidget } from './BookStackRecentWidget'; export { default as BookStackRecentWidget } from './BookStackRecentWidget';
export { default as BookStackSearchWidget } from './BookStackSearchWidget'; export { default as BookStackSearchWidget } from './BookStackSearchWidget';
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';

View File

@@ -15,6 +15,9 @@ import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCa
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard'; import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
import BookStackRecentWidget from '../components/dashboard/BookStackRecentWidget'; import BookStackRecentWidget from '../components/dashboard/BookStackRecentWidget';
import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget'; 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() { function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const canViewAtemschutz = user?.groups?.some(g => const canViewAtemschutz = user?.groups?.some(g =>
@@ -124,6 +127,27 @@ function Dashboard() {
</Box> </Box>
</Fade> </Fade>
</Box> </Box>
{/* Vikunja — My Tasks Widget */}
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
<Box>
<VikunjaMyTasksWidget />
</Box>
</Fade>
</Box>
{/* Vikunja — Quick Add Widget */}
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
<Box>
<VikunjaQuickAddWidget />
</Box>
</Fade>
</Box>
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
<VikunjaOverdueNotifier />
</Box> </Box>
</Container> </Container>
</DashboardLayout> </DashboardLayout>

View File

@@ -0,0 +1,42 @@
import { api } from './api';
import type {
VikunjaTasksResponse,
VikunjaProjectsResponse,
VikunjaTask,
} from '../types/vikunja.types';
interface ApiResponse<T> {
success: boolean;
data: T;
configured: boolean;
}
export const vikunjaApi = {
getMyTasks(): Promise<VikunjaTasksResponse> {
return api
.get<ApiResponse<VikunjaTasksResponse['data']>>('/api/vikunja/tasks')
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
},
getOverdueTasks(): Promise<VikunjaTasksResponse> {
return api
.get<ApiResponse<VikunjaTasksResponse['data']>>('/api/vikunja/overdue')
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
},
getProjects(): Promise<VikunjaProjectsResponse> {
return api
.get<ApiResponse<VikunjaProjectsResponse['data']>>('/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 }));
},
};

View File

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