new features, bookstack

This commit is contained in:
Matthias Hochmeister
2026-03-03 21:30:38 +01:00
parent 817329db70
commit d3561c1109
32 changed files with 1923 additions and 207 deletions

View File

@@ -81,6 +81,7 @@ import atemschutzRoutes from './routes/atemschutz.routes';
import eventsRoutes from './routes/events.routes';
import bookingRoutes from './routes/booking.routes';
import notificationRoutes from './routes/notification.routes';
import bookstackRoutes from './routes/bookstack.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -95,6 +96,7 @@ app.use('/api/nextcloud/talk', nextcloudRoutes);
app.use('/api/events', eventsRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/bookstack', bookstackRoutes);
// 404 handler
app.use(notFoundHandler);

View File

@@ -33,6 +33,11 @@ interface EnvironmentConfig {
redirectUri: string;
};
nextcloudUrl: string;
bookstack: {
url: string;
tokenId: string;
tokenSecret: string;
};
}
const environment: EnvironmentConfig = {
@@ -63,6 +68,11 @@ const environment: EnvironmentConfig = {
redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback',
},
nextcloudUrl: process.env.NEXTCLOUD_URL || '',
bookstack: {
url: process.env.BOOKSTACK_URL || '',
tokenId: process.env.BOOKSTACK_TOKEN_ID || '',
tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '',
},
};
export default environment;

View File

@@ -0,0 +1,41 @@
import { Request, Response } from 'express';
import bookstackService from '../services/bookstack.service';
import environment from '../config/environment';
import logger from '../utils/logger';
class BookStackController {
async getRecent(_req: Request, res: Response): Promise<void> {
if (!environment.bookstack.url) {
res.status(200).json({ success: true, data: [], configured: false });
return;
}
try {
const pages = await bookstackService.getRecentPages();
res.status(200).json({ success: true, data: pages, configured: true });
} catch (error) {
logger.error('BookStackController.getRecent error', { error });
res.status(500).json({ success: false, message: 'BookStack konnte nicht abgefragt werden' });
}
}
async search(req: Request, res: Response): Promise<void> {
if (!environment.bookstack.url) {
res.status(200).json({ success: true, data: [], configured: false });
return;
}
const query = req.query.query as string | undefined;
if (!query || query.trim().length === 0) {
res.status(400).json({ success: false, message: 'Suchbegriff fehlt' });
return;
}
try {
const results = await bookstackService.searchPages(query.trim());
res.status(200).json({ success: true, data: results, configured: true });
} catch (error) {
logger.error('BookStackController.search error', { error });
res.status(500).json({ success: false, message: 'BookStack-Suche fehlgeschlagen' });
}
}
}
export default new BookStackController();

View File

@@ -325,6 +325,43 @@ class EventsController {
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
}
};
// -------------------------------------------------------------------------
// POST /api/events/import
// -------------------------------------------------------------------------
importEvents = async (req: Request, res: Response): Promise<void> => {
try {
const { events } = req.body as { events: unknown[] };
if (!Array.isArray(events) || events.length === 0) {
res.status(400).json({ success: false, message: 'Keine Ereignisse zum Importieren' });
return;
}
const userId = (req.user as any)?.id ?? 'unknown';
const created: number[] = [];
const errors: string[] = [];
for (let i = 0; i < events.length; i++) {
try {
const parsed = CreateVeranstaltungSchema.safeParse(events[i]);
if (!parsed.success) {
errors.push(`Zeile ${i + 2}: ${parsed.error.issues.map((e) => e.message).join(', ')}`);
continue;
}
await eventsService.createEvent(parsed.data, userId);
created.push(i);
} catch (e) {
errors.push(`Zeile ${i + 2}: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`);
}
}
res.status(200).json({
success: true,
data: { created: created.length, errors },
});
} catch (error) {
logger.error('importEvents error', { error });
res.status(500).json({ success: false, message: 'Import fehlgeschlagen' });
}
};
}
export default new EventsController();

View File

@@ -0,0 +1,10 @@
import { Router } from 'express';
import bookstackController from '../controllers/bookstack.controller';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
router.get('/recent', authenticate, bookstackController.getRecent.bind(bookstackController));
router.get('/search', authenticate, bookstackController.search.bind(bookstackController));
export default router;

View File

@@ -104,6 +104,17 @@ router.get(
// Events CRUD
// ---------------------------------------------------------------------------
/**
* POST /api/events/import
* Bulk import events from CSV data. Requires admin or moderator.
*/
router.post(
'/import',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.importEvents.bind(eventsController)
);
/**
* POST /api/events
* Create a new event. Requires admin or moderator.

View File

@@ -0,0 +1,113 @@
import axios from 'axios';
import environment from '../config/environment';
import logger from '../utils/logger';
export interface BookStackPage {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
chapter_id: number;
draft: boolean;
template: boolean;
created_at: string;
updated_at: string;
url: string;
preview_html?: { content: string };
book?: { name: string };
tags?: { name: string; value: string; order: number }[];
createdBy?: { name: string };
updatedBy?: { name: string };
}
export interface BookStackSearchResult {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
url: string;
preview_html: { content: string };
tags: { name: string; value: string; order: number }[];
}
function buildHeaders(): Record<string, string> {
const { bookstack } = environment;
return {
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
'Content-Type': 'application/json',
};
}
async function getRecentPages(): Promise<BookStackPage[]> {
const { bookstack } = environment;
if (!bookstack.url) {
throw new Error('BOOKSTACK_URL is not configured');
}
try {
const response = await axios.get(
`${bookstack.url}/api/pages`,
{
params: { sort: '-updated_at', count: 5 },
headers: buildHeaders(),
},
);
const pages: BookStackPage[] = response.data?.data ?? [];
return pages.map((p) => ({
...p,
url: `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`,
}));
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('BookStack getRecentPages failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
}
logger.error('BookStackService.getRecentPages failed', { error });
throw new Error('Failed to fetch BookStack recent pages');
}
}
async function searchPages(query: string): Promise<BookStackSearchResult[]> {
const { bookstack } = environment;
if (!bookstack.url) {
throw new Error('BOOKSTACK_URL is not configured');
}
try {
const response = await axios.get(
`${bookstack.url}/api/search`,
{
params: { query, count: 8 },
headers: buildHeaders(),
},
);
const results: BookStackSearchResult[] = (response.data?.data ?? [])
.filter((item: any) => item.type === 'page')
.map((item: any) => ({
id: item.id,
name: item.name,
slug: item.slug,
book_id: item.book_id ?? 0,
book_slug: item.book_slug ?? '',
url: `${bookstack.url}/books/${item.book_slug}/page/${item.slug}`,
preview_html: item.preview_html ?? { content: '' },
tags: item.tags ?? [],
}));
return results;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('BookStack searchPages failed', {
status: error.response?.status,
statusText: error.response?.statusText,
});
}
logger.error('BookStackService.searchPages failed', { error });
throw new Error('Failed to search BookStack pages');
}
}
export default { getRecentPages, searchPages };