bug fix for atemschutz

This commit is contained in:
Matthias Hochmeister
2026-03-01 19:19:12 +01:00
parent 2630224edd
commit 6495ca94d1
17 changed files with 5116 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
import { Request, Response } from 'express';
import { ZodError } from 'zod';
import bookingService from '../services/booking.service';
import {
CreateBuchungSchema,
UpdateBuchungSchema,
CancelBuchungSchema,
} from '../models/booking.model';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isValidUUID(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
function handleZodError(res: Response, err: ZodError): void {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: err.flatten().fieldErrors,
});
}
function handleConflictError(res: Response, err: Error): boolean {
if (err.message?.includes('bereits gebucht')) {
res.status(409).json({ success: false, message: err.message });
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
class BookingController {
/**
* GET /api/bookings/calendar?from=&to=&fahrzeugId=
* Returns all non-cancelled bookings overlapping the given date range.
*/
async getCalendarRange(req: Request, res: Response): Promise<void> {
try {
const { from, to, fahrzeugId } = req.query;
if (!from || !to) {
res.status(400).json({ success: false, message: 'from und to sind erforderlich' });
return;
}
const bookings = await bookingService.getBookingsByRange(
new Date(from as string),
new Date(to as string),
fahrzeugId as string | undefined
);
res.json({ success: true, data: bookings });
} catch (error) {
logger.error('Booking getCalendarRange error', { error });
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
}
}
/**
* GET /api/bookings/upcoming?limit=
* Returns the next upcoming non-cancelled bookings.
*/
async getUpcoming(req: Request, res: Response): Promise<void> {
try {
const limit = parseInt(req.query.limit as string) || 20;
const bookings = await bookingService.getUpcoming(limit);
res.json({ success: true, data: bookings });
} catch (error) {
logger.error('Booking getUpcoming error', { error });
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
}
}
/**
* GET /api/bookings/availability?fahrzeugId=&from=&to=
* Returns { available: true } when the vehicle has no conflicting booking.
*/
async checkAvailability(req: Request, res: Response): Promise<void> {
try {
const { fahrzeugId, from, to } = req.query;
if (!fahrzeugId || !from || !to) {
res
.status(400)
.json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' });
return;
}
const hasConflict = await bookingService.checkConflict(
fahrzeugId as string,
new Date(from as string),
new Date(to as string)
);
res.json({ success: true, data: { available: !hasConflict } });
} catch (error) {
logger.error('Booking checkAvailability error', { error });
res.status(500).json({ success: false, message: 'Verfügbarkeit konnte nicht geprüft werden' });
}
}
/**
* GET /api/bookings/:id
* Returns a single booking with all joined fields.
*/
async getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
return;
}
const booking = await bookingService.getById(id);
if (!booking) {
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
return;
}
res.json({ success: true, data: booking });
} catch (error) {
logger.error('Booking getById error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Buchung konnte nicht geladen werden' });
}
}
/**
* POST /api/bookings
* Creates a new vehicle booking.
*/
async create(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateBuchungSchema.safeParse(req.body);
if (!parsed.success) {
handleZodError(res, parsed.error);
return;
}
const booking = await bookingService.create(parsed.data, req.user!.id);
res.status(201).json({ success: true, data: booking });
} catch (error: any) {
if (handleConflictError(res, error)) return;
logger.error('Booking create error', { error });
res.status(500).json({ success: false, message: 'Buchung konnte nicht erstellt werden' });
}
}
/**
* PATCH /api/bookings/:id
* Updates the provided fields of an existing booking.
*/
async update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
return;
}
const parsed = UpdateBuchungSchema.safeParse(req.body);
if (!parsed.success) {
handleZodError(res, parsed.error);
return;
}
if (Object.keys(parsed.data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const booking = await bookingService.update(id, parsed.data);
if (!booking) {
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
return;
}
res.json({ success: true, data: booking });
} catch (error: any) {
if (error?.message === 'No fields to update') {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
if (handleConflictError(res, error)) return;
logger.error('Booking update error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Buchung konnte nicht aktualisiert werden' });
}
}
/**
* DELETE /api/bookings/:id
* Soft-cancels a booking (sets abgesagt=TRUE).
*/
async cancel(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
return;
}
const parsed = CancelBuchungSchema.safeParse(req.body);
if (!parsed.success) {
handleZodError(res, parsed.error);
return;
}
await bookingService.cancel(id, parsed.data.abgesagt_grund);
res.json({ success: true, message: 'Buchung wurde storniert' });
} catch (error) {
logger.error('Booking cancel error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Buchung konnte nicht storniert werden' });
}
}
/**
* DELETE /api/bookings/:id/force
* Hard-deletes a booking record (admin only).
*/
async hardDelete(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
return;
}
await bookingService.delete(id);
res.json({ success: true, message: 'Buchung gelöscht' });
} catch (error) {
logger.error('Booking hardDelete error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Buchung konnte nicht gelöscht werden' });
}
}
/**
* GET /api/bookings/calendar-token
* Returns the user's iCal subscribe token and URL, creating it if needed.
*/
async getCalendarToken(req: Request, res: Response): Promise<void> {
try {
const result = await bookingService.getOrCreateIcalToken(req.user!.id);
res.json({ success: true, data: result });
} catch (error) {
logger.error('Booking getCalendarToken error', { error });
res.status(500).json({ success: false, message: 'Kalender-Token konnte nicht geladen werden' });
}
}
/**
* GET /api/bookings/calendar.ics?token=&fahrzeugId=
* Returns an iCal file for the subscriber. No authentication required
* (token-based access).
*/
async getIcalExport(req: Request, res: Response): Promise<void> {
try {
const { token, fahrzeugId } = req.query;
if (!token) {
res.status(400).send('Token required');
return;
}
const ical = await bookingService.getIcalExport(
token as string,
fahrzeugId as string | undefined
);
if (!ical) {
res.status(404).send('Invalid token');
return;
}
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="fahrzeugbuchungen.ics"');
res.send(ical);
} catch (error) {
logger.error('Booking getIcalExport error', { error });
res.status(500).send('Internal server error');
}
}
}
export default new BookingController();

View File

@@ -0,0 +1,321 @@
import { Request, Response } from 'express';
import eventsService from '../services/events.service';
import {
CreateKategorieSchema,
UpdateKategorieSchema,
CreateVeranstaltungSchema,
UpdateVeranstaltungSchema,
CancelVeranstaltungSchema,
} from '../models/events.model';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Known Authentik groups exposed to the frontend for event targeting
// ---------------------------------------------------------------------------
const KNOWN_GROUPS = [
{ id: 'dashboard_admin', label: 'Administratoren' },
{ id: 'dashboard_moderator', label: 'Moderatoren' },
{ id: 'dashboard_mitglied', label: 'Mitglieder' },
{ id: 'dashboard_fahrmeister', label: 'Fahrmeister' },
{ id: 'dashboard_zeugmeister', label: 'Zeugmeister' },
{ id: 'dashboard_atemschutz', label: 'Atemschutzwart' },
{ id: 'dashboard_jugend', label: 'Feuerwehrjugend' },
{ id: 'dashboard_kommandant', label: 'Kommandanten' },
];
// ---------------------------------------------------------------------------
// Helper — extract userGroups from request
// ---------------------------------------------------------------------------
function getUserGroups(req: Request): string[] {
return (req.user as any)?.groups ?? [];
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
class EventsController {
// -------------------------------------------------------------------------
// GET /api/events/kategorien
// -------------------------------------------------------------------------
listKategorien = async (_req: Request, res: Response): Promise<void> => {
try {
const data = await eventsService.getKategorien();
res.json({ success: true, data });
} catch (error) {
logger.error('listKategorien error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Kategorien' });
}
};
// -------------------------------------------------------------------------
// POST /api/events/kategorien
// -------------------------------------------------------------------------
createKategorie = async (req: Request, res: Response): Promise<void> => {
try {
const parsed = CreateKategorieSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const data = await eventsService.createKategorie(parsed.data, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('createKategorie error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Kategorie' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/events/kategorien/:id
// -------------------------------------------------------------------------
updateKategorie = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const parsed = UpdateKategorieSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const data = await eventsService.updateKategorie(id, parsed.data);
if (!data) {
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
return;
}
res.json({ success: true, data });
} catch (error) {
logger.error('updateKategorie error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Kategorie' });
}
};
// -------------------------------------------------------------------------
// DELETE /api/events/kategorien/:id
// -------------------------------------------------------------------------
deleteKategorie = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
await eventsService.deleteKategorie(id);
res.json({ success: true, message: 'Kategorie wurde gelöscht' });
} catch (error: any) {
if (
error.message === 'Kategorie nicht gefunden' ||
error.message?.includes('noch Veranstaltungen')
) {
res.status(409).json({ success: false, message: error.message });
return;
}
logger.error('deleteKategorie error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Kategorie' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/groups
// -------------------------------------------------------------------------
getAvailableGroups = async (_req: Request, res: Response): Promise<void> => {
res.json({ success: true, data: KNOWN_GROUPS });
};
// -------------------------------------------------------------------------
// GET /api/events/calendar?from=<ISO>&to=<ISO>
// -------------------------------------------------------------------------
getCalendarRange = async (req: Request, res: Response): Promise<void> => {
try {
const fromStr = req.query.from as string | undefined;
const toStr = req.query.to as string | undefined;
if (!fromStr || !toStr) {
res.status(400).json({
success: false,
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
});
return;
}
const from = new Date(fromStr);
const to = new Date(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
return;
}
if (to < from) {
res.status(400).json({ success: false, message: '"to" muss nach "from" liegen' });
return;
}
const userGroups = getUserGroups(req);
const data = await eventsService.getEventsByDateRange(from, to, userGroups);
res.json({ success: true, data });
} catch (error) {
logger.error('getCalendarRange error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/upcoming?limit=10
// -------------------------------------------------------------------------
getUpcoming = async (req: Request, res: Response): Promise<void> => {
try {
const limit = Math.min(Number(req.query.limit ?? 10), 50);
const userGroups = getUserGroups(req);
const data = await eventsService.getUpcomingEvents(limit, userGroups);
res.json({ success: true, data });
} catch (error) {
logger.error('getUpcoming error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/:id
// -------------------------------------------------------------------------
getById = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const event = await eventsService.getById(id);
if (!event) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
res.json({ success: true, data: event });
} catch (error) {
logger.error('getById error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// POST /api/events
// -------------------------------------------------------------------------
createEvent = async (req: Request, res: Response): Promise<void> => {
try {
const parsed = CreateVeranstaltungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const data = await eventsService.createEvent(parsed.data, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('createEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/events/:id
// -------------------------------------------------------------------------
updateEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const parsed = UpdateVeranstaltungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const data = await eventsService.updateEvent(id, parsed.data);
if (!data) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
res.json({ success: true, data });
} catch (error) {
logger.error('updateEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// DELETE /api/events/:id (soft cancel)
// -------------------------------------------------------------------------
cancelEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const parsed = CancelVeranstaltungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await eventsService.cancelEvent(id, parsed.data.abgesagt_grund, req.user!.id);
res.json({ success: true, message: 'Veranstaltung wurde abgesagt' });
} catch (error: any) {
if (error.message === 'Event not found') {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
logger.error('cancelEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Absagen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/calendar-token
// -------------------------------------------------------------------------
getCalendarToken = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const data = await eventsService.getOrCreateIcalToken(req.user.id);
res.json({ success: true, data });
} catch (error) {
logger.error('getCalendarToken error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/calendar.ics?token=<token>
// -------------------------------------------------------------------------
getIcalExport = async (req: Request, res: Response): Promise<void> => {
try {
const token = req.query.token as string | undefined;
if (!token) {
res.status(400).send('Token required');
return;
}
const ical = await eventsService.getIcalExport(token);
if (!ical) {
res.status(404).send('Invalid token');
return;
}
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="veranstaltungen.ics"');
// 30-minute cache — calendar clients typically re-fetch at this interval
res.setHeader('Cache-Control', 'max-age=1800, public');
res.send(ical);
} catch (error) {
logger.error('getIcalExport error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
}
};
}
export default new EventsController();

View File

@@ -0,0 +1,118 @@
-- =============================================================================
-- Migration 015: Veranstaltungen (Events / General Calendar)
-- General event calendar for Feuerwehr Dashboard, separate from the training
-- calendar (uebungen). Supports categories, RSVPs, and iCal subscriptions.
-- Depends on: 001_create_users_table.sql (uuid-ossp, pgcrypto extensions,
-- users table, update_updated_at_column trigger function)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Event categories table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS veranstaltung_kategorien (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
beschreibung TEXT,
farbe VARCHAR(7) NOT NULL DEFAULT '#1976d2', -- hex colour for UI chips
icon VARCHAR(100), -- MUI icon name, e.g. 'Event', 'FireTruck'
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER update_veranstaltung_kategorien_aktualisiert_am
BEFORE UPDATE ON veranstaltung_kategorien
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 2. Main events table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS veranstaltungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
titel VARCHAR(500) NOT NULL,
beschreibung TEXT,
ort VARCHAR(500),
ort_url VARCHAR(1000), -- optional maps/navigation link
kategorie_id UUID REFERENCES veranstaltung_kategorien(id) ON DELETE SET NULL,
datum_von TIMESTAMPTZ NOT NULL,
datum_bis TIMESTAMPTZ NOT NULL,
ganztaegig BOOLEAN NOT NULL DEFAULT FALSE,
-- zielgruppen: array of Authentik group names, e.g. '{dashboard_mitglied,dashboard_jugend}'
zielgruppen TEXT[] NOT NULL DEFAULT '{}',
alle_gruppen BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE = visible to all members
max_teilnehmer INTEGER CHECK (max_teilnehmer > 0),
anmeldung_erforderlich BOOLEAN NOT NULL DEFAULT FALSE,
anmeldung_bis TIMESTAMPTZ,
erstellt_von UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
abgesagt_grund TEXT,
abgesagt_am TIMESTAMPTZ,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT veranstaltung_datum_reihenfolge CHECK (datum_bis >= datum_von)
);
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_von
ON veranstaltungen(datum_von);
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_bis
ON veranstaltungen(datum_bis);
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_kategorie_id
ON veranstaltungen(kategorie_id);
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_abgesagt
ON veranstaltungen(abgesagt) WHERE abgesagt = FALSE;
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_alle_gruppen
ON veranstaltungen(alle_gruppen);
-- Compound index for the most common calendar-range query
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_von_bis
ON veranstaltungen(datum_von, datum_bis);
CREATE TRIGGER update_veranstaltungen_aktualisiert_am
BEFORE UPDATE ON veranstaltungen
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 3. RSVP / attendance table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS veranstaltung_teilnahmen (
veranstaltung_id UUID NOT NULL REFERENCES veranstaltungen(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- status values: zugesagt, abgesagt, erschienen, unbekannt
status VARCHAR(20) NOT NULL DEFAULT 'unbekannt',
notiz VARCHAR(500),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (veranstaltung_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_veranstaltung_teilnahmen_veranstaltung_id
ON veranstaltung_teilnahmen(veranstaltung_id);
CREATE INDEX IF NOT EXISTS idx_veranstaltung_teilnahmen_user_id
ON veranstaltung_teilnahmen(user_id);
-- -----------------------------------------------------------------------------
-- 4. Per-user iCal subscription tokens
-- One token per user — covers the full events calendar feed for that user.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS veranstaltung_ical_tokens (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
zuletzt_verwendet_am TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_veranstaltung_ical_tokens_token
ON veranstaltung_ical_tokens(token);
-- -----------------------------------------------------------------------------
-- 5. Seed default event categories
-- -----------------------------------------------------------------------------
INSERT INTO veranstaltung_kategorien (name, farbe, icon) VALUES
('Allgemein', '#1976d2', 'Event'),
('Ausbildung', '#2e7d32', 'School'),
('Gesellschaft', '#e65100', 'People'),
('Feuerwehrjugend', '#f57c00', 'ChildCare'),
('Kommando', '#6a1b9a', 'Shield')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,86 @@
-- =============================================================================
-- Migration 016: Fahrzeugbuchungen (Vehicle Booking System)
-- Allows members to book fire department vehicles for internal use, external
-- events, maintenance slots and reservations. Includes per-user iCal feeds.
-- Depends on: 001_create_users_table.sql (uuid-ossp, pgcrypto extensions,
-- users table, update_updated_at_column trigger function)
-- 005_create_fahrzeuge.sql (fahrzeuge table)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. ENUM: booking type
-- Uses DO-block for idempotent creation (PostgreSQL has no CREATE TYPE IF NOT EXISTS)
-- -----------------------------------------------------------------------------
DO $$
BEGIN
CREATE TYPE fahrzeug_buchung_art AS ENUM (
'intern',
'extern',
'wartung',
'reservierung',
'sonstiges'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
-- -----------------------------------------------------------------------------
-- 2. Vehicle bookings table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fahrzeug_buchungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
titel VARCHAR(500) NOT NULL,
beschreibung TEXT,
beginn TIMESTAMPTZ NOT NULL,
ende TIMESTAMPTZ NOT NULL,
buchungs_art fahrzeug_buchung_art NOT NULL DEFAULT 'intern',
gebucht_von UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
-- kontakt fields are relevant for external bookings
kontakt_person VARCHAR(255),
kontakt_telefon VARCHAR(50),
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
abgesagt_grund TEXT,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT buchung_ende_nach_beginn CHECK (ende > beginn)
);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_fahrzeug_id
ON fahrzeug_buchungen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_beginn
ON fahrzeug_buchungen(beginn);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_ende
ON fahrzeug_buchungen(ende);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_gebucht_von
ON fahrzeug_buchungen(gebucht_von);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_abgesagt
ON fahrzeug_buchungen(abgesagt) WHERE abgesagt = FALSE;
-- Compound index for availability / overlap checks (fahrzeug + time range)
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_fahrzeug_beginn_ende
ON fahrzeug_buchungen(fahrzeug_id, beginn, ende);
CREATE TRIGGER update_fahrzeug_buchungen_aktualisiert_am
BEFORE UPDATE ON fahrzeug_buchungen
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 3. Per-user iCal subscription tokens for the vehicle booking calendar
-- One token per user — the feed returns all bookings the user has access to.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fahrzeug_ical_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
zuletzt_verwendet_am TIMESTAMPTZ,
-- one token per user for the full vehicle booking feed
UNIQUE (user_id)
);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_ical_tokens_token
ON fahrzeug_ical_tokens(token);

View File

@@ -0,0 +1,88 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
export const BUCHUNGS_ARTEN = ['intern', 'extern', 'wartung', 'reservierung', 'sonstiges'] as const;
export type BuchungsArt = (typeof BUCHUNGS_ARTEN)[number];
// ---------------------------------------------------------------------------
// Core DB-mapped interfaces
// ---------------------------------------------------------------------------
export interface FahrzeugBuchung {
id: string;
fahrzeug_id: string;
titel: string;
beschreibung?: string | null;
beginn: Date;
ende: Date;
buchungs_art: BuchungsArt;
gebucht_von: string;
kontakt_person?: string | null;
kontakt_telefon?: string | null;
abgesagt: boolean;
abgesagt_grund?: string | null;
erstellt_am: Date;
aktualisiert_am: Date;
// Joined
fahrzeug_name?: string | null;
fahrzeug_kennzeichen?: string | null;
gebucht_von_name?: string | null;
}
/** Lightweight list item — used in calendar and upcoming list widget */
export interface FahrzeugBuchungListItem {
id: string;
fahrzeug_id: string;
fahrzeug_name: string;
fahrzeug_kennzeichen?: string | null;
titel: string;
buchungs_art: BuchungsArt;
beginn: Date;
ende: Date;
abgesagt: boolean;
gebucht_von_name?: string | null;
}
// ---------------------------------------------------------------------------
// Zod validation schemas
// ---------------------------------------------------------------------------
const BuchungBaseSchema = z.object({
fahrzeugId: z.string().uuid('fahrzeugId muss eine gueltige UUID sein'),
titel: z.string().min(3, 'Titel muss mindestens 3 Zeichen haben').max(500),
beschreibung: z.string().max(2000).optional().nullable(),
beginn: z
.string()
.datetime({ offset: true, message: 'beginn muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s)),
ende: z
.string()
.datetime({ offset: true, message: 'ende muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s)),
buchungsArt: z.enum(BUCHUNGS_ARTEN).default('intern'),
kontaktPerson: z.string().max(255).optional().nullable(),
kontaktTelefon: z.string().max(50).optional().nullable(),
});
export const CreateBuchungSchema = BuchungBaseSchema.refine(
(d) => d.ende > d.beginn,
{ message: 'Ende muss nach Beginn liegen', path: ['ende'] }
);
export type CreateBuchungData = z.infer<typeof CreateBuchungSchema>;
export const UpdateBuchungSchema = BuchungBaseSchema.partial().refine(
(d) => d.ende == null || d.beginn == null || d.ende > d.beginn,
{ message: 'Ende muss nach Beginn liegen', path: ['ende'] }
);
export type UpdateBuchungData = z.infer<typeof UpdateBuchungSchema>;
export const CancelBuchungSchema = z.object({
abgesagt_grund: z
.string()
.min(5, 'Bitte gib einen Stornierungsgrund an (min. 5 Zeichen)')
.max(1000),
});
export type CancelBuchungData = z.infer<typeof CancelBuchungSchema>;

View File

@@ -0,0 +1,143 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Core DB-mapped interfaces
// ---------------------------------------------------------------------------
export interface VeranstaltungKategorie {
id: string;
name: string;
beschreibung?: string | null;
farbe?: string | null;
icon?: string | null;
erstellt_von?: string | null;
erstellt_am: Date;
aktualisiert_am: Date;
}
export interface Veranstaltung {
id: string;
titel: string;
beschreibung?: string | null;
ort?: string | null;
ort_url?: string | null;
kategorie_id?: string | null;
datum_von: Date;
datum_bis: Date;
ganztaegig: boolean;
zielgruppen: string[];
alle_gruppen: boolean;
max_teilnehmer?: number | null;
anmeldung_erforderlich: boolean;
anmeldung_bis?: Date | null;
erstellt_von: string;
abgesagt: boolean;
abgesagt_grund?: string | null;
abgesagt_am?: Date | null;
erstellt_am: Date;
aktualisiert_am: Date;
// Joined / enriched fields
kategorie_name?: string | null;
kategorie_farbe?: string | null;
kategorie_icon?: string | null;
erstellt_von_name?: string | null;
}
/** Lightweight version for calendar and list views */
export interface VeranstaltungListItem {
id: string;
titel: string;
ort?: string | null;
kategorie_id?: string | null;
kategorie_name?: string | null;
kategorie_farbe?: string | null;
kategorie_icon?: string | null;
datum_von: Date;
datum_bis: Date;
ganztaegig: boolean;
alle_gruppen: boolean;
zielgruppen: string[];
abgesagt: boolean;
anmeldung_erforderlich: boolean;
}
// ---------------------------------------------------------------------------
// Zod schemas -- Kategorien
// ---------------------------------------------------------------------------
export const CreateKategorieSchema = z.object({
name: z
.string()
.min(2, 'Name muss mindestens 2 Zeichen haben')
.max(255),
beschreibung: z.string().max(500).optional().nullable(),
farbe: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Farbe muss ein gültiger Hex-Farbwert sein (z.B. #1976d2)')
.optional(),
icon: z.string().max(100).optional(),
});
export type CreateKategorieData = z.infer<typeof CreateKategorieSchema>;
export const UpdateKategorieSchema = CreateKategorieSchema.partial();
export type UpdateKategorieData = z.infer<typeof UpdateKategorieSchema>;
// ---------------------------------------------------------------------------
// Zod schemas -- Veranstaltungen
// ---------------------------------------------------------------------------
const VeranstaltungBaseSchema = z.object({
titel: z
.string()
.min(3, 'Titel muss mindestens 3 Zeichen haben')
.max(500),
beschreibung: z.string().max(5000).optional().nullable(),
ort: z.string().max(500).optional().nullable(),
// Plain string validator — some users use relative paths or internal URLs
ort_url: z.string().max(1000).optional().nullable(),
kategorie_id: z.string().uuid('kategorie_id muss eine gültige UUID sein').optional().nullable(),
datum_von: z
.string()
.datetime({ offset: true, message: 'datum_von muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s)),
datum_bis: z
.string()
.datetime({ offset: true, message: 'datum_bis muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s)),
ganztaegig: z.boolean().default(false),
zielgruppen: z.array(z.string()).default([]),
alle_gruppen: z.boolean().default(false),
max_teilnehmer: z.number().int().positive().optional().nullable(),
anmeldung_erforderlich: z.boolean().default(false),
anmeldung_bis: z
.string()
.datetime({ offset: true, message: 'anmeldung_bis muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s))
.optional()
.nullable(),
});
export const CreateVeranstaltungSchema = VeranstaltungBaseSchema.refine(
(d) => d.datum_bis >= d.datum_von,
{ message: 'datum_bis muss nach datum_von liegen', path: ['datum_bis'] }
);
export type CreateVeranstaltungData = z.infer<typeof CreateVeranstaltungSchema>;
export const UpdateVeranstaltungSchema = VeranstaltungBaseSchema.partial().refine(
(d) => d.datum_bis == null || d.datum_von == null || d.datum_bis >= d.datum_von,
{ message: 'datum_bis muss nach datum_von liegen', path: ['datum_bis'] }
);
export type UpdateVeranstaltungData = z.infer<typeof UpdateVeranstaltungSchema>;
export const CancelVeranstaltungSchema = z.object({
abgesagt_grund: z
.string()
.min(5, 'Bitte gib einen Grund für die Absage an (min. 5 Zeichen)')
.max(1000),
});
export type CancelVeranstaltungData = z.infer<typeof CancelVeranstaltungSchema>;

View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import bookingController from '../controllers/booking.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
const ADMIN_GROUPS = ['dashboard_admin'];
const router = Router();
// ── Public (token-based, no session auth required) ───────────────────────────
router.get('/calendar.ics', optionalAuth, bookingController.getIcalExport.bind(bookingController));
// ── Read-only (all authenticated users) ──────────────────────────────────────
router.get('/calendar', authenticate, bookingController.getCalendarRange.bind(bookingController));
router.get('/upcoming', authenticate, bookingController.getUpcoming.bind(bookingController));
router.get('/availability', authenticate, bookingController.checkAvailability.bind(bookingController));
router.get('/calendar-token', authenticate, bookingController.getCalendarToken.bind(bookingController));
// ── Write operations ──────────────────────────────────────────────────────────
router.post('/', authenticate, requireGroups(WRITE_GROUPS), bookingController.create.bind(bookingController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.update.bind(bookingController));
// Soft-cancel (sets abgesagt=TRUE)
router.delete('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.cancel.bind(bookingController));
// Hard-delete (admin only)
router.delete('/:id/force', authenticate, requireGroups(ADMIN_GROUPS), bookingController.hardDelete.bind(bookingController));
// ── Single booking read — after specific routes to avoid path conflicts ───────
router.get('/:id', authenticate, bookingController.getById.bind(bookingController));
export default router;

View File

@@ -0,0 +1,146 @@
import { Router } from 'express';
import eventsController from '../controllers/events.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const router = Router();
/** Groups that may create, update, or cancel events */
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
// ---------------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------------
/**
* GET /api/events/kategorien
* List all event categories. Any authenticated user can read.
*/
router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eventsController));
/**
* POST /api/events/kategorien
* Create a new category. Requires admin or moderator.
*/
router.post(
'/kategorien',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.createKategorie.bind(eventsController)
);
/**
* PATCH /api/events/kategorien/:id
* Update an existing category. Requires admin or moderator.
*/
router.patch(
'/kategorien/:id',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.updateKategorie.bind(eventsController)
);
/**
* DELETE /api/events/kategorien/:id
* Delete a category (only if no events reference it). Requires admin or moderator.
*/
router.delete(
'/kategorien/:id',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.deleteKategorie.bind(eventsController)
);
// ---------------------------------------------------------------------------
// Known groups list (used by frontend to populate zielgruppen picker)
// ---------------------------------------------------------------------------
/**
* GET /api/events/groups
* Returns the list of known Authentik groups with human-readable labels.
*/
router.get('/groups', authenticate, eventsController.getAvailableGroups.bind(eventsController));
// ---------------------------------------------------------------------------
// Calendar & upcoming — specific routes must come before /:id
// ---------------------------------------------------------------------------
/**
* GET /api/events/calendar?from=<ISO>&to=<ISO>
* Events in a date range, filtered by the requesting user's groups.
* Optional auth — unauthenticated callers only see alle_gruppen events.
*/
router.get('/calendar', optionalAuth, eventsController.getCalendarRange.bind(eventsController));
/**
* GET /api/events/upcoming?limit=10
* Next N upcoming events visible to the requesting user.
*/
router.get('/upcoming', optionalAuth, eventsController.getUpcoming.bind(eventsController));
/**
* GET /api/events/calendar-token
* Returns (or creates) the user's personal iCal subscribe token + URL.
* Requires authentication.
*/
router.get(
'/calendar-token',
authenticate,
eventsController.getCalendarToken.bind(eventsController)
);
/**
* GET /api/events/calendar.ics?token=<token>
* iCal feed — authenticated via per-user opaque token.
* No Bearer token required; calendar clients use the token query param.
*/
router.get(
'/calendar.ics',
optionalAuth,
eventsController.getIcalExport.bind(eventsController)
);
// ---------------------------------------------------------------------------
// Events CRUD
// ---------------------------------------------------------------------------
/**
* POST /api/events
* Create a new event. Requires admin or moderator.
*/
router.post(
'/',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.createEvent.bind(eventsController)
);
/**
* GET /api/events/:id
* Single event detail. Any authenticated user.
*/
router.get('/:id', authenticate, eventsController.getById.bind(eventsController));
/**
* PATCH /api/events/:id
* Update an existing event. Requires admin or moderator.
*/
router.patch(
'/:id',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.updateEvent.bind(eventsController)
);
/**
* DELETE /api/events/:id
* Soft-cancel an event (sets abgesagt=TRUE + reason). Requires admin or moderator.
*/
router.delete(
'/:id',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.cancelEvent.bind(eventsController)
);
export default router;

View File

@@ -0,0 +1,390 @@
import pool from '../config/database';
import logger from '../utils/logger';
import {
FahrzeugBuchung,
FahrzeugBuchungListItem,
CreateBuchungData,
UpdateBuchungData,
} from '../models/booking.model';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Format a Date to iCal YYYYMMDDTHHMMSSZ format (UTC) */
function toIcalDate(d: Date): string {
return new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function rowToListItem(row: any): FahrzeugBuchungListItem {
return {
id: row.id,
fahrzeug_id: row.fahrzeug_id,
fahrzeug_name: row.fahrzeug_name ?? '',
fahrzeug_kennzeichen: row.fahrzeug_kennzeichen ?? null,
titel: row.titel,
buchungs_art: row.buchungs_art,
beginn: new Date(row.beginn),
ende: new Date(row.ende),
abgesagt: row.abgesagt,
gebucht_von_name: row.gebucht_von_name ?? null,
};
}
function rowToBuchung(row: any): FahrzeugBuchung {
return {
id: row.id,
fahrzeug_id: row.fahrzeug_id,
titel: row.titel,
beschreibung: row.beschreibung ?? null,
beginn: new Date(row.beginn),
ende: new Date(row.ende),
buchungs_art: row.buchungs_art,
gebucht_von: row.gebucht_von,
kontakt_person: row.kontakt_person ?? null,
kontakt_telefon: row.kontakt_telefon ?? null,
abgesagt: row.abgesagt,
abgesagt_grund: row.abgesagt_grund ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
fahrzeug_name: row.fahrzeug_name ?? null,
fahrzeug_kennzeichen: row.fahrzeug_kennzeichen ?? null,
gebucht_von_name: row.gebucht_von_name ?? null,
};
}
// ---------------------------------------------------------------------------
// Booking Service
// ---------------------------------------------------------------------------
class BookingService {
/**
* Returns bookings overlapping the given date range, optionally filtered
* to a single vehicle. Non-cancelled only.
*/
async getBookingsByRange(
from: Date,
to: Date,
fahrzeugId?: string
): Promise<FahrzeugBuchungListItem[]> {
const params: unknown[] = [from, to];
let vehicleFilter = '';
if (fahrzeugId) {
params.push(fahrzeugId);
vehicleFilter = `AND b.fahrzeug_id = $${params.length}`;
}
const query = `
SELECT
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.abgesagt,
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
u.display_name AS gebucht_von_name
FROM fahrzeug_buchungen b
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
JOIN users u ON u.id = b.gebucht_von
WHERE b.abgesagt = FALSE
AND (
b.beginn BETWEEN $1 AND $2
OR b.ende BETWEEN $1 AND $2
OR (b.beginn <= $1 AND b.ende >= $2)
)
${vehicleFilter}
ORDER BY b.beginn ASC
`;
const { rows } = await pool.query(query, params);
return rows.map(rowToListItem);
}
/** Returns the next N upcoming non-cancelled bookings sorted ascending. */
async getUpcoming(limit = 20): Promise<FahrzeugBuchungListItem[]> {
const query = `
SELECT
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.abgesagt,
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
u.display_name AS gebucht_von_name
FROM fahrzeug_buchungen b
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
JOIN users u ON u.id = b.gebucht_von
WHERE b.abgesagt = FALSE
AND b.beginn > NOW()
ORDER BY b.beginn ASC
LIMIT $1
`;
const { rows } = await pool.query(query, [limit]);
return rows.map(rowToListItem);
}
/** Returns a single booking by ID including all joined fields, or null. */
async getById(id: string): Promise<FahrzeugBuchung | null> {
const query = `
SELECT
b.id, b.fahrzeug_id, b.titel, b.beschreibung,
b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende,
b.gebucht_von, b.kontakt_person, b.kontakt_telefon,
b.abgesagt, b.abgesagt_grund,
b.erstellt_am, b.aktualisiert_am,
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
u.display_name AS gebucht_von_name
FROM fahrzeug_buchungen b
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
JOIN users u ON u.id = b.gebucht_von
WHERE b.id = $1
`;
const { rows } = await pool.query(query, [id]);
if (rows.length === 0) return null;
return rowToBuchung(rows[0]);
}
/**
* Checks whether a vehicle is already booked for the given interval.
* Returns true if there IS a conflict.
* Pass excludeId to ignore a specific booking (used during updates).
*/
async checkConflict(
fahrzeugId: string,
beginn: Date,
ende: Date,
excludeId?: string
): Promise<boolean> {
const query = `
SELECT 1
FROM fahrzeug_buchungen
WHERE fahrzeug_id = $1
AND abgesagt = FALSE
AND ($2::timestamptz, $3::timestamptz) OVERLAPS (beginn, ende)
AND ($4::uuid IS NULL OR id != $4)
LIMIT 1
`;
const { rows } = await pool.query(query, [
fahrzeugId,
beginn,
ende,
excludeId ?? null,
]);
return rows.length > 0;
}
/** Creates a new booking. Throws if the vehicle has a conflicting booking. */
async create(data: CreateBuchungData, userId: string): Promise<FahrzeugBuchung> {
const hasConflict = await this.checkConflict(
data.fahrzeugId,
data.beginn,
data.ende
);
if (hasConflict) {
throw new Error('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
}
const query = `
INSERT INTO fahrzeug_buchungen
(fahrzeug_id, titel, beschreibung, beginn, ende, buchungs_art, gebucht_von, kontakt_person, kontakt_telefon)
VALUES
($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9)
RETURNING id
`;
const { rows } = await pool.query(query, [
data.fahrzeugId,
data.titel,
data.beschreibung ?? null,
data.beginn,
data.ende,
data.buchungsArt,
userId,
data.kontaktPerson ?? null,
data.kontaktTelefon ?? null,
]);
const newId: string = rows[0].id;
const booking = await this.getById(newId);
if (!booking) throw new Error('Buchung konnte nach dem Erstellen nicht geladen werden');
return booking;
}
/**
* Updates the provided fields of a booking.
* Checks for conflicts when timing or vehicle fields change.
*/
async update(id: string, data: UpdateBuchungData): Promise<FahrzeugBuchung | null> {
const existing = await this.getById(id);
if (!existing) return null;
// Resolve effective values for conflict check
const effectiveFahrzeugId = data.fahrzeugId ?? existing.fahrzeug_id;
const effectiveBeginn = data.beginn ?? existing.beginn;
const effectiveEnde = data.ende ?? existing.ende;
const timingChanged =
data.fahrzeugId != null || data.beginn != null || data.ende != null;
if (timingChanged) {
const hasConflict = await this.checkConflict(
effectiveFahrzeugId,
effectiveBeginn,
effectiveEnde,
id
);
if (hasConflict) {
throw new Error('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
}
}
// Build dynamic SET clause
const setClauses: string[] = [];
const params: unknown[] = [];
const addField = (column: string, value: unknown, cast?: string) => {
params.push(value);
setClauses.push(`${column} = $${params.length}${cast ? `::${cast}` : ''}`);
};
if (data.fahrzeugId !== undefined) addField('fahrzeug_id', data.fahrzeugId);
if (data.titel !== undefined) addField('titel', data.titel);
if (data.beschreibung !== undefined) addField('beschreibung', data.beschreibung);
if (data.beginn !== undefined) addField('beginn', data.beginn);
if (data.ende !== undefined) addField('ende', data.ende);
if (data.buchungsArt !== undefined) addField('buchungs_art', data.buchungsArt, 'fahrzeug_buchung_art');
if (data.kontaktPerson !== undefined) addField('kontakt_person', data.kontaktPerson);
if (data.kontaktTelefon !== undefined) addField('kontakt_telefon', data.kontaktTelefon);
if (setClauses.length === 0) {
throw new Error('No fields to update');
}
params.push(id);
const query = `
UPDATE fahrzeug_buchungen
SET ${setClauses.join(', ')}, aktualisiert_am = NOW()
WHERE id = $${params.length}
`;
await pool.query(query, params);
return this.getById(id);
}
/** Soft-cancels a booking by setting abgesagt=TRUE and recording the reason. */
async cancel(id: string, abgesagt_grund: string): Promise<void> {
await pool.query(
`UPDATE fahrzeug_buchungen
SET abgesagt = TRUE, abgesagt_grund = $2, aktualisiert_am = NOW()
WHERE id = $1`,
[id, abgesagt_grund]
);
}
/** Permanently deletes a booking record. */
async delete(id: string): Promise<void> {
await pool.query('DELETE FROM fahrzeug_buchungen WHERE id = $1', [id]);
}
/**
* Returns an existing iCal token for the user, or creates a new one.
* Also returns the subscribe URL the user can add to their calendar app.
*/
async getOrCreateIcalToken(
userId: string
): Promise<{ token: string; subscribeUrl: string }> {
const selectResult = await pool.query(
'SELECT token FROM fahrzeug_ical_tokens WHERE user_id = $1',
[userId]
);
let token: string;
if (selectResult.rows.length > 0) {
token = selectResult.rows[0].token;
} else {
const insertResult = await pool.query(
`INSERT INTO fahrzeug_ical_tokens (user_id)
VALUES ($1)
RETURNING token`,
[userId]
);
token = insertResult.rows[0].token;
logger.info('Created new iCal token for user', { userId });
}
const baseUrl = process.env.ICAL_BASE_URL || 'http://localhost:3000';
const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`;
return { token, subscribeUrl };
}
/**
* Validates the iCal token and returns an iCal string for all (or one
* vehicle's) non-cancelled bookings. Returns null when the token is invalid.
*/
async getIcalExport(
token: string,
fahrzeugId?: string
): Promise<string | null> {
// Validate token and update last-used timestamp
const tokenResult = await pool.query(
`UPDATE fahrzeug_ical_tokens
SET zuletzt_verwendet_am = NOW()
WHERE token = $1
RETURNING id`,
[token]
);
if (tokenResult.rows.length === 0) return null;
// Fetch bookings
const params: unknown[] = [];
let vehicleFilter = '';
if (fahrzeugId) {
params.push(fahrzeugId);
vehicleFilter = `AND b.fahrzeug_id = $${params.length}`;
}
const query = `
SELECT
b.id, b.titel, b.beschreibung, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende,
f.name AS fahrzeug_name
FROM fahrzeug_buchungen b
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
WHERE b.abgesagt = FALSE
${vehicleFilter}
ORDER BY b.beginn ASC
`;
const { rows } = await pool.query(query, params);
const now = toIcalDate(new Date());
const events = rows
.map((row: any) => {
const beschreibung = [row.buchungs_art, row.beschreibung]
.filter(Boolean)
.join(' | ');
return (
'BEGIN:VEVENT\r\n' +
`UID:${row.id}@feuerwehr-buchungen\r\n` +
`DTSTAMP:${now}\r\n` +
`DTSTART:${toIcalDate(new Date(row.beginn))}\r\n` +
`DTEND:${toIcalDate(new Date(row.ende))}\r\n` +
`SUMMARY:${row.titel} - ${row.fahrzeug_name}\r\n` +
`DESCRIPTION:${beschreibung}\r\n` +
'END:VEVENT\r\n'
);
})
.join('');
return (
'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//Feuerwehr Dashboard//Fahrzeugbuchungen//DE\r\n' +
'X-WR-CALNAME:Feuerwehr Fahrzeugbuchungen\r\n' +
'X-WR-TIMEZONE:Europe/Berlin\r\n' +
'CALSCALE:GREGORIAN\r\n' +
events +
'END:VCALENDAR\r\n'
);
}
}
export default new BookingService();

View File

@@ -0,0 +1,507 @@
import pool from '../config/database';
import logger from '../utils/logger';
import {
VeranstaltungKategorie,
Veranstaltung,
VeranstaltungListItem,
CreateKategorieData,
UpdateKategorieData,
CreateVeranstaltungData,
UpdateVeranstaltungData,
} from '../models/events.model';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Map a raw DB row to a VeranstaltungListItem */
function rowToListItem(row: any): VeranstaltungListItem {
return {
id: row.id,
titel: row.titel,
ort: row.ort ?? null,
kategorie_id: row.kategorie_id ?? null,
kategorie_name: row.kategorie_name ?? null,
kategorie_farbe: row.kategorie_farbe ?? null,
kategorie_icon: row.kategorie_icon ?? null,
datum_von: new Date(row.datum_von),
datum_bis: new Date(row.datum_bis),
ganztaegig: row.ganztaegig,
alle_gruppen: row.alle_gruppen,
zielgruppen: row.zielgruppen ?? [],
abgesagt: row.abgesagt,
anmeldung_erforderlich: row.anmeldung_erforderlich,
};
}
/** Map a raw DB row to a full Veranstaltung */
function rowToVeranstaltung(row: any): Veranstaltung {
return {
id: row.id,
titel: row.titel,
beschreibung: row.beschreibung ?? null,
ort: row.ort ?? null,
ort_url: row.ort_url ?? null,
kategorie_id: row.kategorie_id ?? null,
datum_von: new Date(row.datum_von),
datum_bis: new Date(row.datum_bis),
ganztaegig: row.ganztaegig,
zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen,
max_teilnehmer: row.max_teilnehmer ?? null,
anmeldung_erforderlich: row.anmeldung_erforderlich,
anmeldung_bis: row.anmeldung_bis ? new Date(row.anmeldung_bis) : null,
erstellt_von: row.erstellt_von,
abgesagt: row.abgesagt,
abgesagt_grund: row.abgesagt_grund ?? null,
abgesagt_am: row.abgesagt_am ? new Date(row.abgesagt_am) : null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
// Joined fields
kategorie_name: row.kategorie_name ?? null,
kategorie_farbe: row.kategorie_farbe ?? null,
kategorie_icon: row.kategorie_icon ?? null,
erstellt_von_name: row.erstellt_von_name ?? null,
};
}
/** Format a Date as YYYYMMDDTHHMMSSZ (UTC) for iCal output */
function formatIcalDate(date: Date): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
/** Fold long iCal lines at 75 octets (RFC 5545 §3.1) */
function icalFold(line: string): string {
if (line.length <= 75) return line;
let folded = '';
while (line.length > 75) {
folded += line.slice(0, 75) + '\r\n ';
line = line.slice(75);
}
folded += line;
return folded;
}
/** Escape special characters in iCal text values (RFC 5545 §3.3.11) */
function icalEscape(value: string | null | undefined): string {
if (!value) return '';
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
}
// ---------------------------------------------------------------------------
// Events Service
// ---------------------------------------------------------------------------
class EventsService {
// -------------------------------------------------------------------------
// KATEGORIEN
// -------------------------------------------------------------------------
/** Returns all event categories ordered by name. */
async getKategorien(): Promise<VeranstaltungKategorie[]> {
const result = await pool.query(`
SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am
FROM veranstaltung_kategorien
ORDER BY name ASC
`);
return result.rows.map((row) => ({
id: row.id,
name: row.name,
beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null,
icon: row.icon ?? null,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
}));
}
/** Creates a new event category. */
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
const result = await pool.query(
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, erstellt_von)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`,
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, userId]
);
const row = result.rows[0];
return {
id: row.id,
name: row.name,
beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null,
icon: row.icon ?? null,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
};
}
/** Updates an existing event category. Returns null if not found. */
async updateKategorie(id: string, data: UpdateKategorieData): Promise<VeranstaltungKategorie | null> {
const fields: string[] = [];
const values: any[] = [];
let idx = 1;
if (data.name !== undefined) { fields.push(`name = $${idx++}`); values.push(data.name); }
if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung); }
if (data.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); }
if (data.icon !== undefined) { fields.push(`icon = $${idx++}`); values.push(data.icon); }
if (fields.length === 0) {
// Nothing to update — return the existing record
const existing = await pool.query(
`SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am
FROM veranstaltung_kategorien WHERE id = $1`,
[id]
);
if (existing.rows.length === 0) return null;
const row = existing.rows[0];
return {
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
};
}
fields.push(`aktualisiert_am = NOW()`);
values.push(id);
const result = await pool.query(
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
WHERE id = $${idx}
RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`,
values
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
};
}
/**
* Deletes an event category.
* Throws if any events still reference this category.
*/
async deleteKategorie(id: string): Promise<void> {
const refCheck = await pool.query(
`SELECT COUNT(*) AS cnt FROM veranstaltungen WHERE kategorie_id = $1`,
[id]
);
if (Number(refCheck.rows[0].cnt) > 0) {
throw new Error('Kategorie kann nicht gelöscht werden, da sie noch Veranstaltungen enthält');
}
const result = await pool.query(
`DELETE FROM veranstaltung_kategorien WHERE id = $1`,
[id]
);
if (result.rowCount === 0) {
throw new Error('Kategorie nicht gefunden');
}
}
// -------------------------------------------------------------------------
// EVENTS — queries
// -------------------------------------------------------------------------
/**
* Returns events within [from, to] visible to the requesting user.
* Visibility: alle_gruppen=TRUE OR zielgruppen overlap with userGroups
* OR user is dashboard_admin.
*/
async getEventsByDateRange(
from: Date,
to: Date,
userGroups: string[]
): Promise<VeranstaltungListItem[]> {
const result = await pool.query(
`SELECT
v.id, v.titel, v.ort, v.kategorie_id,
k.name AS kategorie_name,
k.farbe AS kategorie_farbe,
k.icon AS kategorie_icon,
v.datum_von, v.datum_bis, v.ganztaegig,
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
FROM veranstaltungen v
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2)
AND (
v.alle_gruppen = TRUE
OR v.zielgruppen && $3
OR 'dashboard_admin' = ANY($3)
)
ORDER BY v.datum_von ASC`,
[from, to, userGroups]
);
return result.rows.map(rowToListItem);
}
/**
* Returns the next N upcoming events (datum_von > NOW) visible to the user.
*/
async getUpcomingEvents(limit: number, userGroups: string[]): Promise<VeranstaltungListItem[]> {
const result = await pool.query(
`SELECT
v.id, v.titel, v.ort, v.kategorie_id,
k.name AS kategorie_name,
k.farbe AS kategorie_farbe,
k.icon AS kategorie_icon,
v.datum_von, v.datum_bis, v.ganztaegig,
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
FROM veranstaltungen v
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
WHERE v.datum_von > NOW()
AND (
v.alle_gruppen = TRUE
OR v.zielgruppen && $2
OR 'dashboard_admin' = ANY($2)
)
ORDER BY v.datum_von ASC
LIMIT $1`,
[limit, userGroups]
);
return result.rows.map(rowToListItem);
}
/** Returns a single event with joined kategorie and creator info. Returns null if not found. */
async getById(id: string): Promise<Veranstaltung | null> {
const result = await pool.query(
`SELECT
v.*,
k.name AS kategorie_name,
k.farbe AS kategorie_farbe,
k.icon AS kategorie_icon,
u.name AS erstellt_von_name
FROM veranstaltungen v
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
LEFT JOIN users u ON u.id = v.erstellt_von
WHERE v.id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return rowToVeranstaltung(result.rows[0]);
}
// -------------------------------------------------------------------------
// EVENTS — mutations
// -------------------------------------------------------------------------
/** Creates a new event and returns the full record. */
async createEvent(data: CreateVeranstaltungData, userId: string): Promise<Veranstaltung> {
const result = await pool.query(
`INSERT INTO veranstaltungen (
titel, beschreibung, ort, ort_url, kategorie_id,
datum_von, datum_bis, ganztaegig,
zielgruppen, alle_gruppen,
max_teilnehmer, anmeldung_erforderlich, anmeldung_bis,
erstellt_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING *`,
[
data.titel,
data.beschreibung ?? null,
data.ort ?? null,
data.ort_url ?? null,
data.kategorie_id ?? null,
data.datum_von,
data.datum_bis,
data.ganztaegig,
data.zielgruppen,
data.alle_gruppen,
data.max_teilnehmer ?? null,
data.anmeldung_erforderlich,
data.anmeldung_bis ?? null,
userId,
]
);
return rowToVeranstaltung(result.rows[0]);
}
/**
* Updates an existing event.
* Returns the updated record or null if not found.
*/
async updateEvent(id: string, data: UpdateVeranstaltungData): Promise<Veranstaltung | null> {
const fields: string[] = [];
const values: any[] = [];
let idx = 1;
const fieldMap: Record<string, any> = {
titel: data.titel,
beschreibung: data.beschreibung,
ort: data.ort,
ort_url: data.ort_url,
kategorie_id: data.kategorie_id,
datum_von: data.datum_von,
datum_bis: data.datum_bis,
ganztaegig: data.ganztaegig,
zielgruppen: data.zielgruppen,
alle_gruppen: data.alle_gruppen,
max_teilnehmer: data.max_teilnehmer,
anmeldung_erforderlich: data.anmeldung_erforderlich,
anmeldung_bis: data.anmeldung_bis,
};
for (const [col, val] of Object.entries(fieldMap)) {
if (val !== undefined) {
fields.push(`${col} = $${idx++}`);
values.push(val);
}
}
if (fields.length === 0) return this.getById(id);
fields.push(`aktualisiert_am = NOW()`);
values.push(id);
const result = await pool.query(
`UPDATE veranstaltungen SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
if (result.rows.length === 0) return null;
return rowToVeranstaltung(result.rows[0]);
}
/**
* Soft-cancels an event by setting abgesagt=TRUE, recording the reason
* and the timestamp.
*/
async cancelEvent(id: string, grund: string, userId: string): Promise<void> {
logger.info('Cancelling event', { id, userId });
const result = await pool.query(
`UPDATE veranstaltungen
SET abgesagt = TRUE, abgesagt_grund = $2, abgesagt_am = NOW(), aktualisiert_am = NOW()
WHERE id = $1`,
[id, grund]
);
if (result.rowCount === 0) {
throw new Error('Event not found');
}
}
// -------------------------------------------------------------------------
// ICAL TOKEN
// -------------------------------------------------------------------------
/**
* Returns (or creates) the personal iCal subscription token for a user.
*
* The subscribeUrl is built from ICAL_BASE_URL (env) so it can be used
* directly in calendar clients without any further transformation.
*/
async getOrCreateIcalToken(userId: string): Promise<{ token: string; subscribeUrl: string }> {
// Attempt to fetch an existing token first
const existing = await pool.query(
`SELECT token FROM veranstaltung_ical_tokens WHERE user_id = $1`,
[userId]
);
let token: string;
if (existing.rows.length > 0) {
token = existing.rows[0].token;
} else {
// Insert a new row — the DEFAULT clause generates the token via gen_random_bytes
const inserted = await pool.query(
`INSERT INTO veranstaltung_ical_tokens (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id
RETURNING token`,
[userId]
);
token = inserted.rows[0].token;
}
const baseUrl = (process.env.ICAL_BASE_URL ?? '').replace(/\/$/, '');
const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`;
return { token, subscribeUrl };
}
// -------------------------------------------------------------------------
// ICAL EXPORT
// -------------------------------------------------------------------------
/**
* Generates an iCal feed for a given token.
*
* NOTE — Group visibility limitation:
* Groups are issued by Authentik and embedded only in the short-lived JWT.
* They are NOT persisted in the database. For token-based iCal access we
* therefore cannot look up which Authentik groups a user belongs to.
* As a safe fallback this export includes only events where alle_gruppen=TRUE
* (i.e. events intended for everyone). Authenticated users who request the
* .ics directly via Bearer token already get group-filtered results through
* the normal API endpoints.
*
* Returns null if the token is invalid.
*/
async getIcalExport(token: string): Promise<string | null> {
// Validate token and update last-used timestamp
const tokenResult = await pool.query(
`UPDATE veranstaltung_ical_tokens
SET zuletzt_verwendet_am = NOW()
WHERE token = $1
RETURNING user_id`,
[token]
);
if (tokenResult.rows.length === 0) return null;
// Fetch public events: all future events + those that ended in the last 30 days
// Only alle_gruppen=TRUE events — see NOTE above about group limitation
const eventsResult = await pool.query(
`SELECT v.id, v.titel, v.beschreibung, v.ort, v.datum_von, v.datum_bis, v.ganztaegig, v.abgesagt
FROM veranstaltungen v
WHERE v.alle_gruppen = TRUE
AND v.datum_bis >= NOW() - INTERVAL '30 days'
ORDER BY v.datum_von ASC`
);
const now = new Date();
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Feuerwehr Dashboard//Veranstaltungen//DE',
'X-WR-CALNAME:Feuerwehr Veranstaltungen',
'X-WR-TIMEZONE:Europe/Berlin',
'CALSCALE:GREGORIAN',
];
for (const row of eventsResult.rows) {
const datumVon = new Date(row.datum_von);
const datumBis = new Date(row.datum_bis);
lines.push('BEGIN:VEVENT');
lines.push(icalFold(`UID:${row.id}@feuerwehr-veranstaltungen`));
lines.push(`DTSTAMP:${formatIcalDate(now)}`);
lines.push(`DTSTART:${formatIcalDate(datumVon)}`);
lines.push(`DTEND:${formatIcalDate(datumBis)}`);
lines.push(icalFold(`SUMMARY:${row.abgesagt ? '[ABGESAGT] ' : ''}${icalEscape(row.titel)}`));
if (row.beschreibung) {
lines.push(icalFold(`DESCRIPTION:${icalEscape(row.beschreibung)}`));
}
if (row.ort) {
lines.push(icalFold(`LOCATION:${icalEscape(row.ort)}`));
}
if (row.abgesagt) {
lines.push('STATUS:CANCELLED');
}
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
// RFC 5545 requires CRLF line endings
return lines.join('\r\n') + '\r\n';
}
}
export default new EventsService();