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

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

View File

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

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