add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View File

@@ -7,6 +7,7 @@
"dev": "nodemon",
"build": "tsc",
"start": "node dist/server.js",
"migrate": "ts-node -e \"require('./src/config/database').runMigrations().then(() => { console.log('Done'); process.exit(0); }).catch((e) => { console.error(e.message); process.exit(1); })\"",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],

View File

@@ -0,0 +1,302 @@
import { Request, Response } from 'express';
import incidentService from '../services/incident.service';
import logger from '../utils/logger';
import { AppError } from '../middleware/error.middleware';
import { AppRole, hasPermission } from '../middleware/rbac.middleware';
import {
CreateEinsatzSchema,
UpdateEinsatzSchema,
AssignPersonnelSchema,
AssignVehicleSchema,
IncidentFiltersSchema,
} from '../models/incident.model';
// Extend Request type to carry the resolved role (set by requirePermission)
interface AuthenticatedRequest extends Request {
userRole?: AppRole;
}
class IncidentController {
// -------------------------------------------------------------------------
// GET /api/incidents
// -------------------------------------------------------------------------
async listIncidents(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const parseResult = IncidentFiltersSchema.safeParse(req.query);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Filter-Parameter',
errors: parseResult.error.issues,
});
return;
}
const { items, total } = await incidentService.getAllIncidents(parseResult.data);
res.status(200).json({
success: true,
data: {
items,
total,
limit: parseResult.data.limit,
offset: parseResult.data.offset,
},
});
} catch (error) {
logger.error('List incidents error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Einsätze' });
}
}
// -------------------------------------------------------------------------
// GET /api/incidents/stats
// -------------------------------------------------------------------------
async getStats(req: Request, res: Response): Promise<void> {
try {
const year = req.query.year ? parseInt(req.query.year as string, 10) : undefined;
if (year !== undefined && (isNaN(year) || year < 2000 || year > 2100)) {
res.status(400).json({ success: false, message: 'Ungültiges Jahr' });
return;
}
const stats = await incidentService.getIncidentStats(year);
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('Get incident stats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistik' });
}
}
// -------------------------------------------------------------------------
// GET /api/incidents/:id
// -------------------------------------------------------------------------
async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const incident = await incidentService.getIncidentById(id);
if (!incident) {
throw new AppError('Einsatz nicht gefunden', 404);
}
// Role-based redaction: only Kommandant+ can see full bericht_text
const canReadBerichtText =
req.userRole !== undefined &&
hasPermission(req.userRole, 'incidents:read_bericht_text');
const responseData = {
...incident,
bericht_text: canReadBerichtText ? incident.bericht_text : undefined,
};
res.status(200).json({ success: true, data: responseData });
} catch (error) {
if (error instanceof AppError) {
res.status(error.statusCode).json({ success: false, message: error.message });
return;
}
logger.error('Get incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents
// -------------------------------------------------------------------------
async createIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const parseResult = CreateEinsatzSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
const einsatz = await incidentService.createIncident(parseResult.data, req.user.id);
logger.info('Incident created via API', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
createdBy: req.user.id,
});
res.status(201).json({ success: true, data: einsatz });
} catch (error) {
logger.error('Create incident error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// PATCH /api/incidents/:id
// -------------------------------------------------------------------------
async updateIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const { id } = req.params;
const parseResult = UpdateEinsatzSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
const einsatz = await incidentService.updateIncident(id, parseResult.data, req.user.id);
res.status(200).json({ success: true, data: einsatz });
} catch (error) {
if (error instanceof Error && error.message === 'Incident not found') {
res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' });
return;
}
logger.error('Update incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id (soft delete)
// -------------------------------------------------------------------------
async deleteIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const { id } = req.params;
await incidentService.deleteIncident(id, req.user.id);
res.status(200).json({ success: true, message: 'Einsatz archiviert' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' });
return;
}
logger.error('Delete incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Archivieren des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/:id/personnel
// -------------------------------------------------------------------------
async assignPersonnel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const parseResult = AssignPersonnelSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
await incidentService.assignPersonnel(id, parseResult.data);
res.status(200).json({ success: true, message: 'Person zugewiesen' });
} catch (error) {
logger.error('Assign personnel error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Zuweisen der Person' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id/personnel/:userId
// -------------------------------------------------------------------------
async removePersonnel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id, userId } = req.params;
await incidentService.removePersonnel(id, userId);
res.status(200).json({ success: true, message: 'Person entfernt' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' });
return;
}
logger.error('Remove personnel error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Entfernen der Person' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/:id/vehicles
// -------------------------------------------------------------------------
async assignVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const parseResult = AssignVehicleSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
await incidentService.assignVehicle(id, parseResult.data);
res.status(200).json({ success: true, message: 'Fahrzeug zugewiesen' });
} catch (error) {
logger.error('Assign vehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Zuweisen des Fahrzeugs' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id/vehicles/:fahrzeugId
// -------------------------------------------------------------------------
async removeVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id, fahrzeugId } = req.params;
await incidentService.removeVehicle(id, fahrzeugId);
res.status(200).json({ success: true, message: 'Fahrzeug entfernt' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' });
return;
}
logger.error('Remove vehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Entfernen des Fahrzeugs' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/refresh-stats (admin utility)
// -------------------------------------------------------------------------
async refreshStats(_req: Request, res: Response): Promise<void> {
try {
await incidentService.refreshStatistikView();
res.status(200).json({ success: true, message: 'Statistik-View aktualisiert' });
} catch (error) {
logger.error('Refresh stats view error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Statistik' });
}
}
}
export default new IncidentController();

View File

@@ -0,0 +1,234 @@
import { Request, Response } from 'express';
import memberService from '../services/member.service';
import logger from '../utils/logger';
import { AppError } from '../middleware/error.middleware';
import {
CreateMemberProfileSchema,
UpdateMemberProfileSchema,
SelfUpdateMemberProfileSchema,
} from '../models/member.model';
// ----------------------------------------------------------------
// Role helpers
// These helpers inspect req.user.role which is populated by the
// requireRole / requirePermission middleware (see member.routes.ts).
// ----------------------------------------------------------------
type AppRole = 'admin' | 'kommandant' | 'mitglied';
function getRole(req: Request): AppRole {
return (req.user as any)?.role ?? 'mitglied';
}
function canWrite(req: Request): boolean {
const role = getRole(req);
return role === 'admin' || role === 'kommandant';
}
function isOwnProfile(req: Request, userId: string): boolean {
return req.user?.id === userId;
}
// ----------------------------------------------------------------
// Controller
// ----------------------------------------------------------------
class MemberController {
/**
* GET /api/members
* Returns a paginated list of all active members.
* Supports ?search=, ?status[]=, ?dienstgrad[]=, ?page=, ?pageSize=
*/
async getMembers(req: Request, res: Response): Promise<void> {
try {
const {
search,
page,
pageSize,
} = req.query as Record<string, string | undefined>;
// Arrays can be sent as ?status[]=aktiv&status[]=passiv or CSV
const statusParam = req.query['status'] as string | string[] | undefined;
const dienstgradParam = req.query['dienstgrad'] as string | string[] | undefined;
const normalizeArray = (v?: string | string[]): string[] | undefined => {
if (!v) return undefined;
return Array.isArray(v) ? v : v.split(',').map((s) => s.trim()).filter(Boolean);
};
const { items, total } = await memberService.getAllMembers({
search,
status: normalizeArray(statusParam) as any,
dienstgrad: normalizeArray(dienstgradParam) as any,
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25,
});
res.status(200).json({
success: true,
data: items,
meta: { total, page: page ? parseInt(page, 10) : 1 },
});
} catch (error) {
logger.error('getMembers error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Mitglieder.' });
}
}
/**
* GET /api/members/stats
* Returns aggregate member counts for each status.
* Must be registered BEFORE /:userId to avoid route collision.
*/
async getMemberStats(req: Request, res: Response): Promise<void> {
try {
const stats = await memberService.getMemberStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getMemberStats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken.' });
}
}
/**
* GET /api/members/:userId
* Returns full member detail including profile and rank history.
*
* Role rules:
* - Kommandant/Admin: all fields
* - Mitglied reading own profile: all fields
* - Mitglied reading another member: geburtsdatum and emergency contact redacted
*/
async getMemberById(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const requestorId = req.user!.id;
const requestorRole = getRole(req);
const ownProfile = isOwnProfile(req, userId);
const member = await memberService.getMemberById(userId);
if (!member) {
res.status(404).json({ success: false, message: 'Mitglied nicht gefunden.' });
return;
}
// Sensitive field masking for non-privileged reads of other members
const canReadSensitive = canWrite(req) || ownProfile;
if (!canReadSensitive && member.profile) {
// Replace geburtsdatum with only the age (year of birth omitted for DSGVO)
const ageMasked: any = { ...member.profile };
if (ageMasked.geburtsdatum) {
const birthYear = new Date(ageMasked.geburtsdatum).getFullYear();
const age = new Date().getFullYear() - birthYear;
ageMasked.geburtsdatum = null;
ageMasked._age = age; // synthesised non-DB field
}
// Redact emergency contact entirely
ageMasked.notfallkontakt_name = null;
ageMasked.notfallkontakt_telefon = null;
res.status(200).json({
success: true,
data: { ...member, profile: ageMasked },
});
return;
}
res.status(200).json({ success: true, data: member });
} catch (error) {
logger.error('getMemberById error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Mitglieds.' });
}
}
/**
* POST /api/members/:userId/profile
* Creates the mitglieder_profile row for an existing auth user.
* Restricted to Kommandant/Admin.
*/
async createMemberProfile(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const parseResult = CreateMemberProfileSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Eingabedaten.',
errors: parseResult.error.flatten().fieldErrors,
});
return;
}
const profile = await memberService.createMemberProfile(userId, parseResult.data);
logger.info('createMemberProfile', { userId, createdBy: req.user!.id });
res.status(201).json({ success: true, data: profile });
} catch (error: any) {
if (error?.message?.includes('existiert bereits')) {
res.status(409).json({ success: false, message: error.message });
return;
}
logger.error('createMemberProfile error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Profils.' });
}
}
/**
* PATCH /api/members/:userId
* Updates the mitglieder_profile row.
*
* Role rules:
* - Kommandant/Admin: full update (all fields)
* - Mitglied (own profile only): restricted to SelfUpdateMemberProfileSchema fields
*/
async updateMember(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const updaterId = req.user!.id;
const ownProfile = isOwnProfile(req, userId);
if (!canWrite(req) && !ownProfile) {
res.status(403).json({
success: false,
message: 'Keine Berechtigung, dieses Profil zu bearbeiten.',
});
return;
}
// Choose validation schema based on role
const schema = canWrite(req) ? UpdateMemberProfileSchema : SelfUpdateMemberProfileSchema;
const parseResult = schema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Eingabedaten.',
errors: parseResult.error.flatten().fieldErrors,
});
return;
}
const profile = await memberService.updateMemberProfile(
userId,
parseResult.data as any,
updaterId
);
logger.info('updateMember', { userId, updatedBy: updaterId });
res.status(200).json({ success: true, data: profile });
} catch (error: any) {
if (error?.message === 'Mitgliedsprofil nicht gefunden.') {
res.status(404).json({ success: false, message: error.message });
return;
}
logger.error('updateMember error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' });
}
}
}
export default new MemberController();

View File

@@ -0,0 +1,346 @@
import { Request, Response } from 'express';
import trainingService from '../services/training.service';
import {
CreateUebungSchema,
UpdateUebungSchema,
UpdateRsvpSchema,
MarkAttendanceSchema,
CancelEventSchema,
} from '../models/training.model';
import logger from '../utils/logger';
import environment from '../config/environment';
class TrainingController {
// -------------------------------------------------------------------------
// GET /api/training
// -------------------------------------------------------------------------
getUpcoming = async (req: Request, res: Response): Promise<void> => {
try {
const limit = Math.min(Number(req.query.limit ?? 10), 50);
const userId = req.user?.id;
const events = await trainingService.getUpcomingEvents(limit, userId);
res.json({ success: true, data: events });
} catch (error) {
logger.error('getUpcoming error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar?from=&to=
// -------------------------------------------------------------------------
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 userId = req.user?.id;
const events = await trainingService.getEventsByDateRange(from, to, userId);
res.json({ success: true, data: events });
} catch (error) {
logger.error('getCalendarRange error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar.ics?token=<calendarToken>
//
// Auth design: We use a per-user opaque token stored in `calendar_tokens`.
// This allows calendar clients (Apple Calendar, Google Calendar) to subscribe
// without sending a Bearer token on every refresh. The token is long-lived
// and can be rotated by calling POST /api/training/calendar-token/rotate.
//
// TRADEOFF DISCUSSION:
// Option A (used here) — Personal calendar token:
// + Works natively in all calendar apps (no auth headers needed)
// + Rotating the token invalidates stale subscriptions
// - Token embedded in URL is visible in server logs / browser history
// → Mitigation: tokens are 256-bit random, never contain PII
//
// Option B — Bearer auth only:
// + No token in URL
// - Most native calendar apps cannot send custom headers;
// requires a proxy or special client
// → Rejected for this use case (firefighter mobile phones)
//
// Option C — Publicly readable feed (no auth):
// + Simplest
// - Leaks member names and attendance status to anyone with the URL
// → Rejected on privacy grounds
// -------------------------------------------------------------------------
getIcalExport = async (req: Request, res: Response): Promise<void> => {
try {
const token = req.query.token as string | undefined;
let userId: string | undefined;
if (token) {
const resolved = await trainingService.resolveCalendarToken(token);
if (!resolved) {
res.status(401).json({ success: false, message: 'Ungültiger Kalender-Token' });
return;
}
userId = resolved;
} else if (req.user) {
// Also accept a normal Bearer token for direct browser access
userId = req.user.id;
}
// userId may be undefined → returns all events without personal RSVP info
const ics = await trainingService.getCalendarExport(userId);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="feuerwehr-rems.ics"');
// 30-minute cache — calendar clients typically re-fetch at this interval
res.setHeader('Cache-Control', 'max-age=1800, public');
res.send(ics);
} catch (error) {
logger.error('getIcalExport error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar-token (authenticated — get or create token)
// -------------------------------------------------------------------------
getCalendarToken = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const token = await trainingService.getOrCreateCalendarToken(req.user.id);
const baseUrl = environment.cors.origin.replace(/\/$/, '');
const subscribeUrl = `${baseUrl.replace('localhost:3001', 'localhost:3000')}/api/training/calendar.ics?token=${token}`;
res.json({
success: true,
data: {
token,
subscribeUrl,
instructions: 'Kopiere diese URL und füge sie in "Kalender abonnieren" ein (Apple Kalender, Google Calendar, Thunderbird, etc.).',
},
});
} catch (error) {
logger.error('getCalendarToken error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/stats?year=2026
// -------------------------------------------------------------------------
getStats = async (req: Request, res: Response): Promise<void> => {
try {
const year = Number(req.query.year ?? new Date().getFullYear());
if (isNaN(year) || year < 2000 || year > 2100) {
res.status(400).json({ success: false, message: 'Ungültiges Jahr' });
return;
}
const stats = await trainingService.getMemberParticipationStats(year);
res.json({ success: true, data: stats, meta: { year } });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/:id
// -------------------------------------------------------------------------
getById = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const userId = req.user?.id;
// Determine if the requester may see the full attendee list
// In v1 we use a simple check: if the user has training:write permission
// (Kommandant / Gruppenführer), populate teilnahmen.
// The permission flag is set on req by requirePermission middleware,
// but we check it here by convention.
const canSeeTeilnahmen = (req as any).canSeeTeilnahmen === true;
const event = await trainingService.getEventById(id, userId, canSeeTeilnahmen);
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/training
// -------------------------------------------------------------------------
createEvent = async (req: Request, res: Response): Promise<void> => {
try {
const parsed = CreateUebungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const event = await trainingService.createEvent(parsed.data, req.user!.id);
res.status(201).json({ success: true, data: event });
} catch (error) {
logger.error('createEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/training/:id
// -------------------------------------------------------------------------
updateEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const parsed = UpdateUebungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const event = await trainingService.updateEvent(id, parsed.data, req.user!.id);
res.json({ success: true, data: event });
} catch (error: any) {
if (error.message === 'Event not found') {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
logger.error('updateEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// DELETE /api/training/:id (soft cancel)
// -------------------------------------------------------------------------
cancelEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const parsed = CancelEventSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.cancelEvent(id, parsed.data.absage_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' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/training/:id/attendance — own RSVP
// -------------------------------------------------------------------------
updateRsvp = async (req: Request, res: Response): Promise<void> => {
try {
const { id: uebungId } = req.params;
const userId = req.user!.id;
const parsed = UpdateRsvpSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.updateAttendanceRSVP(
uebungId,
userId,
parsed.data.status,
parsed.data.bemerkung
);
res.json({ success: true, message: 'Rückmeldung gespeichert' });
} catch (error) {
logger.error('updateRsvp error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Rückmeldung' });
}
};
// -------------------------------------------------------------------------
// POST /api/training/:id/attendance/mark — bulk mark as erschienen
// -------------------------------------------------------------------------
markAttendance = async (req: Request, res: Response): Promise<void> => {
try {
const { id: uebungId } = req.params;
const parsed = MarkAttendanceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.markAttendance(uebungId, parsed.data.userIds, req.user!.id);
res.json({
success: true,
message: `${parsed.data.userIds.length} Personen als erschienen markiert`,
});
} catch (error) {
logger.error('markAttendance error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erfassen der Anwesenheit' });
}
};
}
export default new TrainingController();

View File

@@ -0,0 +1,344 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import vehicleService from '../services/vehicle.service';
import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model';
import logger from '../utils/logger';
// ── Zod Validation Schemas ────────────────────────────────────────────────────
const FahrzeugStatusEnum = z.enum([
FahrzeugStatus.Einsatzbereit,
FahrzeugStatus.AusserDienstWartung,
FahrzeugStatus.AusserDienstSchaden,
FahrzeugStatus.InLehrgang,
]);
const PruefungArtEnum = z.enum([
PruefungArt.HU,
PruefungArt.AU,
PruefungArt.UVV,
PruefungArt.Leiter,
PruefungArt.Kran,
PruefungArt.Seilwinde,
PruefungArt.Sonstiges,
]);
const isoDate = z.string().regex(
/^\d{4}-\d{2}-\d{2}$/,
'Expected ISO date format YYYY-MM-DD'
);
const CreateFahrzeugSchema = z.object({
bezeichnung: z.string().min(1).max(100),
kurzname: z.string().max(20).optional(),
amtliches_kennzeichen: z.string().max(20).optional(),
fahrgestellnummer: z.string().max(50).optional(),
baujahr: z.number().int().min(1950).max(2100).optional(),
hersteller: z.string().max(100).optional(),
typ_schluessel: z.string().max(30).optional(),
besatzung_soll: z.string().max(10).optional(),
status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).optional(),
standort: z.string().max(100).optional(),
bild_url: z.string().url().max(500).optional(),
});
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial();
const UpdateStatusSchema = z.object({
status: FahrzeugStatusEnum,
bemerkung: z.string().max(500).optional().default(''),
});
const CreatePruefungSchema = z.object({
pruefung_art: PruefungArtEnum,
faellig_am: isoDate,
durchgefuehrt_am: isoDate.optional(),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden', 'ausstehend']).optional(),
pruefende_stelle: z.string().max(150).optional(),
kosten: z.number().min(0).optional(),
dokument_url: z.string().url().max(500).optional(),
bemerkung: z.string().max(1000).optional(),
});
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(),
beschreibung: z.string().min(1).max(2000),
km_stand: z.number().int().min(0).optional(),
kraftstoff_liter: z.number().min(0).optional(),
kosten: z.number().min(0).optional(),
externe_werkstatt: z.string().max(150).optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string {
// req.user is guaranteed by the authenticate middleware
return req.user!.id;
}
// ── Controller ────────────────────────────────────────────────────────────────
class VehicleController {
/**
* GET /api/vehicles
* Fleet overview list with per-vehicle inspection badge data.
*/
async listVehicles(req: Request, res: Response): Promise<void> {
try {
const vehicles = await vehicleService.getAllVehicles();
res.status(200).json({ success: true, data: vehicles });
} catch (error) {
logger.error('listVehicles error', { error });
res.status(500).json({ success: false, message: 'Fahrzeuge konnten nicht geladen werden' });
}
}
/**
* GET /api/vehicles/stats
* Aggregated KPI counts for the dashboard strip.
*/
async getStats(req: Request, res: Response): Promise<void> {
try {
const stats = await vehicleService.getVehicleStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' });
}
}
/**
* GET /api/vehicles/alerts?daysAhead=30
* Upcoming and overdue inspections — used by the InspectionAlerts dashboard panel.
* Returns alerts sorted by urgency (most overdue / soonest due first).
*/
async getAlerts(req: Request, res: Response): Promise<void> {
try {
const daysAhead = Math.min(
parseInt((req.query.daysAhead as string) || '30', 10),
365 // hard cap — never expose more than 1 year of lookahead
);
if (isNaN(daysAhead) || daysAhead < 0) {
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
return;
}
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
res.status(200).json({ success: true, data: alerts });
} catch (error) {
logger.error('getAlerts error', { error });
res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' });
}
}
/**
* GET /api/vehicles/:id
* Full vehicle detail with pruefstatus, inspection history, and wartungslog.
*/
async getVehicle(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const vehicle = await vehicleService.getVehicleById(id);
if (!vehicle) {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: vehicle });
} catch (error) {
logger.error('getVehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht geladen werden' });
}
}
/**
* POST /api/vehicles
* Create a new vehicle. Requires vehicles:write permission.
*/
async createVehicle(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateFahrzeugSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: vehicle });
} catch (error) {
logger.error('createVehicle error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht erstellt werden' });
}
}
/**
* PATCH /api/vehicles/:id
* Update vehicle fields. Requires vehicles:write permission.
*/
async updateVehicle(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const parsed = UpdateFahrzeugSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: vehicle });
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('updateVehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht aktualisiert werden' });
}
}
/**
* PATCH /api/vehicles/:id/status
* Live status change — the Socket.IO hook point for Tier 3.
* Requires vehicles:write permission.
*
* The `io` instance is attached to req.app in server.ts (Tier 3):
* app.set('io', io);
* and retrieved here via req.app.get('io').
*/
async updateVehicleStatus(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const parsed = UpdateStatusSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
// Tier 3: io will be available via req.app.get('io') once Socket.IO is wired up.
// Passing undefined here is safe — the service handles it gracefully.
const io = req.app.get('io') ?? undefined;
await vehicleService.updateVehicleStatus(
id,
parsed.data.status,
parsed.data.bemerkung,
getUserId(req),
io
);
res.status(200).json({ success: true, message: 'Status aktualisiert' });
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('updateVehicleStatus error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
// ── Inspections ─────────────────────────────────────────────────────────────
/**
* POST /api/vehicles/:id/pruefungen
* Record an inspection (scheduled or completed). Requires vehicles:write.
*/
async addPruefung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const parsed = CreatePruefungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const pruefung = await vehicleService.addPruefung(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: pruefung });
} catch (error) {
logger.error('addPruefung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Prüfung konnte nicht eingetragen werden' });
}
}
/**
* GET /api/vehicles/:id/pruefungen
* Full inspection history for a vehicle.
*/
async getPruefungen(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const pruefungen = await vehicleService.getPruefungenForVehicle(id);
res.status(200).json({ success: true, data: pruefungen });
} catch (error) {
logger.error('getPruefungen error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Prüfungshistorie konnte nicht geladen werden' });
}
}
// ── Maintenance Log ──────────────────────────────────────────────────────────
/**
* POST /api/vehicles/:id/wartung
* Add a maintenance log entry. Requires vehicles:write.
*/
async addWartung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const parsed = CreateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry });
} catch (error) {
logger.error('addWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
}
}
/**
* GET /api/vehicles/:id/wartung
* Maintenance log for a vehicle.
*/
async getWartung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const entries = await vehicleService.getWartungslogForVehicle(id);
res.status(200).json({ success: true, data: entries });
} catch (error) {
logger.error('getWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' });
}
}
}
export default new VehicleController();

View File

@@ -0,0 +1,146 @@
-- =============================================================================
-- Migration 002: Audit Log Table
-- GDPR Art. 5(2) Accountability + Art. 30 Records of Processing Activities
--
-- Design decisions:
-- - UUID primary key (consistent with users table)
-- - user_id is nullable: pre-auth events (LOGIN failures) have no user yet
-- - old_value / new_value are JSONB: flexible, queryable, indexable
-- - ip_address stored as TEXT (supports IPv4 + IPv6); anonymised after 90 days
-- - Table is immutable: a PostgreSQL RULE blocks UPDATE and DELETE at the
-- SQL level, which is simpler than triggers and cannot be bypassed by the
-- application role
-- - Partial index on ip_address covers only recent rows (90-day window) to
-- support efficient anonymisation queries without a full-table scan
-- =============================================================================
-- -------------------------------------------------------
-- 1. Enums — define before the table
-- -------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM (
'CREATE',
'UPDATE',
'DELETE',
'LOGIN',
'LOGOUT',
'EXPORT',
'PERMISSION_DENIED',
'PASSWORD_CHANGE',
'ROLE_CHANGE'
);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_resource_type') THEN
CREATE TYPE audit_resource_type AS ENUM (
'MEMBER',
'INCIDENT',
'VEHICLE',
'EQUIPMENT',
'QUALIFICATION',
'USER',
'SYSTEM'
);
END IF;
END
$$;
-- -------------------------------------------------------
-- 2. Core audit_log table
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255), -- denormalised snapshot; users can be deleted
action audit_action NOT NULL,
resource_type audit_resource_type NOT NULL,
resource_id VARCHAR(255), -- may be UUID, numeric ID, or 'system'
old_value JSONB, -- state before the operation
new_value JSONB, -- state after the operation
ip_address TEXT, -- anonymised to '[anonymized]' after 90 days
user_agent TEXT,
metadata JSONB DEFAULT '{}', -- any extra context (e.g. export format, reason)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Prevent modification of audit records at the database level.
-- Using RULE rather than a trigger because rules are evaluated before
-- the operation and cannot be disabled without superuser access.
CREATE OR REPLACE RULE audit_log_no_update AS
ON UPDATE TO audit_log DO INSTEAD NOTHING;
CREATE OR REPLACE RULE audit_log_no_delete AS
ON DELETE TO audit_log DO INSTEAD NOTHING;
-- -------------------------------------------------------
-- 3. Indexes
-- -------------------------------------------------------
-- Lookup by actor (admin "what did this user do?")
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id
ON audit_log (user_id)
WHERE user_id IS NOT NULL;
-- Lookup by subject resource (admin "what happened to member X?")
CREATE INDEX IF NOT EXISTS idx_audit_log_resource
ON audit_log (resource_type, resource_id);
-- Time-range queries and retention scans (most common filter)
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
ON audit_log (created_at DESC);
-- Action-type filter
CREATE INDEX IF NOT EXISTS idx_audit_log_action
ON audit_log (action);
-- Partial index: only rows where IP address is not yet anonymised.
-- The anonymisation job does: WHERE created_at < NOW() - INTERVAL '90 days'
-- AND ip_address != '[anonymized]'
-- This index makes that query O(matched rows) instead of O(table size).
CREATE INDEX IF NOT EXISTS idx_audit_log_ip_retention
ON audit_log (created_at)
WHERE ip_address IS NOT NULL
AND ip_address != '[anonymized]';
-- -------------------------------------------------------
-- 4. OPTIONAL — Range partitioning by month (recommended
-- for high-volume deployments; skip in small setups)
--
-- To use partitioning, replace the CREATE TABLE above with
-- the following DDL *before* the first row is inserted.
-- Partitioning cannot be added to an existing unpartitioned
-- table without a full rewrite (pg_partman can automate this).
--
-- CREATE TABLE audit_log (
-- id UUID NOT NULL DEFAULT uuid_generate_v4(),
-- user_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- user_email VARCHAR(255),
-- action audit_action NOT NULL,
-- resource_type audit_resource_type NOT NULL,
-- resource_id VARCHAR(255),
-- old_value JSONB,
-- new_value JSONB,
-- ip_address TEXT,
-- user_agent TEXT,
-- metadata JSONB DEFAULT '{}',
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- ) PARTITION BY RANGE (created_at);
--
-- -- Create monthly partitions with pg_partman or manually:
-- CREATE TABLE audit_log_2026_01 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- CREATE TABLE audit_log_2026_02 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- -- ... and so on. pg_partman automates partition creation.
--
-- With partitioning, old partitions can be DROPped for
-- efficient bulk retention deletion without a full-table scan.
-- -------------------------------------------------------

View File

@@ -0,0 +1,138 @@
-- Migration: 003_create_mitglieder_profile
-- Creates the mitglieder_profile and dienstgrad_verlauf tables for fire department member data.
-- Rollback:
-- DROP TABLE IF EXISTS dienstgrad_verlauf;
-- DROP TABLE IF EXISTS mitglieder_profile;
-- ============================================================
-- mitglieder_profile
-- One-to-one extension of the users table.
-- A user can exist without a profile (profile is created later
-- by a Kommandant). The user_id is both FK and UNIQUE.
-- ============================================================
CREATE TABLE IF NOT EXISTS mitglieder_profile (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Internal member identifier assigned by the Kommandant
mitglieds_nr VARCHAR(32) UNIQUE,
-- Rank (Dienstgrad) with allowed values enforced by CHECK
dienstgrad VARCHAR(64)
CHECK (dienstgrad IS NULL OR dienstgrad IN (
'Feuerwehranwärter',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann'
)),
dienstgrad_seit DATE,
-- Funktion(en) — a member can hold multiple roles simultaneously
-- Stored as a PostgreSQL TEXT array
funktion TEXT[] DEFAULT '{}',
-- Membership status
status VARCHAR(32) NOT NULL DEFAULT 'aktiv'
CHECK (status IN (
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten'
)),
-- Important dates
eintrittsdatum DATE,
austrittsdatum DATE,
geburtsdatum DATE, -- sensitive: only shown to Kommandant/Admin
-- Contact information (stored raw, formatted on display)
telefon_mobil VARCHAR(32),
telefon_privat VARCHAR(32),
-- Emergency contact (sensitive: only own record visible to Mitglied)
notfallkontakt_name VARCHAR(255),
notfallkontakt_telefon VARCHAR(32),
-- Driving licenses (e.g. ['B', 'C', 'CE'])
fuehrerscheinklassen TEXT[] DEFAULT '{}',
-- Uniform sizing
tshirt_groesse VARCHAR(8)
CHECK (tshirt_groesse IS NULL OR tshirt_groesse IN (
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'
)),
schuhgroesse VARCHAR(8),
-- Free-text notes (Kommandant only)
bemerkungen TEXT,
-- Profile photo URL (separate from the Authentik profile_picture_url)
bild_url TEXT,
-- Audit timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Enforce one-to-one relationship with users
CONSTRAINT uq_mitglieder_profile_user_id UNIQUE (user_id)
);
-- ============================================================
-- Indexes for the most common query patterns
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_user_id
ON mitglieder_profile(user_id);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_status
ON mitglieder_profile(status);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_dienstgrad
ON mitglieder_profile(dienstgrad);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_mitglieds_nr
ON mitglieder_profile(mitglieds_nr);
-- ============================================================
-- Auto-update trigger for updated_at
-- Reuses the function already created by migration 001.
-- ============================================================
CREATE TRIGGER update_mitglieder_profile_updated_at
BEFORE UPDATE ON mitglieder_profile
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- dienstgrad_verlauf
-- Append-only audit log of every rank change.
-- Never deleted; soft history only.
-- ============================================================
CREATE TABLE IF NOT EXISTS dienstgrad_verlauf (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dienstgrad_neu VARCHAR(64) NOT NULL,
dienstgrad_alt VARCHAR(64), -- NULL on first assignment
datum DATE NOT NULL DEFAULT CURRENT_DATE,
durch_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
bemerkung TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_user_id
ON dienstgrad_verlauf(user_id);
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_datum
ON dienstgrad_verlauf(datum DESC);

View File

@@ -0,0 +1,270 @@
-- Migration: 004_create_einsaetze.sql
-- Feature: Einsatzprotokoll (Incident Management)
-- Depends on: 001_create_users_table.sql
-- Rollback instructions at bottom of file
-- ---------------------------------------------------------------------------
-- ENUM-LIKE CHECK CONSTRAINTS (as VARCHAR + CHECK for easy extension)
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- SEQUENCE SUPPORT TABLE for year-based Einsatz-Nr (YYYY-NNN)
-- One row per year; nextval equivalent done via UPDATE...RETURNING
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_nr_sequence (
year INTEGER PRIMARY KEY,
last_nr INTEGER NOT NULL DEFAULT 0
);
-- ---------------------------------------------------------------------------
-- FAHRZEUGE TABLE (vehicles — stub so junction table has a valid FK target)
-- A full Fahrzeuge feature is covered by 005_create_fahrzeuge.sql.
-- We do NOT create the table here; migration 005 owns it.
-- The FK in einsatz_fahrzeuge will resolve correctly when 005 runs first
-- in the standard sort order (004 runs before 005, so we use DEFERRABLE or
-- we simply add the FK as a separate ALTER TABLE at the end of this file,
-- after we know 005 may not have run yet).
--
-- Resolution strategy: einsatz_fahrzeuge.fahrzeug_id FK is defined as
-- DEFERRABLE INITIALLY DEFERRED so it is checked at transaction commit time
-- rather than at statement time, allowing this migration to run without
-- requiring fahrzeuge to exist yet.
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- MAIN EINSAETZE TABLE
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsaetze (
-- Identity
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
einsatz_nr VARCHAR(12) NOT NULL, -- Format: 2026-001
-- Core timestamps (all TIMESTAMPTZ — never null for alarm_time)
alarm_time TIMESTAMPTZ NOT NULL,
ausrueck_time TIMESTAMPTZ,
ankunft_time TIMESTAMPTZ,
einrueck_time TIMESTAMPTZ,
-- Classification
einsatz_art VARCHAR(30) NOT NULL
CONSTRAINT chk_einsatz_art CHECK (einsatz_art IN (
'Brand',
'THL',
'ABC',
'BMA',
'Hilfeleistung',
'Fehlalarm',
'Brandsicherheitswache'
)),
einsatz_stichwort VARCHAR(30), -- B1/B2/B3/B4, THL 1/2/3, etc.
-- Location
strasse VARCHAR(150),
hausnummer VARCHAR(20),
ort VARCHAR(100),
koordinaten POINT, -- (longitude, latitude)
-- Narrative
bericht_kurz VARCHAR(255), -- short description, visible to all
bericht_text TEXT, -- full narrative, restricted to Kommandant+
-- References
einsatzleiter_id UUID
CONSTRAINT fk_einsaetze_einsatzleiter REFERENCES users(id) ON DELETE SET NULL,
-- Operational metadata
alarmierung_art VARCHAR(30) NOT NULL DEFAULT 'ILS'
CONSTRAINT chk_alarmierung_art CHECK (alarmierung_art IN (
'ILS',
'DME',
'Telefon',
'Vor_Ort',
'Sonstiges'
)),
status VARCHAR(20) NOT NULL DEFAULT 'aktiv'
CONSTRAINT chk_einsatz_status CHECK (status IN (
'aktiv',
'abgeschlossen',
'archiviert'
)),
-- Audit
created_by UUID
CONSTRAINT fk_einsaetze_created_by REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Uniqueness: einsatz_nr is globally unique (year+seq guarantees it)
CONSTRAINT uq_einsatz_nr UNIQUE (einsatz_nr)
);
-- Performance indexes
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_time ON einsaetze(alarm_time DESC);
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatz_art ON einsaetze(einsatz_art);
CREATE INDEX IF NOT EXISTS idx_einsaetze_status ON einsaetze(status);
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatzleiter ON einsaetze(einsatzleiter_id);
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_year ON einsaetze(EXTRACT(YEAR FROM alarm_time));
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art_year ON einsaetze(einsatz_art, EXTRACT(YEAR FROM alarm_time));
-- Auto-update updated_at
CREATE TRIGGER update_einsaetze_updated_at
BEFORE UPDATE ON einsaetze
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ---------------------------------------------------------------------------
-- JUNCTION TABLE: einsatz_fahrzeuge
-- NOTE: fahrzeug_id FK references fahrzeuge which is created in 005.
-- We define the FK as DEFERRABLE INITIALLY DEFERRED so the constraint is
-- validated at transaction commit, not statement time. This means the
-- migration can run before 005 as long as both run in the same session.
-- In production where 005 was already run, the FK resolves immediately.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_fahrzeuge (
einsatz_id UUID NOT NULL
CONSTRAINT fk_ef_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
fahrzeug_id UUID NOT NULL,
-- FK added separately below via ALTER TABLE to handle cross-migration dependency
-- Vehicle-level timestamps (may differ from main einsatz times)
ausrueck_time TIMESTAMPTZ,
einrueck_time TIMESTAMPTZ,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_einsatz_fahrzeuge PRIMARY KEY (einsatz_id, fahrzeug_id)
);
-- Add the FK to fahrzeuge only if the fahrzeuge table already exists
-- (handles the case where 004 runs after 005 in a fresh deployment).
-- If fahrzeuge does not exist yet, the FK will be added by migration 005
-- via an ALTER TABLE at the end of that migration.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'fahrzeuge'
) THEN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'einsatz_fahrzeuge'
AND constraint_name = 'fk_ef_fahrzeug'
) THEN
ALTER TABLE einsatz_fahrzeuge
ADD CONSTRAINT fk_ef_fahrzeug
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
END IF;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_ef_fahrzeug_id ON einsatz_fahrzeuge(fahrzeug_id);
-- ---------------------------------------------------------------------------
-- JUNCTION TABLE: einsatz_personal
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_personal (
einsatz_id UUID NOT NULL
CONSTRAINT fk_ep_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
user_id UUID NOT NULL
CONSTRAINT fk_ep_user REFERENCES users(id) ON DELETE CASCADE,
funktion VARCHAR(50) NOT NULL DEFAULT 'Mannschaft'
CONSTRAINT chk_ep_funktion CHECK (funktion IN (
'Einsatzleiter',
'Gruppenführer',
'Maschinist',
'Atemschutz',
'Sicherheitstrupp',
'Melder',
'Wassertrupp',
'Angriffstrupp',
'Mannschaft',
'Sonstiges'
)),
-- Personal-level timestamps
alarm_time TIMESTAMPTZ,
ankunft_time TIMESTAMPTZ,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_einsatz_personal PRIMARY KEY (einsatz_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_ep_user_id ON einsatz_personal(user_id);
CREATE INDEX IF NOT EXISTS idx_ep_funktion ON einsatz_personal(funktion);
-- ---------------------------------------------------------------------------
-- FUNCTION: generate_einsatz_nr(alarm_time)
-- Atomically generates the next Einsatz-Nr for a given year.
-- Uses an advisory lock to serialise concurrent inserts for the same year.
-- Returns format: '2026-001'
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION generate_einsatz_nr(p_alarm_time TIMESTAMPTZ)
RETURNS VARCHAR AS $$
DECLARE
v_year INTEGER;
v_nr INTEGER;
BEGIN
v_year := EXTRACT(YEAR FROM p_alarm_time)::INTEGER;
-- Upsert the sequence row, then atomically increment and return
INSERT INTO einsatz_nr_sequence (year, last_nr)
VALUES (v_year, 1)
ON CONFLICT (year) DO UPDATE
SET last_nr = einsatz_nr_sequence.last_nr + 1
RETURNING last_nr INTO v_nr;
RETURN v_year::TEXT || '-' || LPAD(v_nr::TEXT, 3, '0');
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- MATERIALIZED VIEW: einsatz_statistik
-- Pre-aggregated stats used by the dashboard and annual KBI reports.
-- Refresh manually after inserts/updates via: REFRESH MATERIALIZED VIEW einsatz_statistik;
-- ---------------------------------------------------------------------------
CREATE MATERIALIZED VIEW IF NOT EXISTS einsatz_statistik AS
SELECT
EXTRACT(YEAR FROM alarm_time)::INTEGER AS jahr,
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
einsatz_art,
COUNT(*) AS anzahl,
-- Hilfsfrist: median minutes from alarm to arrival (only where ankunft_time is set)
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
-- Total duration (alarm to einrueck)
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min,
COUNT(*) FILTER (WHERE status = 'abgeschlossen') AS anzahl_abgeschlossen,
COUNT(*) FILTER (WHERE status = 'aktiv') AS anzahl_aktiv
FROM einsaetze
WHERE status != 'archiviert'
GROUP BY
EXTRACT(YEAR FROM alarm_time),
EXTRACT(MONTH FROM alarm_time),
einsatz_art
WITH DATA;
CREATE UNIQUE INDEX IF NOT EXISTS idx_einsatz_statistik_pk
ON einsatz_statistik(jahr, monat, einsatz_art);
CREATE INDEX IF NOT EXISTS idx_einsatz_statistik_jahr
ON einsatz_statistik(jahr);
-- ---------------------------------------------------------------------------
-- ROLLBACK INSTRUCTIONS (run in reverse order to undo):
--
-- DROP MATERIALIZED VIEW IF EXISTS einsatz_statistik;
-- DROP FUNCTION IF EXISTS generate_einsatz_nr(TIMESTAMPTZ);
-- DROP TABLE IF EXISTS einsatz_personal;
-- DROP TABLE IF EXISTS einsatz_fahrzeuge;
-- DROP TABLE IF EXISTS einsaetze;
-- DROP TABLE IF EXISTS fahrzeuge;
-- DROP TABLE IF EXISTS einsatz_nr_sequence;
-- ---------------------------------------------------------------------------

View File

@@ -0,0 +1,331 @@
-- Migration 005: Fahrzeugverwaltung (Vehicle Fleet Management)
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
-- ============================================================
-- TABLE: fahrzeuge
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeuge (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bezeichnung VARCHAR(100) NOT NULL, -- e.g. "LF 20/16", "HLF 10"
kurzname VARCHAR(20), -- e.g. "LF1", "HLF2"
amtliches_kennzeichen VARCHAR(20) UNIQUE, -- e.g. "WN-FW 1"
fahrgestellnummer VARCHAR(50), -- VIN
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
hersteller VARCHAR(100), -- e.g. "MAN", "Mercedes-Benz", "Rosenbauer"
typ_schluessel VARCHAR(30), -- DIN 14502 code, e.g. "LF 20/16"
besatzung_soll VARCHAR(10), -- crew config e.g. "1/8", "1/5"
status VARCHAR(40) NOT NULL DEFAULT 'einsatzbereit'
CHECK (status IN (
'einsatzbereit',
'ausser_dienst_wartung',
'ausser_dienst_schaden',
'in_lehrgang'
)),
status_bemerkung TEXT,
standort VARCHAR(100) NOT NULL DEFAULT 'Feuerwehrhaus',
bild_url VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_status ON fahrzeuge(status);
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_kennzeichen ON fahrzeuge(amtliches_kennzeichen);
-- Auto-update updated_at (reuses function from migration 001)
CREATE TRIGGER update_fahrzeuge_updated_at
BEFORE UPDATE ON fahrzeuge
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- TABLE: fahrzeug_pruefungen
-- Stores both upcoming scheduled inspections AND completed ones.
-- A row with durchgefuehrt_am = NULL is an open/scheduled inspection.
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeug_pruefungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
pruefung_art VARCHAR(30) NOT NULL
CHECK (pruefung_art IN (
'HU', -- Hauptuntersuchung (TÜV), 24-month interval
'AU', -- Abgasuntersuchung, 12-month interval
'UVV', -- Unfallverhütungsvorschrift BGV D29, 12-month
'Leiter', -- Leiternprüfung (DLK), 12-month
'Kran', -- Kranprüfung, 12-month
'Seilwinde', -- Seilwindenprüfung, 12-month
'Sonstiges'
)),
-- faellig_am: the deadline by which this inspection must be completed
faellig_am DATE NOT NULL,
-- durchgefuehrt_am: NULL = not yet done; set when inspection is completed
durchgefuehrt_am DATE,
ergebnis VARCHAR(30)
CHECK (ergebnis IS NULL OR ergebnis IN (
'bestanden',
'bestanden_mit_maengeln',
'nicht_bestanden',
'ausstehend'
)),
-- naechste_faelligkeit: auto-calculated next due date after completion
naechste_faelligkeit DATE,
pruefende_stelle VARCHAR(150), -- e.g. "TÜV Süd Stuttgart", "DEKRA"
kosten DECIMAL(8,2),
dokument_url VARCHAR(500),
bemerkung TEXT,
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_id ON fahrzeug_pruefungen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_pruefungen_faellig_am ON fahrzeug_pruefungen(faellig_am);
CREATE INDEX IF NOT EXISTS idx_pruefungen_art ON fahrzeug_pruefungen(pruefung_art);
-- Composite index for the "latest per type" query pattern
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_art_faellig
ON fahrzeug_pruefungen(fahrzeug_id, pruefung_art, faellig_am DESC);
-- ============================================================
-- TABLE: fahrzeug_wartungslog
-- Service/maintenance log entries (fuel, repairs, tyres, etc.)
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeug_wartungslog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
datum DATE NOT NULL,
art VARCHAR(30)
CHECK (art IS NULL OR art IN (
'Inspektion',
'Reparatur',
'Kraftstoff',
'Reifenwechsel',
'Hauptuntersuchung',
'Reinigung',
'Sonstiges'
)),
beschreibung TEXT NOT NULL,
km_stand INTEGER CHECK (km_stand >= 0),
kraftstoff_liter DECIMAL(6,2) CHECK (kraftstoff_liter >= 0),
kosten DECIMAL(8,2) CHECK (kosten >= 0),
externe_werkstatt VARCHAR(150),
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wartungslog_fahrzeug_id ON fahrzeug_wartungslog(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_wartungslog_datum ON fahrzeug_wartungslog(datum DESC);
-- ============================================================
-- VIEW: fahrzeuge_mit_pruefstatus
-- For each vehicle, joins its LATEST scheduled pruefung per art
-- and computes tage_bis_faelligkeit (negative = overdue).
-- The dashboard alert panel and fleet overview query this view.
-- ============================================================
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
WITH latest_pruefungen AS (
-- For each (fahrzeug, pruefung_art) pair, pick the row with the
-- latest faellig_am that is NOT yet completed (durchgefuehrt_am IS NULL),
-- OR if all are completed, the one with the highest naechste_faelligkeit.
SELECT DISTINCT ON (fahrzeug_id, pruefung_art)
fahrzeug_id,
pruefung_art,
id AS pruefung_id,
faellig_am,
durchgefuehrt_am,
ergebnis,
naechste_faelligkeit,
pruefende_stelle,
CURRENT_DATE - faellig_am::date AS tage_ueberfaellig,
faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit
FROM fahrzeug_pruefungen
ORDER BY
fahrzeug_id,
pruefung_art,
-- Open inspections (nicht durchgeführt) first, then most recent
(durchgefuehrt_am IS NULL) DESC,
faellig_am DESC
)
SELECT
f.id,
f.bezeichnung,
f.kurzname,
f.amtliches_kennzeichen,
f.fahrgestellnummer,
f.baujahr,
f.hersteller,
f.typ_schluessel,
f.besatzung_soll,
f.status,
f.status_bemerkung,
f.standort,
f.bild_url,
f.created_at,
f.updated_at,
-- HU
hu.pruefung_id AS hu_pruefung_id,
hu.faellig_am AS hu_faellig_am,
hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit,
hu.ergebnis AS hu_ergebnis,
-- AU
au.pruefung_id AS au_pruefung_id,
au.faellig_am AS au_faellig_am,
au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit,
au.ergebnis AS au_ergebnis,
-- UVV
uvv.pruefung_id AS uvv_pruefung_id,
uvv.faellig_am AS uvv_faellig_am,
uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit,
uvv.ergebnis AS uvv_ergebnis,
-- Leiter (DLK only)
leiter.pruefung_id AS leiter_pruefung_id,
leiter.faellig_am AS leiter_faellig_am,
leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit,
leiter.ergebnis AS leiter_ergebnis,
-- Overall worst tage_bis_faelligkeit across all active inspections
LEAST(
hu.tage_bis_faelligkeit,
au.tage_bis_faelligkeit,
uvv.tage_bis_faelligkeit,
leiter.tage_bis_faelligkeit
) AS naechste_pruefung_tage
FROM
fahrzeuge f
LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU'
LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU'
LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV'
LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter';
-- ============================================================
-- SEED DATA: 3 typical German Feuerwehr vehicles
-- ============================================================
DO $$
DECLARE
v_lf10_id UUID := uuid_generate_v4();
v_hlf20_id UUID := uuid_generate_v4();
v_mtf_id UUID := uuid_generate_v4();
BEGIN
-- Only insert if no vehicles exist yet (idempotent seed)
IF (SELECT COUNT(*) FROM fahrzeuge) = 0 THEN
-- 1) LF 10 Standard Löschgruppenfahrzeug
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, standort
) VALUES (
v_lf10_id,
'LF 10',
'LF 1',
'WN-FW 1',
'WDB9634031L123456',
2018,
'Mercedes-Benz Atego',
'LF 10',
'1/8',
'einsatzbereit',
'Feuerwehrhaus'
);
-- LF 10 inspections
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_lf10_id, 'HU', '2026-03-15', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_lf10_id, 'AU', '2026-04-01', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_lf10_id, 'UVV', '2025-11-30', '2025-11-28', 'bestanden', '2026-11-28', 'DGUV Prüfer Rems');
-- LF 10 maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_lf10_id, '2025-11-28', 'Inspektion', 'Jahresinspektion nach Herstellervorgabe, Öl- und Filterwechsel, Bremsenprüfung', 48320, NULL, 420.00),
(v_lf10_id, '2025-10-15', 'Kraftstoff', 'Betankung nach Einsatz Feuerwehr Rems', 48150, 85.4, 145.18),
(v_lf10_id, '2025-09-01', 'Reifenwechsel','Sommerreifen auf Winterreifen gewechselt, alle 4 Reifen erneuert (Continental)', 47800, NULL, 980.00);
-- 2) HLF 20/16 Hilfeleistungslöschgruppenfahrzeug (flagship)
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, standort
) VALUES (
v_hlf20_id,
'HLF 20/16',
'HLF 1',
'WN-FW 2',
'WMAN29ZZ3LM654321',
2020,
'MAN TGM / Rosenbauer',
'HLF 20/16',
'1/8',
'einsatzbereit',
'Feuerwehrhaus'
);
-- HLF 20 inspections — all current
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_hlf20_id, 'HU', '2026-08-20', NULL, 'ausstehend', NULL, 'DEKRA Esslingen'),
(v_hlf20_id, 'AU', '2026-02-01', '2026-01-28', 'bestanden', '2027-01-28', 'DEKRA Esslingen'),
(v_hlf20_id, 'UVV', '2026-01-15', '2026-01-14', 'bestanden', '2027-01-14', 'DGUV Prüfer Rems');
-- HLF 20 maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_hlf20_id, '2026-01-28', 'Hauptuntersuchung', 'AU bestanden ohne Mängel bei DEKRA Esslingen', 22450, NULL, 185.00),
(v_hlf20_id, '2026-01-14', 'Inspektion', 'UVV-Prüfung bestanden, Licht und Bremsen geprüft', 22430, NULL, 0.00),
(v_hlf20_id, '2025-12-10', 'Kraftstoff', 'Betankung nach Übung', 22300, 120.0, 204.00),
(v_hlf20_id, '2025-10-05', 'Reparatur', 'Hydraulikpumpe für Rettungssatz getauscht', 21980, NULL, 2340.00);
-- 3) MTF Mannschaftstransportfahrzeug
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, status_bemerkung, standort
) VALUES (
v_mtf_id,
'MTF',
'MTF 1',
'WN-FW 5',
'WDB9066371S789012',
2015,
'Mercedes-Benz Sprinter',
'MTF',
'1/8',
'ausser_dienst_wartung',
'Geplante Inspektion: Zahnriemenwechsel 60.000 km fällig',
'Feuerwehrhaus'
);
-- MTF inspections — HU overdue (safety-critical test data)
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_mtf_id, 'HU', '2026-01-31', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_mtf_id, 'AU', '2025-06-15', '2025-06-12', 'bestanden', '2026-06-12', 'TÜV Süd Stuttgart'),
(v_mtf_id, 'UVV', '2025-12-01', '2025-11-30', 'bestanden_mit_maengeln', '2026-11-30', 'DGUV Prüfer Rems');
-- MTF maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_mtf_id, '2025-11-30', 'Inspektion', 'UVV-Prüfung: kleiner Mangel Innenbeleuchtung, nachgebessert', 58920, NULL, 0.00),
(v_mtf_id, '2025-11-01', 'Kraftstoff', 'Betankung regulär', 58700, 65.0, 110.50),
(v_mtf_id, '2025-09-20', 'Reparatur', 'Heckleuchte links defekt, Glühbirne getauscht', 58400, NULL, 12.50);
END IF;
END
$$;
-- ---------------------------------------------------------------------------
-- Cross-migration FK: add fahrzeug_id FK to einsatz_fahrzeuge (created in 004)
-- This runs only if einsatz_fahrzeuge exists but the FK is not yet present.
-- Handles the case where 004 ran before 005 and deferred the FK creation.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'einsatz_fahrzeuge'
)
AND NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'einsatz_fahrzeuge'
AND constraint_name = 'fk_ef_fahrzeug'
) THEN
ALTER TABLE einsatz_fahrzeuge
ADD CONSTRAINT fk_ef_fahrzeug
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
RAISE NOTICE 'Added fk_ef_fahrzeug FK on einsatz_fahrzeuge';
END IF;
END $$;

View File

@@ -0,0 +1,201 @@
-- =============================================================================
-- Migration 006: Übungsplanung & Dienstkalender
-- Training Schedule & Service Calendar for Feuerwehr Rems Dashboard
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Calendar token table for iCal subscribe URLs (no auth required per-request)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS calendar_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_calendar_tokens_token ON calendar_tokens(token);
CREATE INDEX idx_calendar_tokens_user_id ON calendar_tokens(user_id);
-- -----------------------------------------------------------------------------
-- 2. Main events table
-- -----------------------------------------------------------------------------
CREATE TYPE uebung_typ AS ENUM (
'Übungsabend',
'Lehrgang',
'Sonderdienst',
'Versammlung',
'Gemeinschaftsübung',
'Sonstiges'
);
CREATE TABLE uebungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
titel VARCHAR(255) NOT NULL,
beschreibung TEXT,
typ uebung_typ NOT NULL,
datum_von TIMESTAMPTZ NOT NULL,
datum_bis TIMESTAMPTZ NOT NULL,
ort VARCHAR(255),
treffpunkt VARCHAR(255),
pflichtveranstaltung BOOLEAN NOT NULL DEFAULT FALSE,
mindest_teilnehmer INT CHECK (mindest_teilnehmer > 0),
max_teilnehmer INT CHECK (max_teilnehmer > 0),
angelegt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
absage_grund TEXT,
CONSTRAINT datum_reihenfolge CHECK (datum_bis >= datum_von),
CONSTRAINT max_groesser_min CHECK (
max_teilnehmer IS NULL OR
mindest_teilnehmer IS NULL OR
max_teilnehmer >= mindest_teilnehmer
)
);
CREATE INDEX idx_uebungen_datum_von ON uebungen(datum_von);
CREATE INDEX idx_uebungen_typ ON uebungen(typ);
CREATE INDEX idx_uebungen_pflichtveranstaltung ON uebungen(pflichtveranstaltung) WHERE pflichtveranstaltung = TRUE;
CREATE INDEX idx_uebungen_abgesagt ON uebungen(abgesagt) WHERE abgesagt = FALSE;
-- Compound index for the most common calendar-range query
CREATE INDEX idx_uebungen_datum_von_bis ON uebungen(datum_von, datum_bis);
-- Keep aktualisiert_am in sync via trigger (reuse function from migration 001)
CREATE TRIGGER update_uebungen_aktualisiert_am
BEFORE UPDATE ON uebungen
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 3. Attendance / RSVP table
-- -----------------------------------------------------------------------------
CREATE TYPE teilnahme_status AS ENUM (
'zugesagt',
'abgesagt',
'erschienen',
'entschuldigt',
'unbekannt'
);
CREATE TABLE uebung_teilnahmen (
uebung_id UUID NOT NULL REFERENCES uebungen(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status teilnahme_status NOT NULL DEFAULT 'unbekannt',
antwort_am TIMESTAMPTZ,
erschienen_erfasst_am TIMESTAMPTZ,
erschienen_erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
bemerkung VARCHAR(500),
PRIMARY KEY (uebung_id, user_id)
);
CREATE INDEX idx_teilnahmen_uebung_id ON uebung_teilnahmen(uebung_id);
CREATE INDEX idx_teilnahmen_user_id ON uebung_teilnahmen(user_id);
CREATE INDEX idx_teilnahmen_status ON uebung_teilnahmen(status);
-- -----------------------------------------------------------------------------
-- 4. Trigger: auto-create 'unbekannt' rows for all active members on new event
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_create_teilnahmen_for_all_active_members()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
SELECT NEW.id, u.id, 'unbekannt'
FROM users u
WHERE u.is_active = TRUE
ON CONFLICT (uebung_id, user_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_auto_teilnahmen_after_insert
AFTER INSERT ON uebungen
FOR EACH ROW
EXECUTE FUNCTION fn_create_teilnahmen_for_all_active_members();
-- -----------------------------------------------------------------------------
-- 5. Trigger: when a new member becomes active, add them to all future events
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_add_member_to_future_events()
RETURNS TRIGGER AS $$
BEGIN
-- Only run when is_active transitions FALSE -> TRUE
IF (OLD.is_active = FALSE OR OLD.is_active IS NULL) AND NEW.is_active = TRUE THEN
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
SELECT u.id, NEW.id, 'unbekannt'
FROM uebungen u
WHERE u.datum_von > NOW()
AND u.abgesagt = FALSE
ON CONFLICT (uebung_id, user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_add_member_to_future_events
AFTER UPDATE OF is_active ON users
FOR EACH ROW
EXECUTE FUNCTION fn_add_member_to_future_events();
-- -----------------------------------------------------------------------------
-- 6. Convenience view: event overview with attendance counts
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW uebung_uebersicht AS
SELECT
u.id,
u.titel,
u.typ,
u.datum_von,
u.datum_bis,
u.ort,
u.treffpunkt,
u.pflichtveranstaltung,
u.mindest_teilnehmer,
u.max_teilnehmer,
u.abgesagt,
u.absage_grund,
u.angelegt_von,
u.erstellt_am,
u.aktualisiert_am,
-- Attendance aggregates
COUNT(t.user_id) AS gesamt_eingeladen,
COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen,
COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt,
COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
GROUP BY u.id;
-- -----------------------------------------------------------------------------
-- 7. View: per-member participation statistics (feeds Tier 3 reporting)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW member_participation_stats AS
SELECT
usr.id AS user_id,
COALESCE(usr.name, usr.preferred_username, usr.email) AS name,
COUNT(t.uebung_id) AS total_eingeladen,
COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS total_erschienen,
COUNT(t.uebung_id) FILTER (
WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen'
) AS pflicht_erschienen,
COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt,
ROUND(
CASE
WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0
ELSE
COUNT(t.uebung_id) FILTER (
WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen'
)::NUMERIC /
COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100
END, 1
) AS uebungsabend_quote_pct
FROM users usr
JOIN uebung_teilnahmen t ON t.user_id = usr.id
JOIN uebungen u ON u.id = t.uebung_id
WHERE usr.is_active = TRUE
AND u.abgesagt = FALSE
GROUP BY usr.id, usr.name, usr.preferred_username, usr.email;

View File

@@ -0,0 +1,197 @@
-- =============================================================================
-- TEST DATA: 5 sample incidents for development and manual testing
-- Run AFTER all migrations have been applied.
-- Assumes at least one user exists in the users table.
-- Replace the user UUIDs with real values from your dev database.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Step 1: Insert two sample vehicles (if not already present)
-- Uses column names from 005_create_fahrzeuge.sql
-- ---------------------------------------------------------------------------
INSERT INTO fahrzeuge (id, bezeichnung, kurzname, amtliches_kennzeichen, baujahr, hersteller, typ_schluessel, besatzung_soll, status)
VALUES
('a1000000-0000-0000-0000-000000000001', 'HLF 20 - Hilfeleistungslöschgruppenfahrzeug', 'HLF TEST', 'WN-FW 1234', 2020, 'MAN / Rosenbauer', 'HLF 20/16', '1/8', 'einsatzbereit'),
('a1000000-0000-0000-0000-000000000002', 'TLF 3000 - Tanklöschfahrzeug', 'TLF TEST', 'WN-FW 5678', 2018, 'Mercedes-Benz Atego', 'TLF 3000', '1/5', 'einsatzbereit')
ON CONFLICT (id) DO NOTHING;
-- ---------------------------------------------------------------------------
-- Step 2: Helper — get the first active user ID (replace with real UUID in prod)
-- We use a DO block to make the script re-runnable and dev-environment agnostic.
-- ---------------------------------------------------------------------------
DO $$
DECLARE
v_user_id UUID;
v_einsatz_id1 UUID := 'b1000000-0000-0000-0000-000000000001';
v_einsatz_id2 UUID := 'b1000000-0000-0000-0000-000000000002';
v_einsatz_id3 UUID := 'b1000000-0000-0000-0000-000000000003';
v_einsatz_id4 UUID := 'b1000000-0000-0000-0000-000000000004';
v_einsatz_id5 UUID := 'b1000000-0000-0000-0000-000000000005';
v_fahrzeug1 UUID := 'a1000000-0000-0000-0000-000000000001';
v_fahrzeug2 UUID := 'a1000000-0000-0000-0000-000000000002';
BEGIN
SELECT id INTO v_user_id FROM users WHERE is_active = TRUE LIMIT 1;
IF v_user_id IS NULL THEN
RAISE NOTICE 'No active user found — skipping test data insert.';
RETURN;
END IF;
-- -------------------------------------------------------------------------
-- EINSATZ 1: Wohnungsbrand (Brand B3) — January — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz, bericht_text,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id1,
generate_einsatz_nr('2026-01-14 14:32:00+01'::TIMESTAMPTZ),
'2026-01-14 14:32:00+01',
'2026-01-14 14:35:00+01',
'2026-01-14 14:41:00+01',
'2026-01-14 17:15:00+01',
'Brand', 'B3',
'Hauptstraße', '42', 'Berglen',
'Wohnungsbrand im 2. OG, 1 Person gerettet',
'Alarm um 14:32 Uhr durch ILS. Bei Ankunft stand das zweite Obergeschoss in Flammen. ' ||
'Eine Person wurde über die Drehleiter gerettet und dem Rettungsdienst übergeben. ' ||
'Brandbekämpfung mit zwei C-Rohren. Nachlöscharbeiten bis 17:00 Uhr.',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 2: Verkehrsunfall (THL 2) — Februar — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz, bericht_text,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id2,
generate_einsatz_nr('2026-02-03 08:17:00+01'::TIMESTAMPTZ),
'2026-02-03 08:17:00+01',
'2026-02-03 08:20:00+01',
'2026-02-03 08:26:00+01',
'2026-02-03 10:45:00+01',
'THL', 'THL 2',
'Landesstraße 1115', NULL, 'Rudersberg',
'Verkehrsunfall mit eingeklemmter Person, PKW gegen Baum',
'PKW frontal gegen Baum. Fahrer eingeklemmt, Beifahrerin konnte selbstständig aussteigen. ' ||
'Technische Rettung mit Spreizer und Schere. Person nach ca. 25 Minuten befreit. ' ||
'Übergabe an den Rettungsdienst und Hubschrauber.',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 3: Brandmeldeanlage (BMA) — März — abgeschlossen (Fehlalarm)
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id3,
generate_einsatz_nr('2026-03-21 11:05:00+01'::TIMESTAMPTZ),
'2026-03-21 11:05:00+01',
'2026-03-21 11:08:00+01',
'2026-03-21 11:12:00+01',
'2026-03-21 11:35:00+01',
'BMA', NULL,
'Gewerbepark Rems', '7', 'Weinstadt',
'BMA ausgelöst durch Staubentwicklung bei Bauarbeiten, kein Feuer',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 4: Ölspur (Hilfeleistung) — März — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id4,
generate_einsatz_nr('2026-03-28 16:45:00+01'::TIMESTAMPTZ),
'2026-03-28 16:45:00+01',
'2026-03-28 16:49:00+01',
'2026-03-28 16:54:00+01',
'2026-03-28 18:20:00+01',
'Hilfeleistung', NULL,
'Ortsdurchfahrt B29', NULL, 'Schorndorf',
'Ölspur ca. 600m, LKW verlor Hydraulikflüssigkeit, Reinigung mit Ölbindemittel',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 5: Flächenbrand (Brand B2) — aktiv (noch laufend heute)
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id5,
generate_einsatz_nr(NOW()),
NOW() - INTERVAL '45 minutes',
NOW() - INTERVAL '42 minutes',
NOW() - INTERVAL '37 minutes',
'Brand', 'B2',
'Waldweg', NULL, 'Alfdorf',
'Flächenbrand ca. 0,5 ha Unterholz, Wasserversorgung über Pendelverkehr',
v_user_id, 'ILS', 'aktiv', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- VEHICLES — assign to incidents
-- -------------------------------------------------------------------------
INSERT INTO einsatz_fahrzeuge (einsatz_id, fahrzeug_id, ausrueck_time, einrueck_time)
VALUES
(v_einsatz_id1, v_fahrzeug1, '2026-01-14 14:35:00+01', '2026-01-14 17:15:00+01'),
(v_einsatz_id1, v_fahrzeug2, '2026-01-14 14:36:00+01', '2026-01-14 17:10:00+01'),
(v_einsatz_id2, v_fahrzeug1, '2026-02-03 08:20:00+01', '2026-02-03 10:45:00+01'),
(v_einsatz_id4, v_fahrzeug1, '2026-03-28 16:49:00+01', '2026-03-28 18:20:00+01'),
(v_einsatz_id5, v_fahrzeug1, NOW() - INTERVAL '42 minutes', NULL),
(v_einsatz_id5, v_fahrzeug2, NOW() - INTERVAL '40 minutes', NULL)
ON CONFLICT (einsatz_id, fahrzeug_id) DO NOTHING;
-- -------------------------------------------------------------------------
-- PERSONNEL — assign the single test user to each incident
-- -------------------------------------------------------------------------
INSERT INTO einsatz_personal (einsatz_id, user_id, funktion, alarm_time, ankunft_time)
VALUES
(v_einsatz_id1, v_user_id, 'Einsatzleiter', '2026-01-14 14:32:00+01', '2026-01-14 14:41:00+01'),
(v_einsatz_id2, v_user_id, 'Gruppenführer', '2026-02-03 08:17:00+01', '2026-02-03 08:26:00+01'),
(v_einsatz_id3, v_user_id, 'Einsatzleiter', '2026-03-21 11:05:00+01', '2026-03-21 11:12:00+01'),
(v_einsatz_id4, v_user_id, 'Maschinist', '2026-03-28 16:45:00+01', '2026-03-28 16:54:00+01'),
(v_einsatz_id5, v_user_id, 'Einsatzleiter', NOW() - INTERVAL '45 minutes', NOW() - INTERVAL '37 minutes')
ON CONFLICT (einsatz_id, user_id) DO NOTHING;
RAISE NOTICE 'Test data inserted successfully for user %', v_user_id;
END $$;
-- ---------------------------------------------------------------------------
-- Refresh the materialized view after data load
-- ---------------------------------------------------------------------------
REFRESH MATERIALIZED VIEW einsatz_statistik;
-- ---------------------------------------------------------------------------
-- Verify: quick sanity check
-- ---------------------------------------------------------------------------
SELECT
e.einsatz_nr,
e.einsatz_art,
TO_CHAR(e.alarm_time AT TIME ZONE 'Europe/Berlin', 'DD.MM.YYYY HH24:MI') AS alarm_de,
e.ort,
e.status,
COUNT(ep.user_id) AS kräfte,
COUNT(ef.fahrzeug_id) AS fahrzeuge
FROM einsaetze e
LEFT JOIN einsatz_personal ep ON ep.einsatz_id = e.id
LEFT JOIN einsatz_fahrzeuge ef ON ef.einsatz_id = e.id
GROUP BY e.id, e.einsatz_nr, e.einsatz_art, e.alarm_time, e.ort, e.status
ORDER BY e.alarm_time DESC;

View File

@@ -0,0 +1,114 @@
/**
* Audit Cleanup Job
*
* Runs the GDPR IP anonymisation task on a schedule.
* Must be started once from server.ts during application boot.
*
* Two scheduling options are shown:
* 1. node-cron (recommended) — cron expression, survives DST changes
* 2. setInterval (fallback) — simpler, no extra dependency
*
* Install node-cron if using option 1:
* npm install node-cron
* npm install --save-dev @types/node-cron
*/
import auditService from '../services/audit.service';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Option 1 (RECOMMENDED): node-cron
//
// Runs every day at 02:00 local server time.
// The cron expression format is: second(optional) minute hour dom month dow
//
// Uncomment this block and comment out the setInterval block below.
// ---------------------------------------------------------------------------
/*
import cron from 'node-cron';
let cronJob: cron.ScheduledTask | null = null;
export function startAuditCleanupJob(): void {
if (cronJob) {
logger.warn('Audit cleanup job already running — skipping duplicate start');
return;
}
// '0 2 * * *' = every day at 02:00
cronJob = cron.schedule('0 2 * * *', async () => {
logger.info('Audit cleanup job: starting IP anonymisation');
await auditService.anonymizeOldIpAddresses();
logger.info('Audit cleanup job: IP anonymisation complete');
}, {
scheduled: true,
timezone: 'Europe/Vienna', // Feuerwehr Rems is in Austria
});
logger.info('Audit cleanup job scheduled (node-cron, daily at 02:00 Europe/Vienna)');
}
export function stopAuditCleanupJob(): void {
if (cronJob) {
cronJob.stop();
cronJob = null;
logger.info('Audit cleanup job stopped');
}
}
*/
// ---------------------------------------------------------------------------
// Option 2 (FALLBACK): setInterval
//
// Runs every 24 hours from the moment the server starts.
// Simpler — no extra dependency — but does not align to wall-clock time
// and will drift if the server restarts frequently.
// ---------------------------------------------------------------------------
const INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
export function startAuditCleanupJob(): void {
if (cleanupInterval !== null) {
logger.warn('Audit cleanup job already running — skipping duplicate start');
return;
}
// Run once on startup to catch any backlog, then repeat on schedule.
// We intentionally do not await — startup must not be blocked.
runAuditCleanup();
cleanupInterval = setInterval(() => {
runAuditCleanup();
}, INTERVAL_MS);
logger.info('Audit cleanup job scheduled (setInterval, 24h interval)');
}
export function stopAuditCleanupJob(): void {
if (cleanupInterval !== null) {
clearInterval(cleanupInterval);
cleanupInterval = null;
logger.info('Audit cleanup job stopped');
}
}
// ---------------------------------------------------------------------------
// Core cleanup function — called by both scheduler options
// ---------------------------------------------------------------------------
export async function runAuditCleanup(): Promise<void> {
try {
logger.debug('Audit cleanup: running IP anonymisation');
await auditService.anonymizeOldIpAddresses();
} catch (error) {
// anonymizeOldIpAddresses() swallows its own errors, but we add a second
// layer of protection here so that a scheduler implementation mistake
// cannot crash the process.
logger.error('Audit cleanup job: unexpected error', {
error: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -0,0 +1,235 @@
/**
* Audit Middleware
*
* Factory function that returns an Express middleware for a given
* (resourceType, action) pair. Designed to sit after `authenticate`
* in the middleware chain.
*
* Usage examples:
*
* // Simple: no old_value needed
* router.post('/', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.CREATE), createMember);
*
* // UPDATE: controller must populate res.locals.auditOldValue before responding
* router.put('/:id', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.UPDATE), updateMember);
*
* // DELETE: controller must populate res.locals.auditOldValue (the entity before deletion)
* router.delete('/:id', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.DELETE), deleteMember);
*
* Pattern for capturing old_value in controllers:
*
* async function updateMember(req: Request, res: Response): Promise<void> {
* const existing = await memberService.findById(req.params.id);
* res.locals.auditOldValue = existing; // <-- set BEFORE modifying
*
* const updated = await memberService.update(req.params.id, req.body);
* res.locals.auditResourceId = req.params.id;
*
* res.status(200).json({ success: true, data: updated });
* // The middleware intercepts res.json() and logs AFTER the response is sent.
* }
*
* For PERMISSION_DENIED events, use auditPermissionDenied() directly in the
* requirePermission middleware — see auth.middleware.ts.
*/
import { Request, Response, NextFunction } from 'express';
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// IP extraction helper
// ---------------------------------------------------------------------------
/**
* Extracts the real client IP address, respecting X-Forwarded-For when set
* by a trusted reverse proxy (Nginx / Traefik in the Docker stack).
*
* Trust only the leftmost IP in X-Forwarded-For to avoid spoofing.
* If you run without a reverse proxy, req.ip is sufficient.
*/
export function extractIp(req: Request): string | null {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
const ips = (typeof forwarded === 'string' ? forwarded : forwarded[0]).split(',');
const ip = ips[0].trim();
return ip || req.ip || null;
}
return req.ip || null;
}
/**
* Extracts User-Agent, truncated to 500 chars to avoid bloating the DB.
*/
export function extractUserAgent(req: Request): string | null {
const ua = req.headers['user-agent'];
if (!ua) return null;
return ua.substring(0, 500);
}
// ---------------------------------------------------------------------------
// res.locals contract (populated by controllers before res.json())
// ---------------------------------------------------------------------------
/**
* Extend Express locals so TypeScript knows what the audit middleware reads.
*
* Controllers set these before calling res.json() / res.status().json():
* res.locals.auditOldValue — entity state before the operation (UPDATE, DELETE)
* res.locals.auditNewValue — override for new value (optional; defaults to response body)
* res.locals.auditResourceId — override for resource ID (optional; defaults to req.params.id)
* res.locals.auditMetadata — any extra context object
* res.locals.auditSkip — set to true to suppress logging for this request
*/
declare global {
namespace Express {
interface Locals {
auditOldValue?: Record<string, unknown> | null;
auditNewValue?: Record<string, unknown> | null;
auditResourceId?: string;
auditMetadata?: Record<string, unknown>;
auditSkip?: boolean;
}
}
}
// ---------------------------------------------------------------------------
// Middleware factory
// ---------------------------------------------------------------------------
/**
* auditMiddleware — returns Express middleware that fires an audit log entry
* after the response has been sent (fire-and-forget; never delays the response).
*
* @param resourceType The type of entity being operated on.
* @param action The action being performed.
*/
export function auditMiddleware(
resourceType: AuditResourceType,
action: AuditAction
) {
return function auditHandler(
req: Request,
res: Response,
next: NextFunction
): void {
// Intercept res.json() so we can capture the response body after it is
// set, without delaying the response to the client.
const originalJson = res.json.bind(res);
res.json = function auditInterceptedJson(body: unknown): Response {
// Call the original json() — this sends the response immediately.
const result = originalJson(body);
// Only log on successful responses (2xx). Failed requests are not
// meaningful audit events for data mutations (the data did not change).
if (res.statusCode >= 200 && res.statusCode < 300 && !res.locals.auditSkip) {
const userId = req.user?.id ?? null;
const userEmail = req.user?.email ?? null;
const ipAddress = extractIp(req);
const userAgent = extractUserAgent(req);
// Resource ID: prefer controller override, then route param, then body id.
const resourceId =
res.locals.auditResourceId ??
req.params.id ??
(body !== null && typeof body === 'object'
? (body as Record<string, unknown>)?.data?.id as string | undefined
: undefined) ??
null;
// old_value: always from res.locals (controller must set it for UPDATE/DELETE).
const oldValue = res.locals.auditOldValue ?? null;
// new_value: prefer controller override, else use the response body data.
// We extract the `data` property if it exists (matches our { success, data } envelope).
let newValue: Record<string, unknown> | null = res.locals.auditNewValue ?? null;
if (newValue === null && action !== AuditAction.DELETE) {
const bodyObj = body as Record<string, unknown> | null;
newValue =
(bodyObj?.data as Record<string, unknown> | undefined) ??
(typeof bodyObj === 'object' && bodyObj !== null ? bodyObj : null);
}
// Fire-and-forget — never await in a middleware.
auditService.logAudit({
user_id: userId,
user_email: userEmail,
action,
resource_type: resourceType,
resource_id: resourceId ? String(resourceId) : null,
old_value: oldValue,
new_value: newValue,
ip_address: ipAddress,
user_agent: userAgent,
metadata: res.locals.auditMetadata ?? {},
}).catch((err) => {
// Belt-and-suspenders: logAudit() never rejects, but just in case.
logger.error('auditMiddleware: unexpected logAudit rejection', { err });
});
}
return result;
};
next();
};
}
// ---------------------------------------------------------------------------
// Standalone helpers for use outside of route middleware chains
// ---------------------------------------------------------------------------
/**
* auditPermissionDenied
*
* Call this inside requirePermission() when access is denied.
* Does NOT depend on res.json interception — fires directly.
*/
export function auditPermissionDenied(
req: Request,
resourceType: AuditResourceType,
resourceId?: string,
metadata?: Record<string, unknown>
): void {
auditService.logAudit({
user_id: req.user?.id ?? null,
user_email: req.user?.email ?? null,
action: AuditAction.PERMISSION_DENIED,
resource_type: resourceType,
resource_id: resourceId ?? null,
old_value: null,
new_value: null,
ip_address: extractIp(req),
user_agent: extractUserAgent(req),
metadata: {
attempted_path: req.path,
attempted_method: req.method,
...metadata,
},
}).catch(() => {/* swallowed — audit must not affect the 403 response */});
}
/**
* auditExport
*
* Call this immediately before streaming an export response.
*/
export function auditExport(
req: Request,
resourceType: AuditResourceType,
metadata: Record<string, unknown> = {}
): void {
auditService.logAudit({
user_id: req.user?.id ?? null,
user_email: req.user?.email ?? null,
action: AuditAction.EXPORT,
resource_type: resourceType,
resource_id: null,
old_value: null,
new_value: null,
ip_address: extractIp(req),
user_agent: extractUserAgent(req),
metadata,
}).catch(() => {});
}

View File

@@ -0,0 +1,136 @@
import { Request, Response, NextFunction } from 'express';
import pool from '../config/database';
import logger from '../utils/logger';
import { auditPermissionDenied } from './audit.middleware';
import { AuditResourceType } from '../services/audit.service';
// ---------------------------------------------------------------------------
// AppRole — mirrors the roles defined in the project spec.
// Tier 1 (RBAC) is assumed complete and adds a `role` column to users.
// This middleware reads that column to enforce permissions.
// ---------------------------------------------------------------------------
export type AppRole =
| 'admin'
| 'kommandant'
| 'gruppenfuehrer'
| 'mitglied'
| 'bewerber';
/**
* Role hierarchy: higher index = more permissions.
* Used to implement "at least X role" checks.
*/
const ROLE_HIERARCHY: AppRole[] = [
'bewerber',
'mitglied',
'gruppenfuehrer',
'kommandant',
'admin',
];
/**
* Permission map: defines which roles hold a given permission string.
* All roles at or above the listed minimum also hold the permission.
*/
const PERMISSION_ROLE_MIN: Record<string, AppRole> = {
'incidents:read': 'mitglied',
'incidents:write': 'gruppenfuehrer',
'incidents:delete': 'kommandant',
'incidents:read_bericht_text': 'kommandant',
'incidents:manage_personnel': 'gruppenfuehrer',
// Training / Calendar
'training:read': 'mitglied',
'training:write': 'gruppenfuehrer',
'training:cancel': 'kommandant',
'training:mark_attendance': 'gruppenfuehrer',
'reports:read': 'kommandant',
// Audit log and admin panel — restricted to admin role only
'admin:access': 'admin',
'audit:read': 'admin',
'audit:export': 'admin',
};
function hasPermission(role: AppRole, permission: string): boolean {
const minRole = PERMISSION_ROLE_MIN[permission];
if (!minRole) {
logger.warn('Unknown permission checked', { permission });
return false;
}
const userLevel = ROLE_HIERARCHY.indexOf(role);
const minLevel = ROLE_HIERARCHY.indexOf(minRole);
return userLevel >= minLevel;
}
/**
* Retrieves the role for a given user ID from the database.
* Falls back to 'mitglied' if the users table does not yet have a role column
* (graceful degradation while Tier 1 migration is pending).
*/
async function getUserRole(userId: string): Promise<AppRole> {
try {
const result = await pool.query(
`SELECT role FROM users WHERE id = $1`,
[userId]
);
if (result.rows.length === 0) return 'mitglied';
return (result.rows[0].role as AppRole) ?? 'mitglied';
} catch (error) {
// If the column doesn't exist yet (Tier 1 not deployed), degrade gracefully
const errMsg = error instanceof Error ? error.message : String(error);
if (errMsg.includes('column "role" does not exist')) {
logger.warn('users.role column not found — Tier 1 RBAC migration pending. Defaulting to mitglied.');
return 'mitglied';
}
logger.error('Error fetching user role', { error, userId });
return 'mitglied';
}
}
/**
* Middleware factory: requires the authenticated user to hold the given
* permission (or a role with sufficient hierarchy level).
*
* Usage:
* router.post('/api/incidents', authenticate, requirePermission('incidents:write'), handler)
*/
export function requirePermission(permission: string) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) {
res.status(401).json({
success: false,
message: 'Authentication required',
});
return;
}
const role = await getUserRole(req.user.id);
// Attach role to request for downstream use (e.g., bericht_text redaction)
(req as Request & { userRole?: AppRole }).userRole = role;
if (!hasPermission(role, permission)) {
logger.warn('Permission denied', {
userId: req.user.id,
role,
permission,
path: req.path,
});
// GDPR audit trail — fire-and-forget, never throws
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
required_permission: permission,
user_role: role,
});
res.status(403).json({
success: false,
message: `Keine Berechtigung: ${permission}`,
});
return;
}
next();
};
}
export { getUserRole, hasPermission };

View File

@@ -0,0 +1,261 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// ENUMS (as const arrays for runtime use + TypeScript types)
// ---------------------------------------------------------------------------
export const EINSATZ_ARTEN = [
'Brand',
'THL',
'ABC',
'BMA',
'Hilfeleistung',
'Fehlalarm',
'Brandsicherheitswache',
] as const;
export type EinsatzArt = (typeof EINSATZ_ARTEN)[number];
export const EINSATZ_ART_LABELS: Record<EinsatzArt, string> = {
Brand: 'Brand',
THL: 'Technische Hilfeleistung',
ABC: 'ABC-Einsatz / Gefahrgut',
BMA: 'Brandmeldeanlage',
Hilfeleistung: 'Hilfeleistung',
Fehlalarm: 'Fehlalarm',
Brandsicherheitswache: 'Brandsicherheitswache',
};
export const EINSATZ_STATUS = ['aktiv', 'abgeschlossen', 'archiviert'] as const;
export type EinsatzStatus = (typeof EINSATZ_STATUS)[number];
export const EINSATZ_STATUS_LABELS: Record<EinsatzStatus, string> = {
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
archiviert: 'Archiviert',
};
export const EINSATZ_FUNKTIONEN = [
'Einsatzleiter',
'Gruppenführer',
'Maschinist',
'Atemschutz',
'Sicherheitstrupp',
'Melder',
'Wassertrupp',
'Angriffstrupp',
'Mannschaft',
'Sonstiges',
] as const;
export type EinsatzFunktion = (typeof EINSATZ_FUNKTIONEN)[number];
export const ALARMIERUNG_ARTEN = [
'ILS',
'DME',
'Telefon',
'Vor_Ort',
'Sonstiges',
] as const;
export type AlarmierungArt = (typeof ALARMIERUNG_ARTEN)[number];
export const ALARMIERUNG_ART_LABELS: Record<AlarmierungArt, string> = {
ILS: 'ILS (Integrierte Leitstelle)',
DME: 'Digitaler Meldeempfänger',
Telefon: 'Telefon',
Vor_Ort: 'Vor Ort',
Sonstiges: 'Sonstiges',
};
// ---------------------------------------------------------------------------
// DATABASE ROW INTERFACES
// ---------------------------------------------------------------------------
/** Raw database row for einsaetze table */
export interface Einsatz {
id: string;
einsatz_nr: string;
alarm_time: Date;
ausrueck_time: Date | null;
ankunft_time: Date | null;
einrueck_time: Date | null;
einsatz_art: EinsatzArt;
einsatz_stichwort: string | null;
strasse: string | null;
hausnummer: string | null;
ort: string | null;
koordinaten: { x: number; y: number } | null; // pg returns POINT as {x,y}
bericht_kurz: string | null;
bericht_text: string | null;
einsatzleiter_id: string | null;
alarmierung_art: AlarmierungArt;
status: EinsatzStatus;
created_by: string | null;
created_at: Date;
updated_at: Date;
}
/** Assigned vehicle row */
export interface EinsatzFahrzeug {
einsatz_id: string;
fahrzeug_id: string;
ausrueck_time: Date | null;
einrueck_time: Date | null;
assigned_at: Date;
// Joined fields — aliased to 'kennzeichen' and 'fahrzeug_typ' in SQL query
// to match the aliased SELECT in incident.service.ts
kennzeichen?: string | null; // aliased from amtliches_kennzeichen
bezeichnung?: string;
fahrzeug_typ?: string | null; // aliased from typ_schluessel
}
/** Assigned personnel row */
export interface EinsatzPersonal {
einsatz_id: string;
user_id: string;
funktion: EinsatzFunktion;
alarm_time: Date | null;
ankunft_time: Date | null;
assigned_at: Date;
// Joined fields
name?: string | null;
email?: string;
given_name?: string | null;
family_name?: string | null;
}
// ---------------------------------------------------------------------------
// EXTENDED TYPES (joins / computed)
// ---------------------------------------------------------------------------
/** Full incident with all related data — used by detail endpoint */
export interface EinsatzWithDetails extends Einsatz {
einsatzleiter_name: string | null;
fahrzeuge: EinsatzFahrzeug[];
personal: EinsatzPersonal[];
/** Hilfsfrist in minutes (alarm → arrival), null if ankunft_time not set */
hilfsfrist_min: number | null;
/** Total duration in minutes (alarm → return), null if einrueck_time not set */
dauer_min: number | null;
}
/** Lightweight row for the list view DataGrid */
export interface EinsatzListItem {
id: string;
einsatz_nr: string;
alarm_time: Date;
einsatz_art: EinsatzArt;
einsatz_stichwort: string | null;
ort: string | null;
strasse: string | null;
status: EinsatzStatus;
einsatzleiter_name: string | null;
/** Hilfsfrist in minutes, null if ankunft_time not set */
hilfsfrist_min: number | null;
/** Total duration in minutes, null if einrueck_time not set */
dauer_min: number | null;
personal_count: number;
}
// ---------------------------------------------------------------------------
// STATISTICS TYPES
// ---------------------------------------------------------------------------
export interface MonthlyStatRow {
monat: number; // 112
anzahl: number;
avg_hilfsfrist_min: number | null;
avg_dauer_min: number | null;
}
export interface EinsatzArtStatRow {
einsatz_art: EinsatzArt;
anzahl: number;
avg_hilfsfrist_min: number | null;
}
export interface EinsatzStats {
jahr: number;
gesamt: number;
abgeschlossen: number;
aktiv: number;
avg_hilfsfrist_min: number | null;
/** Einsatzart with the highest count */
haeufigste_art: EinsatzArt | null;
monthly: MonthlyStatRow[];
by_art: EinsatzArtStatRow[];
/** Previous year monthly for chart overlay */
prev_year_monthly: MonthlyStatRow[];
}
// ---------------------------------------------------------------------------
// ZOD VALIDATION SCHEMAS (Zod v4)
// ---------------------------------------------------------------------------
export const CreateEinsatzSchema = z.object({
alarm_time: z.string().datetime({ offset: true }),
ausrueck_time: z.string().datetime({ offset: true }).optional().nullable(),
ankunft_time: z.string().datetime({ offset: true }).optional().nullable(),
einrueck_time: z.string().datetime({ offset: true }).optional().nullable(),
einsatz_art: z.enum(EINSATZ_ARTEN),
einsatz_stichwort: z.string().max(30).optional().nullable(),
strasse: z.string().max(150).optional().nullable(),
hausnummer: z.string().max(20).optional().nullable(),
ort: z.string().max(100).optional().nullable(),
bericht_kurz: z.string().max(255).optional().nullable(),
bericht_text: z.string().optional().nullable(),
einsatzleiter_id: z.string().uuid().optional().nullable(),
alarmierung_art: z.enum(ALARMIERUNG_ARTEN).optional().default('ILS'),
status: z.enum(EINSATZ_STATUS).optional().default('aktiv'),
});
export type CreateEinsatzData = z.infer<typeof CreateEinsatzSchema>;
export const UpdateEinsatzSchema = CreateEinsatzSchema.partial().omit({
alarm_time: true, // alarm_time can be updated but is handled explicitly
}).extend({
// alarm_time is allowed to be updated but must remain valid if provided
alarm_time: z.string().datetime({ offset: true }).optional(),
});
export type UpdateEinsatzData = z.infer<typeof UpdateEinsatzSchema>;
export const AssignPersonnelSchema = z.object({
user_id: z.string().uuid(),
funktion: z.enum(EINSATZ_FUNKTIONEN).optional().default('Mannschaft'),
alarm_time: z.string().datetime({ offset: true }).optional().nullable(),
ankunft_time: z.string().datetime({ offset: true }).optional().nullable(),
});
export type AssignPersonnelData = z.infer<typeof AssignPersonnelSchema>;
export const AssignVehicleSchema = z.object({
fahrzeug_id: z.string().uuid(),
ausrueck_time: z.string().datetime({ offset: true }).optional().nullable(),
einrueck_time: z.string().datetime({ offset: true }).optional().nullable(),
});
export type AssignVehicleData = z.infer<typeof AssignVehicleSchema>;
export const IncidentFiltersSchema = z.object({
dateFrom: z.string().datetime({ offset: true }).optional(),
dateTo: z.string().datetime({ offset: true }).optional(),
einsatzArt: z.enum(EINSATZ_ARTEN).optional(),
status: z.enum(EINSATZ_STATUS).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
});
export type IncidentFilters = z.infer<typeof IncidentFiltersSchema>;

View File

@@ -0,0 +1,230 @@
import { z } from 'zod';
// ============================================================
// Domain enumerations — used both as Zod schemas and runtime
// arrays (for building <Select> options in the frontend).
// ============================================================
export const DIENSTGRAD_VALUES = [
'Feuerwehranwärter',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann',
] as const;
export const STATUS_VALUES = [
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten',
] as const;
export const FUNKTION_VALUES = [
'Kommandant',
'Stellv. Kommandant',
'Gruppenführer',
'Truppführer',
'Gerätewart',
'Kassier',
'Schriftführer',
'Atemschutzwart',
'Ausbildungsbeauftragter',
] as const;
export const TSHIRT_GROESSE_VALUES = [
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL',
] as const;
export type DienstgradEnum = typeof DIENSTGRAD_VALUES[number];
export type StatusEnum = typeof STATUS_VALUES[number];
export type FunktionEnum = typeof FUNKTION_VALUES[number];
export type TshirtGroesseEnum = typeof TSHIRT_GROESSE_VALUES[number];
// ============================================================
// Core DB row interface — mirrors the mitglieder_profile table
// exactly (snake_case, nullable columns use `| null`)
// ============================================================
export interface MitgliederProfile {
id: string;
user_id: string;
mitglieds_nr: string | null;
dienstgrad: DienstgradEnum | null;
dienstgrad_seit: Date | null;
funktion: FunktionEnum[];
status: StatusEnum;
eintrittsdatum: Date | null;
austrittsdatum: Date | null;
geburtsdatum: Date | null; // sensitive field
telefon_mobil: string | null;
telefon_privat: string | null;
notfallkontakt_name: string | null; // sensitive field
notfallkontakt_telefon: string | null; // sensitive field
fuehrerscheinklassen: string[];
tshirt_groesse: TshirtGroesseEnum | null;
schuhgroesse: string | null;
bemerkungen: string | null;
bild_url: string | null;
created_at: Date;
updated_at: Date;
}
// ============================================================
// Rank-change history row
// ============================================================
export interface DienstgradVerlaufEntry {
id: string;
user_id: string;
dienstgrad_neu: string;
dienstgrad_alt: string | null;
datum: Date;
durch_user_id: string | null;
bemerkung: string | null;
created_at: Date;
}
// ============================================================
// Joined view: users row + mitglieder_profile (nullable)
// This is what the service returns for GET /members/:userId
// ============================================================
export interface MemberWithProfile {
// --- from users ---
id: string;
email: string;
name: string | null;
given_name: string | null;
family_name: string | null;
preferred_username: string | null;
profile_picture_url: string | null;
is_active: boolean;
last_login_at: Date | null;
created_at: Date;
// --- from mitglieder_profile (null when no profile exists) ---
profile: MitgliederProfile | null;
// --- rank history (populated on detail requests) ---
dienstgrad_verlauf?: DienstgradVerlaufEntry[];
}
// ============================================================
// List-view projection — only the fields needed for the table
// ============================================================
export interface MemberListItem {
// user fields
id: string;
name: string | null;
given_name: string | null;
family_name: string | null;
email: string;
profile_picture_url: string | null;
is_active: boolean;
// profile fields (null when no profile exists)
profile_id: string | null;
mitglieds_nr: string | null;
dienstgrad: DienstgradEnum | null;
funktion: FunktionEnum[];
status: StatusEnum | null;
eintrittsdatum: Date | null;
telefon_mobil: string | null;
}
// ============================================================
// Filter parameters for getAllMembers()
// ============================================================
export interface MemberFilters {
search?: string; // matches name, email, mitglieds_nr
status?: StatusEnum[];
dienstgrad?: DienstgradEnum[];
page?: number; // 1-based
pageSize?: number;
}
// ============================================================
// Zod validation schemas
// ============================================================
/**
* Schema for POST /members/:userId/profile
* All fields are optional at creation except user_id (in URL).
* status has a default; every other field may be omitted.
*/
export const CreateMemberProfileSchema = z.object({
mitglieds_nr: z.string().max(32).optional(),
dienstgrad: z.enum(DIENSTGRAD_VALUES).optional(),
dienstgrad_seit: z.coerce.date().optional(),
funktion: z.array(z.enum(FUNKTION_VALUES)).default([]),
status: z.enum(STATUS_VALUES).default('aktiv'),
eintrittsdatum: z.coerce.date().optional(),
austrittsdatum: z.coerce.date().optional(),
geburtsdatum: z.coerce.date().optional(),
telefon_mobil: z.string().max(32).optional(),
telefon_privat: z.string().max(32).optional(),
notfallkontakt_name: z.string().max(255).optional(),
notfallkontakt_telefon: z.string().max(32).optional(),
fuehrerscheinklassen: z.array(z.string().max(8)).default([]),
tshirt_groesse: z.enum(TSHIRT_GROESSE_VALUES).optional(),
schuhgroesse: z.string().max(8).optional(),
bemerkungen: z.string().optional(),
bild_url: z.string().url().optional(),
});
export type CreateMemberProfileData = z.infer<typeof CreateMemberProfileSchema>;
/**
* Schema for PATCH /members/:userId
* Every field is optional — only provided fields are written.
*/
export const UpdateMemberProfileSchema = CreateMemberProfileSchema.partial();
export type UpdateMemberProfileData = z.infer<typeof UpdateMemberProfileSchema>;
/**
* Schema for the "self-edit" subset available to the Mitglied role.
* Only non-sensitive, personally-owned fields.
*/
export const SelfUpdateMemberProfileSchema = z.object({
telefon_mobil: z.string().max(32).optional(),
telefon_privat: z.string().max(32).optional(),
notfallkontakt_name: z.string().max(255).optional(),
notfallkontakt_telefon: z.string().max(32).optional(),
tshirt_groesse: z.enum(TSHIRT_GROESSE_VALUES).optional(),
schuhgroesse: z.string().max(8).optional(),
bild_url: z.string().url().optional(),
});
export type SelfUpdateMemberProfileData = z.infer<typeof SelfUpdateMemberProfileSchema>;
// ============================================================
// Stats response shape (for dashboard KPI endpoint)
// ============================================================
export interface MemberStats {
total: number;
aktiv: number;
passiv: number;
ehrenmitglied: number;
jugendfeuerwehr: number;
anwärter: number;
ausgetreten: number;
}

View File

@@ -0,0 +1,197 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
export const UEBUNG_TYPEN = [
'Übungsabend',
'Lehrgang',
'Sonderdienst',
'Versammlung',
'Gemeinschaftsübung',
'Sonstiges',
] as const;
export type UebungTyp = (typeof UEBUNG_TYPEN)[number];
export const TEILNAHME_STATUSES = [
'zugesagt',
'abgesagt',
'erschienen',
'entschuldigt',
'unbekannt',
] as const;
export type TeilnahmeStatus = (typeof TEILNAHME_STATUSES)[number];
// ---------------------------------------------------------------------------
// Core DB-mapped interfaces
// ---------------------------------------------------------------------------
export interface Uebung {
id: string;
titel: string;
beschreibung?: string | null;
typ: UebungTyp;
datum_von: Date;
datum_bis: Date;
ort?: string | null;
treffpunkt?: string | null;
pflichtveranstaltung: boolean;
mindest_teilnehmer?: number | null;
max_teilnehmer?: number | null;
angelegt_von?: string | null;
erstellt_am: Date;
aktualisiert_am: Date;
abgesagt: boolean;
absage_grund?: string | null;
}
export interface Teilnahme {
uebung_id: string;
user_id: string;
status: TeilnahmeStatus;
antwort_am?: Date | null;
erschienen_erfasst_am?: Date | null;
erschienen_erfasst_von?: string | null;
bemerkung?: string | null;
// Joined from users table
user_name?: string | null;
user_email?: string | null;
}
// ---------------------------------------------------------------------------
// Enriched / view-based interfaces
// ---------------------------------------------------------------------------
export interface AttendanceCounts {
gesamt_eingeladen: number;
anzahl_zugesagt: number;
anzahl_abgesagt: number;
anzahl_erschienen: number;
anzahl_entschuldigt: number;
anzahl_unbekannt: number;
}
/** Full event object including all attendance data — used in detail page */
export interface UebungWithAttendance extends Uebung, AttendanceCounts {
/** Only populated for users with training:write or own role >= Gruppenführer */
teilnahmen?: Teilnahme[];
/** The requesting user's own RSVP status, always included */
eigener_status?: TeilnahmeStatus;
/** Name of the person who created the event */
angelegt_von_name?: string | null;
}
/** Lightweight list item — used in calendar, upcoming list widget */
export interface UebungListItem {
id: string;
titel: string;
typ: UebungTyp;
datum_von: Date;
datum_bis: Date;
ort?: string | null;
pflichtveranstaltung: boolean;
abgesagt: boolean;
anzahl_zugesagt: number;
anzahl_erschienen: number;
gesamt_eingeladen: number;
/** Requesting user's own status — undefined when called without userId */
eigener_status?: TeilnahmeStatus;
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
/** Monthly training summary for dashboard stats card */
export interface TrainingStats {
/** e.g. "2026-02" */
month: string;
/** Total events that month */
total: number;
/** Pflichtveranstaltungen that month */
pflicht: number;
/** % of events (Übungsabende only) a given user attended — 0-100 */
attendanceRate: number;
}
/** Per-member annual participation stats for Jahresbericht (Tier 3) */
export interface MemberParticipationStats {
userId: string;
name: string;
totalUebungen: number;
attended: number;
attendancePercent: number;
pflichtGesamt: number;
pflichtErschienen: number;
uebungsabendQuotePct: number;
}
// ---------------------------------------------------------------------------
// Zod validation schemas — used in service layer and route middleware
// ---------------------------------------------------------------------------
export const CreateUebungSchema = z.object({
titel: z
.string()
.min(3, 'Titel muss mindestens 3 Zeichen haben')
.max(255),
beschreibung: z.string().max(5000).optional().nullable(),
typ: z.enum(UEBUNG_TYPEN),
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)),
ort: z.string().max(255).optional().nullable(),
treffpunkt: z.string().max(255).optional().nullable(),
pflichtveranstaltung: z.boolean().default(false),
mindest_teilnehmer: z.number().int().positive().optional().nullable(),
max_teilnehmer: z.number().int().positive().optional().nullable(),
}).refine(
(d) => d.datum_bis >= d.datum_von,
{ message: 'datum_bis muss nach datum_von liegen', path: ['datum_bis'] }
).refine(
(d) =>
d.max_teilnehmer == null ||
d.mindest_teilnehmer == null ||
d.max_teilnehmer >= d.mindest_teilnehmer,
{ message: 'max_teilnehmer muss >= mindest_teilnehmer sein', path: ['max_teilnehmer'] }
);
export type CreateUebungData = z.infer<typeof CreateUebungSchema>;
export const UpdateUebungSchema = CreateUebungSchema.partial().extend({
// All fields optional on update, but retain type narrowing
});
export type UpdateUebungData = z.infer<typeof UpdateUebungSchema>;
export const UpdateRsvpSchema = z.object({
status: z.enum(['zugesagt', 'abgesagt']),
bemerkung: z.string().max(500).optional().nullable(),
});
export type UpdateRsvpData = z.infer<typeof UpdateRsvpSchema>;
export const MarkAttendanceSchema = z.object({
userIds: z
.array(z.string().uuid())
.min(1, 'Mindestens eine Person muss ausgewählt werden'),
});
export type MarkAttendanceData = z.infer<typeof MarkAttendanceSchema>;
export const CancelEventSchema = z.object({
absage_grund: z
.string()
.min(5, 'Bitte gib einen Grund für die Absage an (min. 5 Zeichen)')
.max(1000),
});
export type CancelEventData = z.infer<typeof CancelEventSchema>;

View File

@@ -0,0 +1,269 @@
// =============================================================================
// Vehicle Fleet Management — Domain Model
// =============================================================================
// ── Enums ─────────────────────────────────────────────────────────────────────
/**
* Operational status of a vehicle.
* These values are the CHECK constraint values in the database.
*/
export enum FahrzeugStatus {
Einsatzbereit = 'einsatzbereit',
AusserDienstWartung = 'ausser_dienst_wartung',
AusserDienstSchaden = 'ausser_dienst_schaden',
InLehrgang = 'in_lehrgang',
}
/** Human-readable German labels for each status value */
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
[FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)',
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
};
/**
* Types of vehicle inspections (Prüfungsarten).
* These values are the CHECK constraint values in the database.
*/
export enum PruefungArt {
HU = 'HU', // Hauptuntersuchung (TÜV) — 24-month interval
AU = 'AU', // Abgasuntersuchung — 12-month interval
UVV = 'UVV', // Unfallverhütungsvorschrift BGV D29 — 12-month
Leiter = 'Leiter', // Leiternprüfung (DLK only) — 12-month
Kran = 'Kran', // Kranprüfung — 12-month
Seilwinde = 'Seilwinde', // Seilwindenprüfung — 12-month
Sonstiges = 'Sonstiges',
}
/** Human-readable German labels for each PruefungArt */
export const PruefungArtLabel: Record<PruefungArt, string> = {
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
[PruefungArt.AU]: 'Abgasuntersuchung',
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
[PruefungArt.Kran]: 'Kranprüfung',
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
};
/**
* Standard inspection intervals in months, keyed by PruefungArt.
* Used by vehicle.service.ts to auto-calculate naechste_faelligkeit.
*/
export const PruefungIntervalMonths: Partial<Record<PruefungArt, number>> = {
[PruefungArt.HU]: 24,
[PruefungArt.AU]: 12,
[PruefungArt.UVV]: 12,
[PruefungArt.Leiter]: 12,
[PruefungArt.Kran]: 12,
[PruefungArt.Seilwinde]: 12,
// Sonstiges: no standard interval — must be set manually
};
/** Inspection result values */
export type PruefungErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden'
| 'ausstehend';
/** Maintenance log entry types */
export type WartungslogArt =
| 'Inspektion'
| 'Reparatur'
| 'Kraftstoff'
| 'Reifenwechsel'
| 'Hauptuntersuchung'
| 'Reinigung'
| 'Sonstiges';
// ── Core Entities ─────────────────────────────────────────────────────────────
/** Raw database row from the `fahrzeuge` table */
export interface Fahrzeug {
id: string; // UUID
bezeichnung: string; // e.g. "LF 20/16"
kurzname: string | null;
amtliches_kennzeichen: string | null;
fahrgestellnummer: string | null;
baujahr: number | null;
hersteller: string | null;
typ_schluessel: string | null;
besatzung_soll: string | null; // e.g. "1/8"
status: FahrzeugStatus;
status_bemerkung: string | null;
standort: string;
bild_url: string | null;
created_at: Date;
updated_at: Date;
}
/** Raw database row from `fahrzeug_pruefungen` */
export interface FahrzeugPruefung {
id: string; // UUID
fahrzeug_id: string; // UUID FK
pruefung_art: PruefungArt;
faellig_am: Date; // The hard legal deadline
durchgefuehrt_am: Date | null;
ergebnis: PruefungErgebnis | null;
naechste_faelligkeit: Date | null;
pruefende_stelle: string | null;
kosten: number | null;
dokument_url: string | null;
bemerkung: string | null;
erfasst_von: string | null; // UUID FK users
created_at: Date;
}
/** Raw database row from `fahrzeug_wartungslog` */
export interface FahrzeugWartungslog {
id: string; // UUID
fahrzeug_id: string; // UUID FK
datum: Date;
art: WartungslogArt | null;
beschreibung: string;
km_stand: number | null;
kraftstoff_liter: number | null;
kosten: number | null;
externe_werkstatt: string | null;
erfasst_von: string | null; // UUID FK users
created_at: Date;
}
// ── Inspection Status per Type ────────────────────────────────────────────────
/** Status of a single inspection type for a vehicle */
export interface PruefungStatus {
pruefung_id: string | null;
faellig_am: Date | null;
tage_bis_faelligkeit: number | null; // negative = overdue
ergebnis: PruefungErgebnis | null;
}
/**
* Vehicle with its per-type inspection status.
* Comes from the `fahrzeuge_mit_pruefstatus` view.
*/
export interface FahrzeugWithPruefstatus extends Fahrzeug {
pruefstatus: {
hu: PruefungStatus;
au: PruefungStatus;
uvv: PruefungStatus;
leiter: PruefungStatus;
};
/** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */
naechste_pruefung_tage: number | null;
/** Full inspection history, ordered by faellig_am DESC */
pruefungen: FahrzeugPruefung[];
/** Maintenance log entries, ordered by datum DESC */
wartungslog: FahrzeugWartungslog[];
}
// ── List Item (Grid / Card view) ──────────────────────────────────────────────
/**
* Lightweight type used in the vehicle fleet overview grid.
* Includes only the fields needed to render a card plus inspection badges.
*/
export interface FahrzeugListItem {
id: string;
bezeichnung: string;
kurzname: string | null;
amtliches_kennzeichen: string | null;
baujahr: number | null;
hersteller: string | null;
besatzung_soll: string | null;
status: FahrzeugStatus;
status_bemerkung: string | null;
bild_url: string | null;
hu_faellig_am: Date | null;
hu_tage_bis_faelligkeit: number | null;
au_faellig_am: Date | null;
au_tage_bis_faelligkeit: number | null;
uvv_faellig_am: Date | null;
uvv_tage_bis_faelligkeit: number | null;
leiter_faellig_am: Date | null;
leiter_tage_bis_faelligkeit: number | null;
naechste_pruefung_tage: number | null;
}
// ── Dashboard KPI ─────────────────────────────────────────────────────────────
/** Aggregated vehicle stats for the dashboard KPI strip */
export interface VehicleStats {
total: number;
einsatzbereit: number;
ausserDienst: number; // wartung + schaden combined
inLehrgang: number;
inspectionsDue: number; // vehicles with any inspection due within 30 days
inspectionsOverdue: number; // vehicles with any inspection already overdue
}
// ── Inspection Alert ──────────────────────────────────────────────────────────
/** Single alert item for the dashboard InspectionAlerts component */
export interface InspectionAlert {
fahrzeugId: string;
bezeichnung: string;
kurzname: string | null;
pruefungId: string;
pruefungArt: PruefungArt;
faelligAm: Date;
tage: number; // negative = already overdue
}
// ── Create / Update DTOs ──────────────────────────────────────────────────────
export interface CreateFahrzeugData {
bezeichnung: string;
kurzname?: string;
amtliches_kennzeichen?: string;
fahrgestellnummer?: string;
baujahr?: number;
hersteller?: string;
typ_schluessel?: string;
besatzung_soll?: string;
status?: FahrzeugStatus;
status_bemerkung?: string;
standort?: string;
bild_url?: string;
}
export interface UpdateFahrzeugData {
bezeichnung?: string;
kurzname?: string | null;
amtliches_kennzeichen?: string | null;
fahrgestellnummer?: string | null;
baujahr?: number | null;
hersteller?: string | null;
typ_schluessel?: string | null;
besatzung_soll?: string | null;
status?: FahrzeugStatus;
status_bemerkung?: string | null;
standort?: string;
bild_url?: string | null;
}
export interface CreatePruefungData {
pruefung_art: PruefungArt;
faellig_am: string; // ISO date string 'YYYY-MM-DD'
durchgefuehrt_am?: string; // ISO date string, optional
ergebnis?: PruefungErgebnis;
pruefende_stelle?: string;
kosten?: number;
dokument_url?: string;
bemerkung?: string;
// naechste_faelligkeit is auto-calculated by the service — not accepted from client
}
export interface CreateWartungslogData {
datum: string; // ISO date string 'YYYY-MM-DD'
art?: WartungslogArt;
beschreibung: string;
km_stand?: number;
kraftstoff_liter?: number;
kosten?: number;
externe_werkstatt?: string;
}

View File

@@ -0,0 +1,169 @@
/**
* Admin API Routes — Audit Log
*
* GET /api/admin/audit-log — paginated, filtered list
* GET /api/admin/audit-log/export — CSV download of filtered results
*
* Both endpoints require authentication + admin:access permission.
*
* Register in app.ts:
* import adminRoutes from './routes/admin.routes';
* app.use('/api/admin', adminRoutes);
*/
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
import { auditExport } from '../middleware/audit.middleware';
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
import logger from '../utils/logger';
const router = Router();
// ---------------------------------------------------------------------------
// Input validation schemas (Zod)
// ---------------------------------------------------------------------------
const auditQuerySchema = z.object({
userId: z.string().uuid().optional(),
action: z.union([
z.nativeEnum(Object.fromEntries(
Object.entries(AuditAction).map(([k, v]) => [k, v])
) as Record<string, string>),
z.array(z.string()),
]).optional(),
resourceType: z.union([
z.string(),
z.array(z.string()),
]).optional(),
resourceId: z.string().optional(),
dateFrom: z.string().datetime({ offset: true }).optional(),
dateTo: z.string().datetime({ offset: true }).optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(25),
});
// ---------------------------------------------------------------------------
// Helper — parse and validate query string
// ---------------------------------------------------------------------------
function parseAuditQuery(query: Record<string, unknown>): AuditFilters {
const parsed = auditQuerySchema.parse(query);
// Normalise action to array of AuditAction
const actions = parsed.action
? (Array.isArray(parsed.action) ? parsed.action : [parsed.action]) as AuditAction[]
: undefined;
// Normalise resourceType to array of AuditResourceType
const resourceTypes = parsed.resourceType
? (Array.isArray(parsed.resourceType)
? parsed.resourceType
: [parsed.resourceType]) as AuditResourceType[]
: undefined;
return {
userId: parsed.userId,
action: actions && actions.length === 1 ? actions[0] : actions,
resourceType: resourceTypes && resourceTypes.length === 1
? resourceTypes[0]
: resourceTypes,
resourceId: parsed.resourceId,
dateFrom: parsed.dateFrom ? new Date(parsed.dateFrom) : undefined,
dateTo: parsed.dateTo ? new Date(parsed.dateTo) : undefined,
page: parsed.page,
pageSize: parsed.pageSize,
};
}
// ---------------------------------------------------------------------------
// GET /api/admin/audit-log
// ---------------------------------------------------------------------------
router.get(
'/audit-log',
authenticate,
requirePermission('admin:access'),
async (req: Request, res: Response): Promise<void> => {
try {
const filters = parseAuditQuery(req.query as Record<string, unknown>);
const result = await auditService.getAuditLog(filters);
res.status(200).json({
success: true,
data: result,
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
success: false,
message: 'Invalid query parameters',
errors: error.errors,
});
return;
}
logger.error('Failed to fetch audit log', { error });
res.status(500).json({
success: false,
message: 'Failed to fetch audit log',
});
}
}
);
// ---------------------------------------------------------------------------
// GET /api/admin/audit-log/export
// ---------------------------------------------------------------------------
router.get(
'/audit-log/export',
authenticate,
requirePermission('admin:access'),
async (req: Request, res: Response): Promise<void> => {
try {
// For CSV exports we fetch up to 10,000 rows (no pagination).
const filters = parseAuditQuery(req.query as Record<string, unknown>);
const exportFilters: AuditFilters = {
...filters,
page: 1,
pageSize: 10_000,
};
// Audit the export action itself before streaming the response
auditExport(req, AuditResourceType.SYSTEM, {
export_format: 'csv',
filters: JSON.stringify(exportFilters),
});
const result = await auditService.getAuditLog(exportFilters);
const csv = auditService.entriesToCsv(result.entries);
const filename = `audit_log_${new Date().toISOString().replace(/[:.]/g, '-')}.csv`;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Add BOM for Excel UTF-8 compatibility
res.send('\uFEFF' + csv);
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
success: false,
message: 'Invalid query parameters',
errors: error.errors,
});
return;
}
logger.error('Failed to export audit log', { error });
res.status(500).json({
success: false,
message: 'Failed to export audit log',
});
}
}
);
export default router;

View File

@@ -0,0 +1,139 @@
import { Router } from 'express';
import incidentController from '../controllers/incident.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// All incident routes require authentication
router.use(authenticate);
/**
* @route GET /api/incidents
* @desc List incidents with pagination and filters
* @access mitglied+
* @query dateFrom, dateTo, einsatzArt, status, limit, offset
*/
router.get(
'/',
requirePermission('incidents:read'),
incidentController.listIncidents.bind(incidentController)
);
/**
* @route GET /api/incidents/stats
* @desc Aggregated statistics (monthly + by type) for a given year
* @access mitglied+
* @query year (optional, defaults to current year)
*
* NOTE: This route MUST be defined before /:id to prevent 'stats' being
* captured as an id parameter.
*/
router.get(
'/stats',
requirePermission('incidents:read'),
incidentController.getStats.bind(incidentController)
);
/**
* @route POST /api/incidents/refresh-stats
* @desc Refresh einsatz_statistik materialized view (admin utility)
* @access admin only
*/
router.post(
'/refresh-stats',
requirePermission('incidents:delete'), // kommandant+ (repurposing delete permission for admin ops)
incidentController.refreshStats.bind(incidentController)
);
/**
* @route GET /api/incidents/:id
* @desc Get single incident with full detail (personnel, vehicles, timeline)
* @access mitglied+ (bericht_text redacted for roles below kommandant)
*/
router.get(
'/:id',
requirePermission('incidents:read'),
incidentController.getIncident.bind(incidentController)
);
/**
* @route POST /api/incidents
* @desc Create a new incident
* @access gruppenfuehrer+
*/
router.post(
'/',
requirePermission('incidents:write'),
incidentController.createIncident.bind(incidentController)
);
/**
* @route PATCH /api/incidents/:id
* @desc Update an existing incident (partial update)
* @access gruppenfuehrer+
*/
router.patch(
'/:id',
requirePermission('incidents:write'),
incidentController.updateIncident.bind(incidentController)
);
/**
* @route DELETE /api/incidents/:id
* @desc Soft-delete (archive) an incident
* @access kommandant+
*/
router.delete(
'/:id',
requirePermission('incidents:delete'),
incidentController.deleteIncident.bind(incidentController)
);
/**
* @route POST /api/incidents/:id/personnel
* @desc Assign a member to an incident with a function (Funktion)
* @access gruppenfuehrer+
* @body { user_id: UUID, funktion?: string, alarm_time?: ISO8601, ankunft_time?: ISO8601 }
*/
router.post(
'/:id/personnel',
requirePermission('incidents:manage_personnel'),
incidentController.assignPersonnel.bind(incidentController)
);
/**
* @route DELETE /api/incidents/:id/personnel/:userId
* @desc Remove a member from an incident
* @access gruppenfuehrer+
*/
router.delete(
'/:id/personnel/:userId',
requirePermission('incidents:manage_personnel'),
incidentController.removePersonnel.bind(incidentController)
);
/**
* @route POST /api/incidents/:id/vehicles
* @desc Assign a vehicle to an incident
* @access gruppenfuehrer+
* @body { fahrzeug_id: UUID, ausrueck_time?: ISO8601, einrueck_time?: ISO8601 }
*/
router.post(
'/:id/vehicles',
requirePermission('incidents:manage_personnel'),
incidentController.assignVehicle.bind(incidentController)
);
/**
* @route DELETE /api/incidents/:id/vehicles/:fahrzeugId
* @desc Remove a vehicle from an incident
* @access gruppenfuehrer+
*/
router.delete(
'/:id/vehicles/:fahrzeugId',
requirePermission('incidents:manage_personnel'),
incidentController.removeVehicle.bind(incidentController)
);
export default router;

View File

@@ -0,0 +1,139 @@
import { Router, Request, Response, NextFunction } from 'express';
import memberController from '../controllers/member.controller';
import { authenticate } from '../middleware/auth.middleware';
import logger from '../utils/logger';
const router = Router();
// ----------------------------------------------------------------
// Role/permission middleware
//
// The JWT currently carries: { userId, email, authentikSub }.
// Roles come from Authentik group membership stored in the
// `groups` array on the UserInfo response. The auth controller
// already upserts the user in the DB on every login; this
// middleware resolves the role from req.user (extended below).
//
// Until a full roles column exists in the users table, roles are
// derived from a well-known Authentik group naming convention:
//
// "feuerwehr-admin" → AppRole 'admin'
// "feuerwehr-kommandant" → AppRole 'kommandant'
// (everything else) → AppRole 'mitglied'
//
// The groups are passed through the JWT as req.user.groups (added
// by the extended type below). Replace this logic with a DB
// lookup once a roles column is added to users.
// ----------------------------------------------------------------
type AppRole = 'admin' | 'kommandant' | 'mitglied';
// Extend the Express Request type to include role
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
authentikSub: string;
role?: AppRole;
groups?: string[];
};
}
}
}
/**
* Resolves the AppRole from Authentik groups attached to the JWT.
* Mutates req.user.role so downstream controllers can read it directly.
*/
const resolveRole = (req: Request, _res: Response, next: NextFunction): void => {
if (req.user) {
const groups: string[] = (req.user as any).groups ?? [];
if (groups.includes('feuerwehr-admin')) {
req.user.role = 'admin';
} else if (groups.includes('feuerwehr-kommandant')) {
req.user.role = 'kommandant';
} else {
req.user.role = 'mitglied';
}
logger.debug('resolveRole', { userId: req.user.id, role: req.user.role });
}
next();
};
/**
* Factory: creates a middleware that enforces the minimum required role.
* Role hierarchy: admin > kommandant > mitglied
*/
const requirePermission = (permission: 'members:read' | 'members:write') => {
return (req: Request, res: Response, next: NextFunction): void => {
const role = (req.user as any)?.role ?? 'mitglied';
const writeRoles: AppRole[] = ['admin', 'kommandant'];
const readRoles: AppRole[] = ['admin', 'kommandant', 'mitglied'];
const allowed =
permission === 'members:write'
? writeRoles.includes(role)
: readRoles.includes(role);
if (!allowed) {
res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Aktion.',
});
return;
}
next();
};
};
// ----------------------------------------------------------------
// Apply authentication + role resolution to every route in this
// router. Note: requirePermission is applied per-route because
// PATCH allows the owner to update their own limited fields even
// without 'members:write'.
// ----------------------------------------------------------------
router.use(authenticate, resolveRole);
// IMPORTANT: The static /stats route must be registered BEFORE
// the dynamic /:userId route, otherwise Express would match
// "stats" as a userId parameter.
router.get(
'/stats',
requirePermission('members:read'),
memberController.getMemberStats.bind(memberController)
);
router.get(
'/',
requirePermission('members:read'),
memberController.getMembers.bind(memberController)
);
router.get(
'/:userId',
requirePermission('members:read'),
memberController.getMemberById.bind(memberController)
);
router.post(
'/:userId/profile',
requirePermission('members:write'),
memberController.createMemberProfile.bind(memberController)
);
/**
* PATCH /:userId — open to both privileged users AND the profile owner.
* The controller itself enforces the correct Zod schema (full vs. limited)
* based on the caller's role.
*/
router.patch(
'/:userId',
// No requirePermission here — controller handles own-profile vs. write-role logic
memberController.updateMember.bind(memberController)
);
export default router;

View File

@@ -0,0 +1,149 @@
import { Router, Request, Response, NextFunction } from 'express';
import trainingController from '../controllers/training.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requirePermission, getUserRole } from '../middleware/rbac.middleware';
const router = Router();
// ---------------------------------------------------------------------------
// injectTeilnahmenFlag
//
// Sets req.canSeeTeilnahmen = true for Gruppenführer and above.
// Regular Mitglieder see only attendance counts; officers see the full list.
// ---------------------------------------------------------------------------
async function injectTeilnahmenFlag(
req: Request,
_res: Response,
next: NextFunction
): Promise<void> {
try {
if (req.user) {
const role = await getUserRole(req.user.id);
const ROLE_ORDER: Record<string, number> = {
bewerber: -1, mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3,
};
(req as any).canSeeTeilnahmen =
(ROLE_ORDER[role] ?? 0) >= ROLE_ORDER.gruppenfuehrer;
}
} catch (_err) {
// Non-fatal — default to restricted view
}
next();
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
/**
* GET /api/training
* Public list of upcoming events (limit param).
* Optional auth to include own RSVP status.
*/
router.get('/', optionalAuth, trainingController.getUpcoming);
/**
* GET /api/training/calendar?from=<ISO>&to=<ISO>
* Events in a date range for the calendar view.
* Optional auth to include own RSVP status.
*/
router.get('/calendar', optionalAuth, trainingController.getCalendarRange);
/**
* GET /api/training/calendar.ics?token=<calendarToken>
* iCal export — authenticated via per-user calendar token OR Bearer JWT.
* No `authenticate` enforced here; controller resolves auth itself.
* See training.controller.ts for full auth tradeoff discussion.
*/
router.get('/calendar.ics', optionalAuth, trainingController.getIcalExport);
/**
* GET /api/training/calendar-token
* Returns (or creates) the user's personal iCal subscribe token + URL.
* Requires authentication.
*/
router.get('/calendar-token', authenticate, trainingController.getCalendarToken);
/**
* GET /api/training/stats?year=<YYYY>
* Annual participation stats.
* Requires Kommandant or above (requirePermission('reports:read')).
*/
router.get(
'/stats',
authenticate,
requirePermission('reports:read'),
trainingController.getStats
);
/**
* GET /api/training/:id
* Single event with attendance counts.
* Gruppenführer+ also gets the full attendee list.
*/
router.get(
'/:id',
authenticate,
injectTeilnahmenFlag,
trainingController.getById
);
/**
* POST /api/training
* Create a new training event.
* Requires Gruppenführer or above (requirePermission('training:write')).
*/
router.post(
'/',
authenticate,
requirePermission('training:write'),
trainingController.createEvent
);
/**
* PATCH /api/training/:id
* Update an existing event.
* Requires Gruppenführer or above.
*/
router.patch(
'/:id',
authenticate,
requirePermission('training:write'),
trainingController.updateEvent
);
/**
* DELETE /api/training/:id
* Soft-cancel an event (sets abgesagt=true, records reason).
* Requires Kommandant or above.
*/
router.delete(
'/:id',
authenticate,
requirePermission('training:cancel'),
trainingController.cancelEvent
);
/**
* PATCH /api/training/:id/attendance
* Any authenticated member updates their own RSVP.
*/
router.patch(
'/:id/attendance',
authenticate,
trainingController.updateRsvp
);
/**
* POST /api/training/:id/attendance/mark
* Gruppenführer bulk-marks who actually appeared.
*/
router.post(
'/:id/attendance/mark',
authenticate,
requirePermission('training:mark_attendance'),
trainingController.markAttendance
);
export default router;

View File

@@ -0,0 +1,147 @@
import { Router } from 'express';
import vehicleController from '../controllers/vehicle.controller';
import { authenticate } from '../middleware/auth.middleware';
// ---------------------------------------------------------------------------
// RBAC guard — requirePermission('vehicles:write')
// ---------------------------------------------------------------------------
// Tier 1 will deliver a full RBAC middleware. Until then, this inline guard
// enforces that only admin/kommandant/gruppenfuehrer roles can mutate vehicle
// data. The role is expected on req.user once Tier 1 is complete.
// For now it uses a conservative allowlist that can be updated via Tier 1 RBAC.
// ---------------------------------------------------------------------------
import { Request, Response, NextFunction } from 'express';
/** Roles that are allowed to write vehicle data */
const WRITE_ROLES = new Set(['admin', 'kommandant', 'gruppenfuehrer']);
/**
* requirePermission guard — temporary inline implementation.
* Replace with the Tier 1 RBAC middleware when available:
* import { requirePermission } from '../middleware/rbac.middleware';
*/
const requireVehicleWrite = (
req: Request,
res: Response,
next: NextFunction
): void => {
// Once Tier 1 RBAC is merged, replace the body with:
// return requirePermission('vehicles:write')(req, res, next);
//
// Temporary implementation: check the role field on the JWT payload.
// The role is stored in req.user once authenticate() has run (Tier 1 adds it).
const role = (req.user as any)?.role as string | undefined;
if (!role || !WRITE_ROLES.has(role)) {
res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Aktion (vehicles:write erforderlich)',
});
return;
}
next();
};
// ---------------------------------------------------------------------------
const router = Router();
// ── Read-only endpoints (any authenticated user) ──────────────────────────────
/**
* GET /api/vehicles
* Fleet overview list — inspection badges included.
*/
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
/**
* GET /api/vehicles/stats
* Dashboard KPI aggregates.
* NOTE: /stats and /alerts must be declared BEFORE /:id to avoid route conflicts.
*/
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
/**
* GET /api/vehicles/alerts?daysAhead=30
* Upcoming and overdue inspections for the dashboard alert panel.
*/
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
/**
* GET /api/vehicles/:id
* Full vehicle detail with inspection history and maintenance log.
*/
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
/**
* GET /api/vehicles/:id/pruefungen
* Inspection history for a single vehicle.
*/
router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind(vehicleController));
/**
* GET /api/vehicles/:id/wartung
* Maintenance log for a single vehicle.
*/
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
// ── Write endpoints (vehicles:write role required) ─────────────────────────────
/**
* POST /api/vehicles
* Create a new vehicle.
*/
router.post(
'/',
authenticate,
requireVehicleWrite,
vehicleController.createVehicle.bind(vehicleController)
);
/**
* PATCH /api/vehicles/:id
* Update vehicle fields.
*/
router.patch(
'/:id',
authenticate,
requireVehicleWrite,
vehicleController.updateVehicle.bind(vehicleController)
);
/**
* PATCH /api/vehicles/:id/status
* Live status change — Socket.IO hook point for Tier 3.
* The `io` instance is retrieved inside the controller via req.app.get('io').
*/
router.patch(
'/:id/status',
authenticate,
requireVehicleWrite,
vehicleController.updateVehicleStatus.bind(vehicleController)
);
/**
* POST /api/vehicles/:id/pruefungen
* Record an inspection (scheduled or completed).
*/
router.post(
'/:id/pruefungen',
authenticate,
requireVehicleWrite,
vehicleController.addPruefung.bind(vehicleController)
);
/**
* POST /api/vehicles/:id/wartung
* Add a maintenance log entry.
*/
router.post(
'/:id/wartung',
authenticate,
requireVehicleWrite,
vehicleController.addWartung.bind(vehicleController)
);
export default router;

View File

@@ -1,7 +1,7 @@
import app from './app';
import environment from './config/environment';
import logger from './utils/logger';
import { testConnection, closePool } from './config/database';
import { testConnection, closePool, runMigrations } from './config/database';
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
const startServer = async (): Promise<void> => {
@@ -12,6 +12,9 @@ const startServer = async (): Promise<void> => {
if (!dbConnected) {
logger.warn('Database connection failed - server will start but database operations may fail');
} else {
// Run pending migrations automatically on startup
await runMigrations();
}
// Start the GDPR IP anonymisation job

View File

@@ -0,0 +1,394 @@
/**
* Audit Service
*
* GDPR compliance:
* - Art. 5(2) Accountability: every write to personal data is logged.
* - Art. 30 Records of Processing Activities: who, what, when, delta.
* - Art. 5(1)e Storage limitation: IP addresses anonymised after 90 days
* via anonymizeOldIpAddresses(), called by the scheduled job.
*
* Critical invariant: logAudit() MUST NEVER throw or reject. Audit failures
* are logged to Winston and silently swallowed so that the main request flow
* is never interrupted.
*/
import pool from '../config/database';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Enums — kept as const objects rather than TypeScript enums so that the
// values are available as plain strings in runtime code and SQL parameters.
// ---------------------------------------------------------------------------
export const AuditAction = {
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
LOGIN: 'LOGIN',
LOGOUT: 'LOGOUT',
EXPORT: 'EXPORT',
PERMISSION_DENIED:'PERMISSION_DENIED',
PASSWORD_CHANGE: 'PASSWORD_CHANGE',
ROLE_CHANGE: 'ROLE_CHANGE',
} as const;
export type AuditAction = typeof AuditAction[keyof typeof AuditAction];
export const AuditResourceType = {
MEMBER: 'MEMBER',
INCIDENT: 'INCIDENT',
VEHICLE: 'VEHICLE',
EQUIPMENT: 'EQUIPMENT',
QUALIFICATION: 'QUALIFICATION',
USER: 'USER',
SYSTEM: 'SYSTEM',
} as const;
export type AuditResourceType = typeof AuditResourceType[keyof typeof AuditResourceType];
// ---------------------------------------------------------------------------
// Core interfaces
// ---------------------------------------------------------------------------
export interface AuditLogEntry {
id: string; // UUID, set by database
user_id: string | null; // UUID; null for unauthenticated events
user_email: string | null; // denormalised snapshot
action: AuditAction;
resource_type: AuditResourceType;
resource_id: string | null;
old_value: Record<string, unknown> | null;
new_value: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
metadata: Record<string, unknown>;
created_at: Date;
}
/**
* Input type for logAudit() — the caller never supplies id or created_at.
*/
export type AuditLogInput = Omit<AuditLogEntry, 'id' | 'created_at'>;
// ---------------------------------------------------------------------------
// Filter + pagination types (used by the admin API)
// ---------------------------------------------------------------------------
export interface AuditFilters {
userId?: string;
action?: AuditAction | AuditAction[];
resourceType?: AuditResourceType | AuditResourceType[];
resourceId?: string;
dateFrom?: Date;
dateTo?: Date;
page: number; // 1-based
pageSize: number; // max 200
}
export interface AuditLogPage {
entries: AuditLogEntry[];
total: number;
page: number;
pages: number;
}
// ---------------------------------------------------------------------------
// Sensitive field stripping
// Ensures that passwords, tokens, and secrets never appear in the log even
// if a caller accidentally passes a raw request body.
// ---------------------------------------------------------------------------
const SENSITIVE_KEYS = new Set([
'password', 'password_hash', 'passwordHash',
'secret', 'token', 'accessToken', 'refreshToken', 'access_token',
'refresh_token', 'id_token', 'client_secret',
'authorization', 'cookie',
]);
function stripSensitiveFields(
value: Record<string, unknown> | null | undefined
): Record<string, unknown> | null {
if (value === null || value === undefined) return null;
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (SENSITIVE_KEYS.has(k.toLowerCase())) {
result[k] = '[REDACTED]';
} else if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
result[k] = stripSensitiveFields(v as Record<string, unknown>);
} else {
result[k] = v;
}
}
return result;
}
// ---------------------------------------------------------------------------
// AuditService
// ---------------------------------------------------------------------------
class AuditService {
/**
* logAudit — fire-and-forget.
*
* The caller does NOT await this. Even if awaited, it will never reject;
* all errors are swallowed and written to Winston instead.
*/
async logAudit(entry: AuditLogInput): Promise<void> {
// Start immediately — do not block the caller.
this._writeAuditEntry(entry).catch(() => {
// _writeAuditEntry already handles its own error logging; this .catch()
// prevents a potential unhandledRejection if the async method itself
// throws synchronously before the inner try/catch.
});
}
/**
* Internal write — all failures are caught and sent to Winston.
* This method should never surface an exception to its caller.
*/
private async _writeAuditEntry(entry: AuditLogInput): Promise<void> {
try {
const sanitisedOld = stripSensitiveFields(entry.old_value ?? null);
const sanitisedNew = stripSensitiveFields(entry.new_value ?? null);
const query = `
INSERT INTO audit_log (
user_id,
user_email,
action,
resource_type,
resource_id,
old_value,
new_value,
ip_address,
user_agent,
metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`;
const values = [
entry.user_id ?? null,
entry.user_email ?? null,
entry.action,
entry.resource_type,
entry.resource_id ?? null,
sanitisedOld ? JSON.stringify(sanitisedOld) : null,
sanitisedNew ? JSON.stringify(sanitisedNew) : null,
entry.ip_address ?? null,
entry.user_agent ?? null,
JSON.stringify(entry.metadata ?? {}),
];
await pool.query(query, values);
logger.debug('Audit entry written', {
action: entry.action,
resource_type: entry.resource_type,
resource_id: entry.resource_id,
user_id: entry.user_id,
});
} catch (error) {
// GDPR obligation: log the failure so it can be investigated, but
// NEVER propagate — the main request must complete successfully.
logger.error('AUDIT LOG FAILURE — entry could not be persisted to database', {
error: error instanceof Error ? error.message : String(error),
action: entry.action,
resource_type: entry.resource_type,
resource_id: entry.resource_id,
user_id: entry.user_id,
// Note: we intentionally do NOT log old_value/new_value here
// because they may contain personal data.
});
}
}
// -------------------------------------------------------------------------
// Query — admin UI
// -------------------------------------------------------------------------
/**
* getAuditLog — paginated, filtered query for the admin dashboard.
*
* Never called from hot paths; can be awaited normally.
*/
async getAuditLog(filters: AuditFilters): Promise<AuditLogPage> {
const page = Math.max(1, filters.page ?? 1);
const pageSize = Math.min(200, Math.max(1, filters.pageSize ?? 25));
const offset = (page - 1) * pageSize;
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (filters.userId) {
conditions.push(`user_id = $${paramIndex++}`);
values.push(filters.userId);
}
if (filters.action) {
const actions = Array.isArray(filters.action)
? filters.action
: [filters.action];
conditions.push(`action = ANY($${paramIndex++}::audit_action[])`);
values.push(actions);
}
if (filters.resourceType) {
const types = Array.isArray(filters.resourceType)
? filters.resourceType
: [filters.resourceType];
conditions.push(`resource_type = ANY($${paramIndex++}::audit_resource_type[])`);
values.push(types);
}
if (filters.resourceId) {
conditions.push(`resource_id = $${paramIndex++}`);
values.push(filters.resourceId);
}
if (filters.dateFrom) {
conditions.push(`created_at >= $${paramIndex++}`);
values.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`created_at <= $${paramIndex++}`);
values.push(filters.dateTo);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
const countQuery = `
SELECT COUNT(*) AS total
FROM audit_log
${whereClause}
`;
const dataQuery = `
SELECT
id, user_id, user_email, action, resource_type, resource_id,
old_value, new_value, ip_address, user_agent, metadata, created_at
FROM audit_log
${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`;
const [countResult, dataResult] = await Promise.all([
pool.query(countQuery, values),
pool.query(dataQuery, [...values, pageSize, offset]),
]);
const total = parseInt(countResult.rows[0].total, 10);
const entries = dataResult.rows as AuditLogEntry[];
return {
entries,
total,
page,
pages: Math.ceil(total / pageSize),
};
}
// -------------------------------------------------------------------------
// GDPR IP anonymisation — run as a scheduled job
// -------------------------------------------------------------------------
/**
* anonymizeOldIpAddresses
*
* Replaces the ip_address of every audit_log row older than 90 days with
* the literal string '[anonymized]'. This satisfies the GDPR storage
* limitation principle (Art. 5(1)(e)) for IP addresses as personal data.
*
* Uses a single UPDATE with a WHERE clause covered by idx_audit_log_ip_retention,
* so performance is proportional to the number of rows being anonymised, not
* the total table size.
*
* Note: The database RULE audit_log_no_update blocks UPDATE statements issued
* *by application queries*, but that rule is created with DO INSTEAD NOTHING,
* which means the application-level anonymisation query also cannot run unless
* the rule is dropped or replaced.
*
* Resolution: the migration creates the rule. For the anonymisation job to
* work, the database role used by this application must either:
* (a) own the table (then rules do not apply to the owner), OR
* (b) use a separate privileged role for the anonymisation query only.
*
* Recommended approach: use option (a) by ensuring DB_USER is the table
* owner, which is the default when the same user runs the migrations.
* The immutability rule then protects against accidental application-level
* UPDATE/DELETE while the owner can still perform sanctioned data operations.
*/
async anonymizeOldIpAddresses(): Promise<void> {
try {
const result = await pool.query(`
UPDATE audit_log
SET ip_address = '[anonymized]'
WHERE created_at < NOW() - INTERVAL '90 days'
AND ip_address IS NOT NULL
AND ip_address != '[anonymized]'
`);
const count = result.rowCount ?? 0;
if (count > 0) {
logger.info('GDPR IP anonymisation complete', { rows_anonymized: count });
} else {
logger.debug('GDPR IP anonymisation: no rows required anonymisation');
}
} catch (error) {
logger.error('GDPR IP anonymisation job failed', {
error: error instanceof Error ? error.message : String(error),
});
// Do not re-throw: the job scheduler should not crash the process.
}
}
// -------------------------------------------------------------------------
// CSV export helper (used by the admin API route)
// -------------------------------------------------------------------------
/**
* Converts a page of audit entries to CSV format.
* Passwords and secrets are already stripped; old_value/new_value are
* serialised as compact JSON strings within the CSV cell.
*/
entriesToCsv(entries: AuditLogEntry[]): string {
const header = [
'id', 'created_at', 'user_id', 'user_email',
'action', 'resource_type', 'resource_id',
'ip_address', 'user_agent', 'old_value', 'new_value', 'metadata',
].join(',');
const escape = (v: unknown): string => {
if (v === null || v === undefined) return '';
const str = typeof v === 'object' ? JSON.stringify(v) : String(v);
// RFC 4180: wrap in quotes, double any internal quotes
return `"${str.replace(/"/g, '""')}"`;
};
const rows = entries.map((e) =>
[
e.id,
e.created_at.toISOString(),
e.user_id ?? '',
e.user_email ?? '',
e.action,
e.resource_type,
e.resource_id ?? '',
e.ip_address ?? '',
escape(e.user_agent),
escape(e.old_value),
escape(e.new_value),
escape(e.metadata),
].join(',')
);
return [header, ...rows].join('\r\n');
}
}
export default new AuditService();

View File

@@ -0,0 +1,699 @@
import pool from '../config/database';
import logger from '../utils/logger';
import {
Einsatz,
EinsatzWithDetails,
EinsatzListItem,
EinsatzStats,
EinsatzFahrzeug,
EinsatzPersonal,
MonthlyStatRow,
EinsatzArtStatRow,
EinsatzArt,
CreateEinsatzData,
UpdateEinsatzData,
AssignPersonnelData,
AssignVehicleData,
IncidentFilters,
} from '../models/incident.model';
class IncidentService {
// -------------------------------------------------------------------------
// LIST
// -------------------------------------------------------------------------
/**
* Get a paginated list of incidents with optional filters.
* Returns lightweight EinsatzListItem rows (no bericht_text, no sub-arrays).
*/
async getAllIncidents(
filters: IncidentFilters = { limit: 50, offset: 0 }
): Promise<{ items: EinsatzListItem[]; total: number }> {
try {
const conditions: string[] = ["e.status != 'archiviert'"];
const params: unknown[] = [];
let p = 1;
if (filters.dateFrom) {
conditions.push(`e.alarm_time >= $${p++}`);
params.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`e.alarm_time <= $${p++}`);
params.push(filters.dateTo);
}
if (filters.einsatzArt) {
conditions.push(`e.einsatz_art = $${p++}`);
params.push(filters.einsatzArt);
}
if (filters.status) {
conditions.push(`e.status = $${p++}`);
params.push(filters.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Count query
const countResult = await pool.query(
`SELECT COUNT(*)::INTEGER AS total FROM einsaetze e ${where}`,
params
);
const total: number = countResult.rows[0].total;
// Data query — joined with users for einsatzleiter name and personal count
const dataQuery = `
SELECT
e.id,
e.einsatz_nr,
e.alarm_time,
e.einsatz_art,
e.einsatz_stichwort,
e.ort,
e.strasse,
e.status,
COALESCE(
TRIM(CONCAT(u.given_name, ' ', u.family_name)),
u.name,
u.preferred_username
) AS einsatzleiter_name,
ROUND(
EXTRACT(EPOCH FROM (e.ankunft_time - e.alarm_time)) / 60.0
)::INTEGER AS hilfsfrist_min,
ROUND(
EXTRACT(EPOCH FROM (e.einrueck_time - e.alarm_time)) / 60.0
)::INTEGER AS dauer_min,
COALESCE(ep.personal_count, 0)::INTEGER AS personal_count
FROM einsaetze e
LEFT JOIN users u ON u.id = e.einsatzleiter_id
LEFT JOIN (
SELECT einsatz_id, COUNT(*) AS personal_count
FROM einsatz_personal
GROUP BY einsatz_id
) ep ON ep.einsatz_id = e.id
${where}
ORDER BY e.alarm_time DESC
LIMIT $${p++} OFFSET $${p++}
`;
params.push(filters.limit ?? 50, filters.offset ?? 0);
const dataResult = await pool.query(dataQuery, params);
const items: EinsatzListItem[] = dataResult.rows.map((row) => ({
id: row.id,
einsatz_nr: row.einsatz_nr,
alarm_time: row.alarm_time,
einsatz_art: row.einsatz_art,
einsatz_stichwort: row.einsatz_stichwort,
ort: row.ort,
strasse: row.strasse,
status: row.status,
einsatzleiter_name: row.einsatzleiter_name ?? null,
hilfsfrist_min: row.hilfsfrist_min !== null ? Number(row.hilfsfrist_min) : null,
dauer_min: row.dauer_min !== null ? Number(row.dauer_min) : null,
personal_count: Number(row.personal_count),
}));
return { items, total };
} catch (error) {
logger.error('Error fetching incident list', { error, filters });
throw new Error('Failed to fetch incidents');
}
}
// -------------------------------------------------------------------------
// DETAIL
// -------------------------------------------------------------------------
/**
* Get a single incident with full details: personnel, vehicles, computed times.
* NOTE: bericht_text is included here; role-based redaction is applied in
* the controller based on req.user.role.
*/
async getIncidentById(id: string): Promise<EinsatzWithDetails | null> {
try {
const einsatzResult = await pool.query(
`
SELECT
e.*,
COALESCE(
TRIM(CONCAT(u.given_name, ' ', u.family_name)),
u.name,
u.preferred_username
) AS einsatzleiter_name,
ROUND(
EXTRACT(EPOCH FROM (e.ankunft_time - e.alarm_time)) / 60.0
)::INTEGER AS hilfsfrist_min,
ROUND(
EXTRACT(EPOCH FROM (e.einrueck_time - e.alarm_time)) / 60.0
)::INTEGER AS dauer_min
FROM einsaetze e
LEFT JOIN users u ON u.id = e.einsatzleiter_id
WHERE e.id = $1
`,
[id]
);
if (einsatzResult.rows.length === 0) {
return null;
}
const row = einsatzResult.rows[0];
// Fetch assigned personnel
const personalResult = await pool.query(
`
SELECT
ep.einsatz_id,
ep.user_id,
ep.funktion,
ep.alarm_time,
ep.ankunft_time,
ep.assigned_at,
u.name,
u.email,
u.given_name,
u.family_name
FROM einsatz_personal ep
JOIN users u ON u.id = ep.user_id
WHERE ep.einsatz_id = $1
ORDER BY ep.funktion, u.family_name
`,
[id]
);
// Fetch assigned vehicles
const fahrzeugeResult = await pool.query(
`
SELECT
ef.einsatz_id,
ef.fahrzeug_id,
ef.ausrueck_time,
ef.einrueck_time,
ef.assigned_at,
f.amtliches_kennzeichen AS kennzeichen,
f.bezeichnung,
f.typ_schluessel AS fahrzeug_typ
FROM einsatz_fahrzeuge ef
JOIN fahrzeuge f ON f.id = ef.fahrzeug_id
WHERE ef.einsatz_id = $1
ORDER BY f.bezeichnung
`,
[id]
);
const einsatz: EinsatzWithDetails = {
id: row.id,
einsatz_nr: row.einsatz_nr,
alarm_time: row.alarm_time,
ausrueck_time: row.ausrueck_time,
ankunft_time: row.ankunft_time,
einrueck_time: row.einrueck_time,
einsatz_art: row.einsatz_art,
einsatz_stichwort: row.einsatz_stichwort,
strasse: row.strasse,
hausnummer: row.hausnummer,
ort: row.ort,
koordinaten: row.koordinaten,
bericht_kurz: row.bericht_kurz,
bericht_text: row.bericht_text,
einsatzleiter_id: row.einsatzleiter_id,
alarmierung_art: row.alarmierung_art,
status: row.status,
created_by: row.created_by,
created_at: row.created_at,
updated_at: row.updated_at,
einsatzleiter_name: row.einsatzleiter_name ?? null,
hilfsfrist_min: row.hilfsfrist_min !== null ? Number(row.hilfsfrist_min) : null,
dauer_min: row.dauer_min !== null ? Number(row.dauer_min) : null,
fahrzeuge: fahrzeugeResult.rows as EinsatzFahrzeug[],
personal: personalResult.rows as EinsatzPersonal[],
};
return einsatz;
} catch (error) {
logger.error('Error fetching incident by ID', { error, id });
throw new Error('Failed to fetch incident');
}
}
// -------------------------------------------------------------------------
// CREATE
// -------------------------------------------------------------------------
/**
* Create a new incident.
* Einsatz-Nr is generated atomically by the PostgreSQL function
* generate_einsatz_nr() using a per-year sequence table with UPDATE ... RETURNING.
* This is safe under concurrent inserts.
*/
async createIncident(data: CreateEinsatzData, createdBy: string): Promise<Einsatz> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Generate the Einsatz-Nr atomically inside the transaction
const nrResult = await client.query(
`SELECT generate_einsatz_nr($1::TIMESTAMPTZ) AS einsatz_nr`,
[data.alarm_time]
);
const einsatz_nr: string = nrResult.rows[0].einsatz_nr;
const result = await client.query(
`
INSERT INTO einsaetze (
einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort,
strasse, hausnummer, ort,
bericht_kurz, bericht_text,
einsatzleiter_id, alarmierung_art, status,
created_by
) VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10,
$11, $12,
$13, $14, $15,
$16
)
RETURNING *
`,
[
einsatz_nr,
data.alarm_time,
data.ausrueck_time ?? null,
data.ankunft_time ?? null,
data.einrueck_time ?? null,
data.einsatz_art,
data.einsatz_stichwort ?? null,
data.strasse ?? null,
data.hausnummer ?? null,
data.ort ?? null,
data.bericht_kurz ?? null,
data.bericht_text ?? null,
data.einsatzleiter_id ?? null,
data.alarmierung_art ?? 'ILS',
data.status ?? 'aktiv',
createdBy,
]
);
await client.query('COMMIT');
const einsatz = result.rows[0] as Einsatz;
logger.info('Incident created', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
createdBy,
});
return einsatz;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error creating incident', { error, createdBy });
throw new Error('Failed to create incident');
} finally {
client.release();
}
}
// -------------------------------------------------------------------------
// UPDATE
// -------------------------------------------------------------------------
async updateIncident(
id: string,
data: UpdateEinsatzData,
updatedBy: string
): Promise<Einsatz> {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const fieldMap: Array<[keyof UpdateEinsatzData, string]> = [
['alarm_time', 'alarm_time'],
['ausrueck_time', 'ausrueck_time'],
['ankunft_time', 'ankunft_time'],
['einrueck_time', 'einrueck_time'],
['einsatz_art', 'einsatz_art'],
['einsatz_stichwort', 'einsatz_stichwort'],
['strasse', 'strasse'],
['hausnummer', 'hausnummer'],
['ort', 'ort'],
['bericht_kurz', 'bericht_kurz'],
['bericht_text', 'bericht_text'],
['einsatzleiter_id', 'einsatzleiter_id'],
['alarmierung_art', 'alarmierung_art'],
['status', 'status'],
];
for (const [key, col] of fieldMap) {
if (key in data) {
fields.push(`${col} = $${p++}`);
values.push((data as Record<string, unknown>)[key] ?? null);
}
}
if (fields.length === 0) {
// Nothing to update — return current state
const current = await this.getIncidentById(id);
if (!current) throw new Error('Incident not found');
return current as Einsatz;
}
// updated_at is handled by the trigger, but we also set it explicitly
// to ensure immediate consistency within the same request cycle
fields.push(`updated_at = NOW()`);
values.push(id);
const result = await pool.query(
`
UPDATE einsaetze
SET ${fields.join(', ')}
WHERE id = $${p}
RETURNING *
`,
values
);
if (result.rows.length === 0) {
throw new Error('Incident not found');
}
const einsatz = result.rows[0] as Einsatz;
logger.info('Incident updated', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
updatedBy,
});
return einsatz;
} catch (error) {
logger.error('Error updating incident', { error, id, updatedBy });
if (error instanceof Error && error.message === 'Incident not found') throw error;
throw new Error('Failed to update incident');
}
}
// -------------------------------------------------------------------------
// SOFT DELETE
// -------------------------------------------------------------------------
/** Soft delete: sets status = 'archiviert'. Hard delete is not exposed. */
async deleteIncident(id: string, deletedBy: string): Promise<void> {
try {
const result = await pool.query(
`
UPDATE einsaetze
SET status = 'archiviert', updated_at = NOW()
WHERE id = $1 AND status != 'archiviert'
RETURNING id
`,
[id]
);
if (result.rows.length === 0) {
throw new Error('Incident not found or already archived');
}
logger.info('Incident archived (soft-deleted)', { einsatzId: id, deletedBy });
} catch (error) {
logger.error('Error archiving incident', { error, id });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to archive incident');
}
}
// -------------------------------------------------------------------------
// PERSONNEL
// -------------------------------------------------------------------------
async assignPersonnel(einsatzId: string, data: AssignPersonnelData): Promise<void> {
try {
await pool.query(
`
INSERT INTO einsatz_personal (einsatz_id, user_id, funktion, alarm_time, ankunft_time)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (einsatz_id, user_id) DO UPDATE SET
funktion = EXCLUDED.funktion,
alarm_time = EXCLUDED.alarm_time,
ankunft_time = EXCLUDED.ankunft_time
`,
[
einsatzId,
data.user_id,
data.funktion ?? 'Mannschaft',
data.alarm_time ?? null,
data.ankunft_time ?? null,
]
);
logger.info('Personnel assigned to incident', {
einsatzId,
userId: data.user_id,
funktion: data.funktion,
});
} catch (error) {
logger.error('Error assigning personnel', { error, einsatzId, data });
throw new Error('Failed to assign personnel');
}
}
async removePersonnel(einsatzId: string, userId: string): Promise<void> {
try {
const result = await pool.query(
`DELETE FROM einsatz_personal WHERE einsatz_id = $1 AND user_id = $2 RETURNING user_id`,
[einsatzId, userId]
);
if (result.rows.length === 0) {
throw new Error('Personnel assignment not found');
}
logger.info('Personnel removed from incident', { einsatzId, userId });
} catch (error) {
logger.error('Error removing personnel', { error, einsatzId, userId });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to remove personnel');
}
}
// -------------------------------------------------------------------------
// VEHICLES
// -------------------------------------------------------------------------
async assignVehicle(einsatzId: string, data: AssignVehicleData): Promise<void> {
try {
await pool.query(
`
INSERT INTO einsatz_fahrzeuge (einsatz_id, fahrzeug_id, ausrueck_time, einrueck_time)
VALUES ($1, $2, $3, $4)
ON CONFLICT (einsatz_id, fahrzeug_id) DO UPDATE SET
ausrueck_time = EXCLUDED.ausrueck_time,
einrueck_time = EXCLUDED.einrueck_time
`,
[
einsatzId,
data.fahrzeug_id,
data.ausrueck_time ?? null,
data.einrueck_time ?? null,
]
);
logger.info('Vehicle assigned to incident', { einsatzId, fahrzeugId: data.fahrzeug_id });
} catch (error) {
logger.error('Error assigning vehicle', { error, einsatzId, data });
throw new Error('Failed to assign vehicle');
}
}
async removeVehicle(einsatzId: string, fahrzeugId: string): Promise<void> {
try {
const result = await pool.query(
`DELETE FROM einsatz_fahrzeuge WHERE einsatz_id = $1 AND fahrzeug_id = $2 RETURNING fahrzeug_id`,
[einsatzId, fahrzeugId]
);
if (result.rows.length === 0) {
throw new Error('Vehicle assignment not found');
}
logger.info('Vehicle removed from incident', { einsatzId, fahrzeugId });
} catch (error) {
logger.error('Error removing vehicle', { error, einsatzId, fahrzeugId });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to remove vehicle');
}
}
// -------------------------------------------------------------------------
// STATISTICS
// -------------------------------------------------------------------------
/**
* Returns aggregated statistics for a given year (defaults to current year).
* Queries live data directly rather than the materialized view so the stats
* page always reflects uncommitted-or-just-committed incidents.
* The materialized view is used for dashboard KPI cards via a separate endpoint.
*/
async getIncidentStats(year?: number): Promise<EinsatzStats> {
try {
const targetYear = year ?? new Date().getFullYear();
const prevYear = targetYear - 1;
// Overall totals for target year
const totalsResult = await pool.query(
`
SELECT
COUNT(*)::INTEGER AS gesamt,
COUNT(*) FILTER (WHERE status = 'abgeschlossen')::INTEGER AS abgeschlossen,
COUNT(*) FILTER (WHERE status = 'aktiv')::INTEGER AS aktiv,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
`,
[targetYear]
);
const totals = totalsResult.rows[0];
// Monthly breakdown — target year
const monthlyResult = await pool.query(
`
SELECT
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY EXTRACT(MONTH FROM alarm_time)
ORDER BY monat
`,
[targetYear]
);
// Monthly breakdown — previous year (for chart overlay)
const prevMonthlyResult = await pool.query(
`
SELECT
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY EXTRACT(MONTH FROM alarm_time)
ORDER BY monat
`,
[prevYear]
);
// By Einsatzart — target year
const byArtResult = await pool.query(
`
SELECT
einsatz_art,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY einsatz_art
ORDER BY anzahl DESC
`,
[targetYear]
);
// Determine most common Einsatzart
const haeufigste_art: EinsatzArt | null =
byArtResult.rows.length > 0 ? (byArtResult.rows[0].einsatz_art as EinsatzArt) : null;
const monthly: MonthlyStatRow[] = monthlyResult.rows.map((r) => ({
monat: r.monat,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
avg_dauer_min: r.avg_dauer_min !== null ? Number(r.avg_dauer_min) : null,
}));
const prev_year_monthly: MonthlyStatRow[] = prevMonthlyResult.rows.map((r) => ({
monat: r.monat,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
avg_dauer_min: r.avg_dauer_min !== null ? Number(r.avg_dauer_min) : null,
}));
const by_art: EinsatzArtStatRow[] = byArtResult.rows.map((r) => ({
einsatz_art: r.einsatz_art as EinsatzArt,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
}));
return {
jahr: targetYear,
gesamt: totals.gesamt ?? 0,
abgeschlossen: totals.abgeschlossen ?? 0,
aktiv: totals.aktiv ?? 0,
avg_hilfsfrist_min:
totals.avg_hilfsfrist_min !== null ? Number(totals.avg_hilfsfrist_min) : null,
haeufigste_art,
monthly,
by_art,
prev_year_monthly,
};
} catch (error) {
logger.error('Error fetching incident statistics', { error, year });
throw new Error('Failed to fetch incident statistics');
}
}
// -------------------------------------------------------------------------
// MATERIALIZED VIEW REFRESH
// -------------------------------------------------------------------------
/** Refresh the einsatz_statistik materialized view. Call after bulk operations. */
async refreshStatistikView(): Promise<void> {
try {
await pool.query('REFRESH MATERIALIZED VIEW CONCURRENTLY einsatz_statistik');
logger.info('einsatz_statistik materialized view refreshed');
} catch (error) {
// CONCURRENTLY requires a unique index — fall back to non-concurrent refresh
try {
await pool.query('REFRESH MATERIALIZED VIEW einsatz_statistik');
logger.info('einsatz_statistik materialized view refreshed (non-concurrent)');
} catch (fallbackError) {
logger.error('Error refreshing einsatz_statistik view', { fallbackError });
throw new Error('Failed to refresh statistics view');
}
}
}
}
export default new IncidentService();

View File

@@ -0,0 +1,594 @@
import pool from '../config/database';
import logger from '../utils/logger';
import {
MitgliederProfile,
MemberWithProfile,
MemberListItem,
MemberFilters,
MemberStats,
CreateMemberProfileData,
UpdateMemberProfileData,
DienstgradVerlaufEntry,
} from '../models/member.model';
class MemberService {
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
/**
* Builds the SELECT columns and JOIN for a full MemberWithProfile query.
* Returns raw pg rows that map to MemberWithProfile.
*/
private buildMemberSelectQuery(): string {
return `
SELECT
u.id,
u.email,
u.name,
u.given_name,
u.family_name,
u.preferred_username,
u.profile_picture_url,
u.is_active,
u.last_login_at,
u.created_at,
-- profile columns (aliased with mp_ prefix to avoid collision)
mp.id AS mp_id,
mp.user_id AS mp_user_id,
mp.mitglieds_nr AS mp_mitglieds_nr,
mp.dienstgrad AS mp_dienstgrad,
mp.dienstgrad_seit AS mp_dienstgrad_seit,
mp.funktion AS mp_funktion,
mp.status AS mp_status,
mp.eintrittsdatum AS mp_eintrittsdatum,
mp.austrittsdatum AS mp_austrittsdatum,
mp.geburtsdatum AS mp_geburtsdatum,
mp.telefon_mobil AS mp_telefon_mobil,
mp.telefon_privat AS mp_telefon_privat,
mp.notfallkontakt_name AS mp_notfallkontakt_name,
mp.notfallkontakt_telefon AS mp_notfallkontakt_telefon,
mp.fuehrerscheinklassen AS mp_fuehrerscheinklassen,
mp.tshirt_groesse AS mp_tshirt_groesse,
mp.schuhgroesse AS mp_schuhgroesse,
mp.bemerkungen AS mp_bemerkungen,
mp.bild_url AS mp_bild_url,
mp.created_at AS mp_created_at,
mp.updated_at AS mp_updated_at
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
`;
}
/**
* Maps a raw pg result row (with mp_ prefixed columns) into a
* MemberWithProfile object. Handles null profile gracefully.
*/
private mapRowToMemberWithProfile(row: any): MemberWithProfile {
const hasProfile = row.mp_id !== null;
return {
id: row.id,
email: row.email,
name: row.name,
given_name: row.given_name,
family_name: row.family_name,
preferred_username: row.preferred_username,
profile_picture_url: row.profile_picture_url,
is_active: row.is_active,
last_login_at: row.last_login_at,
created_at: row.created_at,
profile: hasProfile
? {
id: row.mp_id,
user_id: row.mp_user_id,
mitglieds_nr: row.mp_mitglieds_nr,
dienstgrad: row.mp_dienstgrad,
dienstgrad_seit: row.mp_dienstgrad_seit,
funktion: row.mp_funktion ?? [],
status: row.mp_status,
eintrittsdatum: row.mp_eintrittsdatum,
austrittsdatum: row.mp_austrittsdatum,
geburtsdatum: row.mp_geburtsdatum,
telefon_mobil: row.mp_telefon_mobil,
telefon_privat: row.mp_telefon_privat,
notfallkontakt_name: row.mp_notfallkontakt_name,
notfallkontakt_telefon: row.mp_notfallkontakt_telefon,
fuehrerscheinklassen: row.mp_fuehrerscheinklassen ?? [],
tshirt_groesse: row.mp_tshirt_groesse,
schuhgroesse: row.mp_schuhgroesse,
bemerkungen: row.mp_bemerkungen,
bild_url: row.mp_bild_url,
created_at: row.mp_created_at,
updated_at: row.mp_updated_at,
}
: null,
};
}
// ----------------------------------------------------------------
// Public API
// ----------------------------------------------------------------
/**
* Returns a paginated list of members with the minimal fields
* required by the list view. Supports free-text search and
* multi-value filter by status and dienstgrad.
*/
async getAllMembers(filters?: MemberFilters): Promise<{ items: MemberListItem[]; total: number }> {
try {
const {
search,
status,
dienstgrad,
page = 1,
pageSize = 25,
} = filters ?? {};
const conditions: string[] = ['u.is_active = TRUE'];
const values: any[] = [];
let paramIdx = 1;
if (search) {
conditions.push(`(
u.name ILIKE $${paramIdx}
OR u.email ILIKE $${paramIdx}
OR u.given_name ILIKE $${paramIdx}
OR u.family_name ILIKE $${paramIdx}
OR mp.mitglieds_nr ILIKE $${paramIdx}
)`);
values.push(`%${search}%`);
paramIdx++;
}
if (status && status.length > 0) {
conditions.push(`mp.status = ANY($${paramIdx}::VARCHAR[])`);
values.push(status);
paramIdx++;
}
if (dienstgrad && dienstgrad.length > 0) {
conditions.push(`mp.dienstgrad = ANY($${paramIdx}::VARCHAR[])`);
values.push(dienstgrad);
paramIdx++;
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const offset = (page - 1) * pageSize;
const dataQuery = `
SELECT
u.id,
u.name,
u.given_name,
u.family_name,
u.email,
u.profile_picture_url,
u.is_active,
mp.id AS profile_id,
mp.mitglieds_nr,
mp.dienstgrad,
mp.funktion,
mp.status,
mp.eintrittsdatum,
mp.telefon_mobil
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause}
ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`;
values.push(pageSize, offset);
const countQuery = `
SELECT COUNT(*)::INTEGER AS total
FROM users u
LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id
${whereClause}
`;
const [dataResult, countResult] = await Promise.all([
pool.query(dataQuery, values),
pool.query(countQuery, values.slice(0, values.length - 2)), // exclude LIMIT/OFFSET
]);
const items: MemberListItem[] = dataResult.rows.map((row) => ({
id: row.id,
name: row.name,
given_name: row.given_name,
family_name: row.family_name,
email: row.email,
profile_picture_url: row.profile_picture_url,
is_active: row.is_active,
profile_id: row.profile_id ?? null,
mitglieds_nr: row.mitglieds_nr ?? null,
dienstgrad: row.dienstgrad ?? null,
funktion: row.funktion ?? [],
status: row.status ?? null,
eintrittsdatum: row.eintrittsdatum ?? null,
telefon_mobil: row.telefon_mobil ?? null,
}));
logger.debug('getAllMembers', { count: items.length, filters });
return { items, total: countResult.rows[0].total };
} catch (error) {
logger.error('Error fetching member list', { error, filters });
throw new Error('Failed to fetch members');
}
}
/**
* Returns a single member with their full profile (including rank history).
* Returns null when the user does not exist.
*/
async getMemberById(userId: string): Promise<MemberWithProfile | null> {
try {
const query = `${this.buildMemberSelectQuery()} WHERE u.id = $1`;
const result = await pool.query(query, [userId]);
if (result.rows.length === 0) {
logger.debug('getMemberById: not found', { userId });
return null;
}
const member = this.mapRowToMemberWithProfile(result.rows[0]);
// Attach rank history when the profile exists
if (member.profile) {
member.dienstgrad_verlauf = await this.getDienstgradVerlauf(userId);
}
return member;
} catch (error) {
logger.error('Error fetching member by id', { error, userId });
throw new Error('Failed to fetch member');
}
}
/**
* Looks up a member by their Authentik OIDC subject identifier.
* Useful for looking up the currently logged-in user's own profile.
*/
async getMemberByAuthentikSub(sub: string): Promise<MemberWithProfile | null> {
try {
const query = `${this.buildMemberSelectQuery()} WHERE u.authentik_sub = $1`;
const result = await pool.query(query, [sub]);
if (result.rows.length === 0) return null;
const member = this.mapRowToMemberWithProfile(result.rows[0]);
if (member.profile) {
member.dienstgrad_verlauf = await this.getDienstgradVerlauf(member.id);
}
return member;
} catch (error) {
logger.error('Error fetching member by authentik sub', { error, sub });
throw new Error('Failed to fetch member');
}
}
/**
* Creates the mitglieder_profile row for an existing auth user.
* Throws if a profile already exists for this user_id.
*/
async createMemberProfile(
userId: string,
data: CreateMemberProfileData
): Promise<MitgliederProfile> {
try {
const query = `
INSERT INTO mitglieder_profile (
user_id,
mitglieds_nr,
dienstgrad,
dienstgrad_seit,
funktion,
status,
eintrittsdatum,
austrittsdatum,
geburtsdatum,
telefon_mobil,
telefon_privat,
notfallkontakt_name,
notfallkontakt_telefon,
fuehrerscheinklassen,
tshirt_groesse,
schuhgroesse,
bemerkungen,
bild_url
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14, $15, $16, $17, $18
)
RETURNING *
`;
const values = [
userId,
data.mitglieds_nr ?? null,
data.dienstgrad ?? null,
data.dienstgrad_seit ?? null,
data.funktion ?? [],
data.status ?? 'aktiv',
data.eintrittsdatum ?? null,
data.austrittsdatum ?? null,
data.geburtsdatum ?? null,
data.telefon_mobil ?? null,
data.telefon_privat ?? null,
data.notfallkontakt_name ?? null,
data.notfallkontakt_telefon ?? null,
data.fuehrerscheinklassen ?? [],
data.tshirt_groesse ?? null,
data.schuhgroesse ?? null,
data.bemerkungen ?? null,
data.bild_url ?? null,
];
const result = await pool.query(query, values);
const profile = result.rows[0] as MitgliederProfile;
// If a dienstgrad was set at creation, record it in history
if (data.dienstgrad) {
await this.writeDienstgradVerlauf(
userId,
data.dienstgrad,
null,
userId, // created-by = the user being set up (or override with system user)
'Initialer Dienstgrad bei Profilerstellung'
);
}
logger.info('Created mitglieder_profile', { userId, profileId: profile.id });
return profile;
} catch (error: any) {
if (error?.code === '23505') {
// unique_violation on user_id
throw new Error('Ein Profil für dieses Mitglied existiert bereits.');
}
logger.error('Error creating mitglieder_profile', { error, userId });
throw new Error('Failed to create member profile');
}
}
/**
* Partially updates the mitglieder_profile for the given user.
* Only fields present in `data` are written (undefined = untouched).
*
* Rank changes are handled separately via updateDienstgrad() to ensure
* the change is always logged in dienstgrad_verlauf. If `data.dienstgrad`
* is present it will be delegated to that method automatically.
*/
async updateMemberProfile(
userId: string,
data: UpdateMemberProfileData,
updatedBy: string
): Promise<MitgliederProfile> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Handle rank change through dedicated method to ensure audit log
const { dienstgrad, dienstgrad_seit, ...rest } = data;
if (dienstgrad !== undefined) {
await this.updateDienstgrad(userId, dienstgrad, updatedBy, dienstgrad_seit, client);
} else if (dienstgrad_seit !== undefined) {
// dienstgrad_seit can be updated independently
rest.dienstgrad_seit = dienstgrad_seit;
}
// Build dynamic SET clause for remaining fields
const updateFields: string[] = [];
const values: any[] = [];
let paramIdx = 1;
const fieldMap: Record<string, any> = {
mitglieds_nr: rest.mitglieds_nr,
funktion: rest.funktion,
status: rest.status,
eintrittsdatum: rest.eintrittsdatum,
austrittsdatum: rest.austrittsdatum,
geburtsdatum: rest.geburtsdatum,
telefon_mobil: rest.telefon_mobil,
telefon_privat: rest.telefon_privat,
notfallkontakt_name: rest.notfallkontakt_name,
notfallkontakt_telefon: rest.notfallkontakt_telefon,
fuehrerscheinklassen: rest.fuehrerscheinklassen,
tshirt_groesse: rest.tshirt_groesse,
schuhgroesse: rest.schuhgroesse,
bemerkungen: rest.bemerkungen,
bild_url: rest.bild_url,
dienstgrad_seit: (rest as any).dienstgrad_seit,
};
for (const [column, value] of Object.entries(fieldMap)) {
if (value !== undefined) {
updateFields.push(`${column} = $${paramIdx++}`);
values.push(value);
}
}
let profile: MitgliederProfile;
if (updateFields.length > 0) {
values.push(userId);
const query = `
UPDATE mitglieder_profile
SET ${updateFields.join(', ')}
WHERE user_id = $${paramIdx}
RETURNING *
`;
const result = await client.query(query, values);
if (result.rows.length === 0) {
throw new Error('Mitgliedsprofil nicht gefunden.');
}
profile = result.rows[0] as MitgliederProfile;
} else {
// Nothing to update (rank change only) — fetch current state
const result = await client.query(
'SELECT * FROM mitglieder_profile WHERE user_id = $1',
[userId]
);
if (result.rows.length === 0) throw new Error('Mitgliedsprofil nicht gefunden.');
profile = result.rows[0] as MitgliederProfile;
}
await client.query('COMMIT');
logger.info('Updated mitglieder_profile', { userId, updatedBy });
return profile;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error updating mitglieder_profile', { error, userId });
throw error instanceof Error ? error : new Error('Failed to update member profile');
} finally {
client.release();
}
}
/**
* Sets a new Dienstgrad and writes an entry to dienstgrad_verlauf.
* Can accept an optional pg PoolClient to participate in an outer transaction.
*/
async updateDienstgrad(
userId: string,
newDienstgrad: string,
changedBy: string,
since?: Date,
existingClient?: any
): Promise<void> {
const executor = existingClient ?? pool;
try {
// Fetch current rank for the history entry
const currentResult = await executor.query(
'SELECT dienstgrad FROM mitglieder_profile WHERE user_id = $1',
[userId]
);
const oldDienstgrad: string | null =
currentResult.rows.length > 0 ? currentResult.rows[0].dienstgrad : null;
// Update the profile
await executor.query(
`UPDATE mitglieder_profile
SET dienstgrad = $1, dienstgrad_seit = $2
WHERE user_id = $3`,
[newDienstgrad, since ?? new Date(), userId]
);
// Write audit entry
await this.writeDienstgradVerlauf(
userId,
newDienstgrad,
oldDienstgrad,
changedBy,
null,
existingClient
);
logger.info('Updated Dienstgrad', { userId, oldDienstgrad, newDienstgrad, changedBy });
} catch (error) {
logger.error('Error updating Dienstgrad', { error, userId, newDienstgrad });
throw new Error('Failed to update Dienstgrad');
}
}
/**
* Internal helper: inserts one row into dienstgrad_verlauf.
*/
private async writeDienstgradVerlauf(
userId: string,
dienstgradNeu: string,
dienstgradAlt: string | null,
durchUserId: string | null,
bemerkung: string | null = null,
existingClient?: any
): Promise<void> {
const executor = existingClient ?? pool;
await executor.query(
`INSERT INTO dienstgrad_verlauf
(user_id, dienstgrad_neu, dienstgrad_alt, durch_user_id, bemerkung)
VALUES ($1, $2, $3, $4, $5)`,
[userId, dienstgradNeu, dienstgradAlt, durchUserId, bemerkung]
);
}
/**
* Fetches the rank change history for a member, newest first.
*/
async getDienstgradVerlauf(userId: string): Promise<DienstgradVerlaufEntry[]> {
try {
const result = await pool.query(
`SELECT
dv.*,
u.name AS durch_user_name
FROM dienstgrad_verlauf dv
LEFT JOIN users u ON u.id = dv.durch_user_id
WHERE dv.user_id = $1
ORDER BY dv.datum DESC, dv.created_at DESC`,
[userId]
);
return result.rows as DienstgradVerlaufEntry[];
} catch (error) {
logger.error('Error fetching Dienstgrad history', { error, userId });
return [];
}
}
/**
* Returns aggregate member counts, used by the dashboard KPI cards.
* Optionally scoped to a single status value.
*/
async getMemberCount(status?: string): Promise<number> {
try {
let query: string;
let values: any[];
if (status) {
query = `
SELECT COUNT(*)::INTEGER AS count
FROM mitglieder_profile
WHERE status = $1
`;
values = [status];
} else {
query = `SELECT COUNT(*)::INTEGER AS count FROM mitglieder_profile`;
values = [];
}
const result = await pool.query(query, values);
return result.rows[0].count;
} catch (error) {
logger.error('Error counting members', { error, status });
throw new Error('Failed to count members');
}
}
/**
* Returns a full stats breakdown for all statuses (dashboard KPI widget).
*/
async getMemberStats(): Promise<MemberStats> {
try {
const result = await pool.query(`
SELECT
COUNT(*)::INTEGER AS total,
COUNT(*) FILTER (WHERE status = 'aktiv')::INTEGER AS aktiv,
COUNT(*) FILTER (WHERE status = 'passiv')::INTEGER AS passiv,
COUNT(*) FILTER (WHERE status = 'ehrenmitglied')::INTEGER AS ehrenmitglied,
COUNT(*) FILTER (WHERE status = 'jugendfeuerwehr')::INTEGER AS jugendfeuerwehr,
COUNT(*) FILTER (WHERE status = 'anwärter')::INTEGER AS "anwärter",
COUNT(*) FILTER (WHERE status = 'ausgetreten')::INTEGER AS ausgetreten
FROM mitglieder_profile
`);
return result.rows[0] as MemberStats;
} catch (error) {
logger.error('Error fetching member stats', { error });
throw new Error('Failed to fetch member stats');
}
}
}
export default new MemberService();

View File

@@ -0,0 +1,614 @@
import { randomBytes } from 'crypto';
import pool from '../config/database';
import logger from '../utils/logger';
import {
Uebung,
UebungWithAttendance,
UebungListItem,
MemberParticipationStats,
CreateUebungData,
UpdateUebungData,
TeilnahmeStatus,
Teilnahme,
} from '../models/training.model';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Columns used in all SELECT queries to hydrate a full Uebung row */
const UEBUNG_COLUMNS = `
u.id, u.titel, u.beschreibung, u.typ::text AS typ,
u.datum_von, u.datum_bis, u.ort, u.treffpunkt,
u.pflichtveranstaltung, u.mindest_teilnehmer, u.max_teilnehmer,
u.angelegt_von, u.erstellt_am, u.aktualisiert_am,
u.abgesagt, u.absage_grund
`;
const ATTENDANCE_COUNT_COLUMNS = `
COUNT(t.user_id) AS gesamt_eingeladen,
COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen,
COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt,
COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt
`;
/** Map a raw DB row to UebungListItem, optionally including eigener_status */
function rowToListItem(row: any, eigenerStatus?: TeilnahmeStatus): UebungListItem {
return {
id: row.id,
titel: row.titel,
typ: row.typ,
datum_von: new Date(row.datum_von),
datum_bis: new Date(row.datum_bis),
ort: row.ort ?? null,
pflichtveranstaltung: row.pflichtveranstaltung,
abgesagt: row.abgesagt,
anzahl_zugesagt: Number(row.anzahl_zugesagt ?? 0),
anzahl_erschienen: Number(row.anzahl_erschienen ?? 0),
gesamt_eingeladen: Number(row.gesamt_eingeladen ?? 0),
eigener_status: eigenerStatus ?? row.eigener_status ?? undefined,
};
}
function rowToUebung(row: any): Uebung {
return {
id: row.id,
titel: row.titel,
beschreibung: row.beschreibung ?? null,
typ: row.typ,
datum_von: new Date(row.datum_von),
datum_bis: new Date(row.datum_bis),
ort: row.ort ?? null,
treffpunkt: row.treffpunkt ?? null,
pflichtveranstaltung: row.pflichtveranstaltung,
mindest_teilnehmer: row.mindest_teilnehmer ?? null,
max_teilnehmer: row.max_teilnehmer ?? null,
angelegt_von: row.angelegt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
abgesagt: row.abgesagt,
absage_grund: row.absage_grund ?? null,
};
}
// ---------------------------------------------------------------------------
// Training Service
// ---------------------------------------------------------------------------
class TrainingService {
/**
* Returns upcoming (future) events sorted ascending by datum_von.
* Used by the dashboard widget and the list view.
*/
async getUpcomingEvents(limit = 10, userId?: string): Promise<UebungListItem[]> {
const query = `
SELECT
${UEBUNG_COLUMNS},
${ATTENDANCE_COUNT_COLUMNS}
${userId ? `, own_t.status::text AS eigener_status` : ''}
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $2` : ''}
WHERE u.datum_von > NOW()
AND u.abgesagt = FALSE
GROUP BY u.id ${userId ? `, own_t.status` : ''}
ORDER BY u.datum_von ASC
LIMIT $1
`;
const values = userId ? [limit, userId] : [limit];
const result = await pool.query(query, values);
return result.rows.map((r) => rowToListItem(r));
}
/**
* Returns all events within a date range (inclusive) for the calendar view.
* Does NOT filter out cancelled events — the frontend shows them struck through.
*/
async getEventsByDateRange(from: Date, to: Date, userId?: string): Promise<UebungListItem[]> {
const query = `
SELECT
${UEBUNG_COLUMNS},
${ATTENDANCE_COUNT_COLUMNS}
${userId ? `, own_t.status::text AS eigener_status` : ''}
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $3` : ''}
WHERE u.datum_von >= $1
AND u.datum_von <= $2
GROUP BY u.id ${userId ? `, own_t.status` : ''}
ORDER BY u.datum_von ASC
`;
const values = userId ? [from, to, userId] : [from, to];
const result = await pool.query(query, values);
return result.rows.map((r) => rowToListItem(r));
}
/**
* Returns the full event detail including attendance counts and, for
* privileged users, the individual attendee list.
*/
async getEventById(
id: string,
userId?: string,
includeTeilnahmen = false
): Promise<UebungWithAttendance | null> {
const eventQuery = `
SELECT
${UEBUNG_COLUMNS},
${ATTENDANCE_COUNT_COLUMNS},
creator.name AS angelegt_von_name
${userId ? `, own_t.status::text AS eigener_status` : ''}
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
LEFT JOIN users creator ON creator.id = u.angelegt_von
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $2` : ''}
WHERE u.id = $1
GROUP BY u.id, creator.name ${userId ? `, own_t.status` : ''}
`;
const values = userId ? [id, userId] : [id];
const eventResult = await pool.query(eventQuery, values);
if (eventResult.rows.length === 0) return null;
const row = eventResult.rows[0];
const uebung = rowToUebung(row);
const result: UebungWithAttendance = {
...uebung,
gesamt_eingeladen: Number(row.gesamt_eingeladen ?? 0),
anzahl_zugesagt: Number(row.anzahl_zugesagt ?? 0),
anzahl_abgesagt: Number(row.anzahl_abgesagt ?? 0),
anzahl_erschienen: Number(row.anzahl_erschienen ?? 0),
anzahl_entschuldigt: Number(row.anzahl_entschuldigt ?? 0),
anzahl_unbekannt: Number(row.anzahl_unbekannt ?? 0),
angelegt_von_name: row.angelegt_von_name ?? null,
eigener_status: row.eigener_status ?? undefined,
};
if (includeTeilnahmen) {
const teilnahmenQuery = `
SELECT
t.uebung_id,
t.user_id,
t.status::text AS status,
t.antwort_am,
t.erschienen_erfasst_am,
t.erschienen_erfasst_von,
t.bemerkung,
COALESCE(u.name, u.preferred_username, u.email) AS user_name,
u.email AS user_email
FROM uebung_teilnahmen t
JOIN users u ON u.id = t.user_id
WHERE t.uebung_id = $1
ORDER BY u.name ASC NULLS LAST
`;
const teilnahmenResult = await pool.query(teilnahmenQuery, [id]);
result.teilnahmen = teilnahmenResult.rows as Teilnahme[];
}
return result;
}
/**
* Creates a new training event.
* The database trigger automatically creates 'unbekannt' teilnahmen
* rows for all active members.
*/
async createEvent(data: CreateUebungData, createdBy: string): Promise<Uebung> {
const query = `
INSERT INTO uebungen (
titel, beschreibung, typ, datum_von, datum_bis, ort, treffpunkt,
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer, angelegt_von
)
VALUES ($1, $2, $3::uebung_typ, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING
id, titel, beschreibung, typ::text AS typ,
datum_von, datum_bis, ort, treffpunkt,
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer,
angelegt_von, erstellt_am, aktualisiert_am,
abgesagt, absage_grund
`;
const values = [
data.titel,
data.beschreibung ?? null,
data.typ,
data.datum_von,
data.datum_bis,
data.ort ?? null,
data.treffpunkt ?? null,
data.pflichtveranstaltung,
data.mindest_teilnehmer ?? null,
data.max_teilnehmer ?? null,
createdBy,
];
const result = await pool.query(query, values);
const event = rowToUebung(result.rows[0]);
logger.info('Training event created', {
eventId: event.id,
titel: event.titel,
typ: event.typ,
datum_von: event.datum_von,
createdBy,
});
return event;
}
/**
* Updates mutable fields of an existing event.
* Only provided fields are updated (partial update semantics).
*/
async updateEvent(
id: string,
data: UpdateUebungData,
_updatedBy: string
): Promise<Uebung> {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const add = (col: string, val: unknown, cast = '') => {
fields.push(`${col} = $${p++}${cast}`);
values.push(val);
};
if (data.titel !== undefined) add('titel', data.titel);
if (data.beschreibung !== undefined) add('beschreibung', data.beschreibung);
if (data.typ !== undefined) add('typ', data.typ, '::uebung_typ');
if (data.datum_von !== undefined) add('datum_von', data.datum_von);
if (data.datum_bis !== undefined) add('datum_bis', data.datum_bis);
if (data.ort !== undefined) add('ort', data.ort);
if (data.treffpunkt !== undefined) add('treffpunkt', data.treffpunkt);
if (data.pflichtveranstaltung !== undefined) add('pflichtveranstaltung', data.pflichtveranstaltung);
if (data.mindest_teilnehmer !== undefined) add('mindest_teilnehmer', data.mindest_teilnehmer);
if (data.max_teilnehmer !== undefined) add('max_teilnehmer', data.max_teilnehmer);
if (fields.length === 0) {
// Nothing to update — return existing event
const existing = await this.getEventById(id);
if (!existing) throw new Error('Event not found');
return existing;
}
values.push(id);
const query = `
UPDATE uebungen
SET ${fields.join(', ')}
WHERE id = $${p}
RETURNING
id, titel, beschreibung, typ::text AS typ,
datum_von, datum_bis, ort, treffpunkt,
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer,
angelegt_von, erstellt_am, aktualisiert_am,
abgesagt, absage_grund
`;
const result = await pool.query(query, values);
if (result.rows.length === 0) throw new Error('Event not found');
logger.info('Training event updated', { eventId: id, updatedBy: _updatedBy });
return rowToUebung(result.rows[0]);
}
/**
* Soft-cancels an event. Sets abgesagt=true and records the reason.
* Does NOT delete the event or its attendance rows.
*/
async cancelEvent(id: string, reason: string, updatedBy: string): Promise<void> {
const result = await pool.query(
`UPDATE uebungen
SET abgesagt = TRUE, absage_grund = $2
WHERE id = $1
RETURNING id`,
[id, reason]
);
if (result.rows.length === 0) throw new Error('Event not found');
logger.info('Training event cancelled', { eventId: id, updatedBy, reason });
}
/**
* Member updates their own RSVP — only 'zugesagt' or 'abgesagt' allowed here.
* Sets antwort_am to now.
*/
async updateAttendanceRSVP(
uebungId: string,
userId: string,
status: 'zugesagt' | 'abgesagt',
bemerkung?: string | null
): Promise<void> {
const result = await pool.query(
`UPDATE uebung_teilnahmen
SET status = $3::teilnahme_status,
antwort_am = NOW(),
bemerkung = COALESCE($4, bemerkung)
WHERE uebung_id = $1 AND user_id = $2
RETURNING uebung_id`,
[uebungId, userId, status, bemerkung ?? null]
);
if (result.rows.length === 0) {
// Row might not exist if member joined after event was created — insert it
await pool.query(
`INSERT INTO uebung_teilnahmen (uebung_id, user_id, status, antwort_am, bemerkung)
VALUES ($1, $2, $3::teilnahme_status, NOW(), $4)
ON CONFLICT (uebung_id, user_id) DO UPDATE
SET status = EXCLUDED.status,
antwort_am = EXCLUDED.antwort_am,
bemerkung = COALESCE(EXCLUDED.bemerkung, uebung_teilnahmen.bemerkung)`,
[uebungId, userId, status, bemerkung ?? null]
);
}
logger.info('RSVP updated', { uebungId, userId, status });
}
/**
* Gruppenführer / Kommandant bulk-marks members as 'erschienen'.
* Marks erschienen_erfasst_am and erschienen_erfasst_von.
*/
async markAttendance(
uebungId: string,
userIds: string[],
markedBy: string
): Promise<void> {
if (userIds.length === 0) return;
// Build parameterized IN clause: $3, $4, $5, ...
const placeholders = userIds.map((_, i) => `$${i + 3}`).join(', ');
await pool.query(
`UPDATE uebung_teilnahmen
SET status = 'erschienen'::teilnahme_status,
erschienen_erfasst_am = NOW(),
erschienen_erfasst_von = $2
WHERE uebung_id = $1
AND user_id IN (${placeholders})`,
[uebungId, markedBy, ...userIds]
);
logger.info('Bulk attendance marked', {
uebungId,
count: userIds.length,
markedBy,
});
}
/**
* Annual participation statistics for all active members.
* Filters to events within the given calendar year.
* "unbekannt" responses are NOT treated as absent.
*/
async getMemberParticipationStats(year: number): Promise<MemberParticipationStats[]> {
const query = `
SELECT
usr.id AS user_id,
COALESCE(usr.name, usr.preferred_username, usr.email) AS name,
COUNT(t.uebung_id) AS total_uebungen,
COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS attended,
COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt,
COUNT(t.uebung_id) FILTER (
WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen'
) AS pflicht_erschienen,
ROUND(
CASE
WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0
ELSE
COUNT(t.uebung_id) FILTER (
WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen'
)::NUMERIC /
COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100
END, 1
) AS uebungsabend_quote_pct
FROM users usr
JOIN uebung_teilnahmen t ON t.user_id = usr.id
JOIN uebungen u ON u.id = t.uebung_id
WHERE usr.is_active = TRUE
AND u.abgesagt = FALSE
AND EXTRACT(YEAR FROM u.datum_von) = $1
GROUP BY usr.id, usr.name, usr.preferred_username, usr.email
ORDER BY name ASC
`;
const result = await pool.query(query, [year]);
return result.rows.map((r) => ({
userId: r.user_id,
name: r.name,
totalUebungen: Number(r.total_uebungen),
attended: Number(r.attended),
attendancePercent:
Number(r.total_uebungen) === 0
? 0
: Math.round((Number(r.attended) / Number(r.total_uebungen)) * 1000) / 10,
pflichtGesamt: Number(r.pflicht_gesamt),
pflichtErschienen: Number(r.pflicht_erschienen),
uebungsabendQuotePct: Number(r.uebungsabend_quote_pct),
}));
}
// ---------------------------------------------------------------------------
// iCal token management
// ---------------------------------------------------------------------------
/**
* Returns the existing calendar token for a user, or creates a new one.
* Tokens are 32-byte hex strings (URL-safe).
*/
async getOrCreateCalendarToken(userId: string): Promise<string> {
const existing = await pool.query(
`SELECT token FROM calendar_tokens WHERE user_id = $1`,
[userId]
);
if (existing.rows.length > 0) return existing.rows[0].token;
const token = randomBytes(32).toString('hex');
await pool.query(
`INSERT INTO calendar_tokens (user_id, token) VALUES ($1, $2)`,
[userId, token]
);
return token;
}
/**
* Looks up the userId associated with a calendar token and
* touches last_used_at.
*/
async resolveCalendarToken(token: string): Promise<string | null> {
const result = await pool.query(
`UPDATE calendar_tokens
SET last_used_at = NOW()
WHERE token = $1
RETURNING user_id`,
[token]
);
return result.rows[0]?.user_id ?? null;
}
/**
* Generates iCal content for the given user (or public feed for all events
* when userId is undefined).
*/
async getCalendarExport(userId?: string): Promise<string> {
// Fetch events for the next 12 months + past 3 months
const from = new Date();
from.setMonth(from.getMonth() - 3);
const to = new Date();
to.setFullYear(to.getFullYear() + 1);
const events = await this.getEventsByDateRange(from, to, userId);
return generateICS(events, 'Feuerwehr Rems');
}
}
// ---------------------------------------------------------------------------
// iCal generation — zero-dependency RFC 5545 implementation
// ---------------------------------------------------------------------------
/**
* Formats a Date to the iCal DTSTART/DTEND format with UTC timezone.
* Output: 20260304T190000Z
*/
function formatIcsDate(date: Date): string {
return date
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
}
/**
* Folds long iCal lines at 75 octets (RFC 5545 §3.1).
* Continuation lines start with a single space.
*/
function foldLine(line: string): string {
const MAX = 75;
if (line.length <= MAX) return line;
let result = '';
while (line.length > MAX) {
result += line.substring(0, MAX) + '\r\n ';
line = line.substring(MAX);
}
result += line;
return result;
}
/**
* Escapes text field values per RFC 5545 §3.3.11.
*/
function escapeIcsText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
.replace(/\r/g, '');
}
/** Maps UebungTyp to a human-readable category string */
const TYP_CATEGORY: Record<string, string> = {
'Übungsabend': 'Training',
'Lehrgang': 'Course',
'Sonderdienst': 'Special Duty',
'Versammlung': 'Meeting',
'Gemeinschaftsübung': 'Joint Exercise',
'Sonstiges': 'Other',
};
export function generateICS(
events: Array<{
id: string;
titel: string;
beschreibung?: string | null;
typ: string;
datum_von: Date;
datum_bis: Date;
ort?: string | null;
pflichtveranstaltung: boolean;
abgesagt: boolean;
}>,
organizerName: string
): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
`PRODID:-//Feuerwehr Rems//Dashboard//DE`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${escapeIcsText(organizerName)} - Dienstkalender`,
'X-WR-TIMEZONE:Europe/Vienna',
'X-WR-CALDESC:Übungs- und Dienstkalender der Feuerwehr Rems',
];
const stampNow = formatIcsDate(new Date());
for (const event of events) {
const summary = event.abgesagt
? `[ABGESAGT] ${event.titel}`
: event.pflichtveranstaltung
? `* ${event.titel}`
: event.titel;
const descParts: string[] = [];
if (event.beschreibung) descParts.push(event.beschreibung);
if (event.pflichtveranstaltung) descParts.push('PFLICHTVERANSTALTUNG');
if (event.abgesagt) descParts.push('Diese Veranstaltung wurde abgesagt.');
descParts.push(`Typ: ${event.typ}`);
lines.push('BEGIN:VEVENT');
lines.push(foldLine(`UID:${event.id}@feuerwehr-rems.at`));
lines.push(`DTSTAMP:${stampNow}`);
lines.push(`DTSTART:${formatIcsDate(event.datum_von)}`);
lines.push(`DTEND:${formatIcsDate(event.datum_bis)}`);
lines.push(foldLine(`SUMMARY:${escapeIcsText(summary)}`));
if (descParts.length > 0) {
lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\\n'))}`));
}
if (event.ort) {
lines.push(foldLine(`LOCATION:${escapeIcsText(event.ort)}`));
}
lines.push(`CATEGORIES:${TYP_CATEGORY[event.typ] ?? 'Other'}`);
if (event.abgesagt) {
lines.push('STATUS:CANCELLED');
}
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
return lines.join('\r\n') + '\r\n';
}
export default new TrainingService();

View File

@@ -0,0 +1,572 @@
import { Server as SocketIOServer } from 'socket.io';
import pool from '../config/database';
import logger from '../utils/logger';
import {
Fahrzeug,
FahrzeugListItem,
FahrzeugWithPruefstatus,
FahrzeugPruefung,
FahrzeugWartungslog,
CreateFahrzeugData,
UpdateFahrzeugData,
CreatePruefungData,
CreateWartungslogData,
FahrzeugStatus,
PruefungArt,
PruefungIntervalMonths,
VehicleStats,
InspectionAlert,
} from '../models/vehicle.model';
// ---------------------------------------------------------------------------
// Helper: add N months to a Date (handles month-end edge cases)
// ---------------------------------------------------------------------------
function addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
// ---------------------------------------------------------------------------
// Helper: map a flat view row to PruefungStatus sub-object
// ---------------------------------------------------------------------------
function mapPruefungStatus(row: any, prefix: string) {
return {
pruefung_id: row[`${prefix}_pruefung_id`] ?? null,
faellig_am: row[`${prefix}_faellig_am`] ?? null,
tage_bis_faelligkeit: row[`${prefix}_tage_bis_faelligkeit`] != null
? parseInt(row[`${prefix}_tage_bis_faelligkeit`], 10)
: null,
ergebnis: row[`${prefix}_ergebnis`] ?? null,
};
}
class VehicleService {
// =========================================================================
// FLEET OVERVIEW
// =========================================================================
/**
* Returns all vehicles with their next-due inspection dates per type.
* Used by the fleet overview grid (FahrzeugListItem[]).
*/
async getAllVehicles(): Promise<FahrzeugListItem[]> {
try {
const result = await pool.query(`
SELECT
id,
bezeichnung,
kurzname,
amtliches_kennzeichen,
baujahr,
hersteller,
besatzung_soll,
status,
status_bemerkung,
bild_url,
hu_faellig_am,
hu_tage_bis_faelligkeit,
au_faellig_am,
au_tage_bis_faelligkeit,
uvv_faellig_am,
uvv_tage_bis_faelligkeit,
leiter_faellig_am,
leiter_tage_bis_faelligkeit,
naechste_pruefung_tage
FROM fahrzeuge_mit_pruefstatus
ORDER BY bezeichnung ASC
`);
return result.rows.map((row) => ({
...row,
hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null
? parseInt(row.hu_tage_bis_faelligkeit, 10) : null,
au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null
? parseInt(row.au_tage_bis_faelligkeit, 10) : null,
uvv_tage_bis_faelligkeit: row.uvv_tage_bis_faelligkeit != null
? parseInt(row.uvv_tage_bis_faelligkeit, 10) : null,
leiter_tage_bis_faelligkeit: row.leiter_tage_bis_faelligkeit != null
? parseInt(row.leiter_tage_bis_faelligkeit, 10) : null,
naechste_pruefung_tage: row.naechste_pruefung_tage != null
? parseInt(row.naechste_pruefung_tage, 10) : null,
})) as FahrzeugListItem[];
} catch (error) {
logger.error('VehicleService.getAllVehicles failed', { error });
throw new Error('Failed to fetch vehicles');
}
}
// =========================================================================
// VEHICLE DETAIL
// =========================================================================
/**
* Returns a single vehicle with full pruefstatus, inspection history,
* and maintenance log.
*/
async getVehicleById(id: string): Promise<FahrzeugWithPruefstatus | null> {
try {
// 1) Main record + inspection status from view
const vehicleResult = await pool.query(
`SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`,
[id]
);
if (vehicleResult.rows.length === 0) return null;
const row = vehicleResult.rows[0];
// 2) Full inspection history
const pruefungenResult = await pool.query(
`SELECT * FROM fahrzeug_pruefungen
WHERE fahrzeug_id = $1
ORDER BY faellig_am DESC, created_at DESC`,
[id]
);
// 3) Maintenance log
const wartungslogResult = await pool.query(
`SELECT * FROM fahrzeug_wartungslog
WHERE fahrzeug_id = $1
ORDER BY datum DESC, created_at DESC`,
[id]
);
const vehicle: FahrzeugWithPruefstatus = {
id: row.id,
bezeichnung: row.bezeichnung,
kurzname: row.kurzname,
amtliches_kennzeichen: row.amtliches_kennzeichen,
fahrgestellnummer: row.fahrgestellnummer,
baujahr: row.baujahr,
hersteller: row.hersteller,
typ_schluessel: row.typ_schluessel,
besatzung_soll: row.besatzung_soll,
status: row.status as FahrzeugStatus,
status_bemerkung: row.status_bemerkung,
standort: row.standort,
bild_url: row.bild_url,
created_at: row.created_at,
updated_at: row.updated_at,
pruefstatus: {
hu: mapPruefungStatus(row, 'hu'),
au: mapPruefungStatus(row, 'au'),
uvv: mapPruefungStatus(row, 'uvv'),
leiter: mapPruefungStatus(row, 'leiter'),
},
naechste_pruefung_tage: row.naechste_pruefung_tage != null
? parseInt(row.naechste_pruefung_tage, 10) : null,
pruefungen: pruefungenResult.rows as FahrzeugPruefung[],
wartungslog: wartungslogResult.rows as FahrzeugWartungslog[],
};
return vehicle;
} catch (error) {
logger.error('VehicleService.getVehicleById failed', { error, id });
throw new Error('Failed to fetch vehicle');
}
}
// =========================================================================
// CRUD
// =========================================================================
async createVehicle(
data: CreateFahrzeugData,
createdBy: string
): Promise<Fahrzeug> {
try {
const result = await pool.query(
`INSERT INTO fahrzeuge (
bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer,
baujahr, hersteller, typ_schluessel, besatzung_soll,
status, status_bemerkung, standort, bild_url
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`,
[
data.bezeichnung,
data.kurzname ?? null,
data.amtliches_kennzeichen ?? null,
data.fahrgestellnummer ?? null,
data.baujahr ?? null,
data.hersteller ?? null,
data.typ_schluessel ?? null,
data.besatzung_soll ?? null,
data.status ?? FahrzeugStatus.Einsatzbereit,
data.status_bemerkung ?? null,
data.standort ?? 'Feuerwehrhaus',
data.bild_url ?? null,
]
);
const vehicle = result.rows[0] as Fahrzeug;
logger.info('Vehicle created', { id: vehicle.id, by: createdBy });
return vehicle;
} catch (error) {
logger.error('VehicleService.createVehicle failed', { error, createdBy });
throw new Error('Failed to create vehicle');
}
}
async updateVehicle(
id: string,
data: UpdateFahrzeugData,
updatedBy: string
): Promise<Fahrzeug> {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const addField = (col: string, value: unknown) => {
fields.push(`${col} = $${p++}`);
values.push(value);
};
if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung);
if (data.kurzname !== undefined) addField('kurzname', data.kurzname);
if (data.amtliches_kennzeichen !== undefined) addField('amtliches_kennzeichen', data.amtliches_kennzeichen);
if (data.fahrgestellnummer !== undefined) addField('fahrgestellnummer', data.fahrgestellnummer);
if (data.baujahr !== undefined) addField('baujahr', data.baujahr);
if (data.hersteller !== undefined) addField('hersteller', data.hersteller);
if (data.typ_schluessel !== undefined) addField('typ_schluessel', data.typ_schluessel);
if (data.besatzung_soll !== undefined) addField('besatzung_soll', data.besatzung_soll);
if (data.status !== undefined) addField('status', data.status);
if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung);
if (data.standort !== undefined) addField('standort', data.standort);
if (data.bild_url !== undefined) addField('bild_url', data.bild_url);
if (fields.length === 0) {
throw new Error('No fields to update');
}
values.push(id); // for WHERE clause
const result = await pool.query(
`UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`,
values
);
if (result.rows.length === 0) {
throw new Error('Vehicle not found');
}
const vehicle = result.rows[0] as Fahrzeug;
logger.info('Vehicle updated', { id, by: updatedBy });
return vehicle;
} catch (error) {
logger.error('VehicleService.updateVehicle failed', { error, id, updatedBy });
throw error;
}
}
// =========================================================================
// STATUS MANAGEMENT
// Socket.io-ready: accepts optional `io` parameter.
// In Tier 3, pass the real Socket.IO server instance here.
// The endpoint contract is: PATCH /api/vehicles/:id/status
// =========================================================================
/**
* Updates vehicle status and optionally broadcasts a Socket.IO event.
*
* Socket.IO integration (Tier 3):
* Pass the live `io` instance from server.ts. When provided, emits:
* event: 'vehicle:statusChanged'
* payload: { vehicleId, bezeichnung, oldStatus, newStatus, bemerkung, updatedBy, timestamp }
* All connected clients on the default namespace receive the update immediately.
*
* @param io - Optional Socket.IO server instance (injected from app layer in Tier 3)
*/
async updateVehicleStatus(
id: string,
status: FahrzeugStatus,
bemerkung: string,
updatedBy: string,
io?: SocketIOServer
): Promise<void> {
try {
// Fetch old status for Socket.IO payload and logging
const oldResult = await pool.query(
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1`,
[id]
);
if (oldResult.rows.length === 0) {
throw new Error('Vehicle not found');
}
const { bezeichnung, status: oldStatus } = oldResult.rows[0];
await pool.query(
`UPDATE fahrzeuge
SET status = $1, status_bemerkung = $2
WHERE id = $3`,
[status, bemerkung || null, id]
);
logger.info('Vehicle status updated', {
id,
from: oldStatus,
to: status,
by: updatedBy,
});
// ── Socket.IO broadcast (Tier 3 integration point) ──────────────────
// When `io` is provided (Tier 3), broadcast the status change to all
// connected dashboard clients so the live status board updates in real time.
if (io) {
const payload = {
vehicleId: id,
bezeichnung,
oldStatus,
newStatus: status,
bemerkung: bemerkung || null,
updatedBy,
timestamp: new Date().toISOString(),
};
io.emit('vehicle:statusChanged', payload);
logger.debug('Emitted vehicle:statusChanged via Socket.IO', { vehicleId: id });
}
} catch (error) {
logger.error('VehicleService.updateVehicleStatus failed', { error, id });
throw error;
}
}
// =========================================================================
// INSPECTIONS
// =========================================================================
/**
* Records a new inspection entry.
* Automatically calculates naechste_faelligkeit based on standard intervals
* when durchgefuehrt_am is provided and the art has a known interval.
*/
async addPruefung(
fahrzeugId: string,
data: CreatePruefungData,
createdBy: string
): Promise<FahrzeugPruefung> {
try {
// Auto-calculate naechste_faelligkeit
let naechsteFaelligkeit: string | null = null;
if (data.durchgefuehrt_am) {
const intervalMonths = PruefungIntervalMonths[data.pruefung_art];
if (intervalMonths !== undefined) {
const durchgefuehrt = new Date(data.durchgefuehrt_am);
naechsteFaelligkeit = addMonths(durchgefuehrt, intervalMonths)
.toISOString()
.split('T')[0];
}
}
const result = await pool.query(
`INSERT INTO fahrzeug_pruefungen (
fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am,
ergebnis, naechste_faelligkeit, pruefende_stelle,
kosten, dokument_url, bemerkung, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
fahrzeugId,
data.pruefung_art,
data.faellig_am,
data.durchgefuehrt_am ?? null,
data.ergebnis ?? 'ausstehend',
naechsteFaelligkeit,
data.pruefende_stelle ?? null,
data.kosten ?? null,
data.dokument_url ?? null,
data.bemerkung ?? null,
createdBy,
]
);
const pruefung = result.rows[0] as FahrzeugPruefung;
logger.info('Pruefung added', {
pruefungId: pruefung.id,
fahrzeugId,
art: data.pruefung_art,
by: createdBy,
});
return pruefung;
} catch (error) {
logger.error('VehicleService.addPruefung failed', { error, fahrzeugId });
throw new Error('Failed to add inspection record');
}
}
/**
* Returns the full inspection history for a specific vehicle,
* ordered newest-first.
*/
async getPruefungenForVehicle(fahrzeugId: string): Promise<FahrzeugPruefung[]> {
try {
const result = await pool.query(
`SELECT * FROM fahrzeug_pruefungen
WHERE fahrzeug_id = $1
ORDER BY faellig_am DESC, created_at DESC`,
[fahrzeugId]
);
return result.rows as FahrzeugPruefung[];
} catch (error) {
logger.error('VehicleService.getPruefungenForVehicle failed', { error, fahrzeugId });
throw new Error('Failed to fetch inspection history');
}
}
/**
* Returns all upcoming or overdue inspections within the given lookahead window.
* Used by the dashboard InspectionAlerts panel.
*
* @param daysAhead - How many days into the future to look (e.g. 30).
* Pass a very large number (e.g. 9999) to include all overdue too.
*/
async getUpcomingInspections(daysAhead: number): Promise<InspectionAlert[]> {
try {
// We include already-overdue inspections (tage < 0) AND upcoming within window.
// Only open (not yet completed) inspections are relevant.
const result = await pool.query(
`SELECT
p.id AS pruefung_id,
p.fahrzeug_id,
p.pruefung_art,
p.faellig_am,
(p.faellig_am::date - CURRENT_DATE) AS tage,
f.bezeichnung,
f.kurzname
FROM fahrzeug_pruefungen p
JOIN fahrzeuge f ON f.id = p.fahrzeug_id
WHERE
p.durchgefuehrt_am IS NULL
AND (p.faellig_am::date - CURRENT_DATE) <= $1
ORDER BY p.faellig_am ASC`,
[daysAhead]
);
return result.rows.map((row) => ({
fahrzeugId: row.fahrzeug_id,
bezeichnung: row.bezeichnung,
kurzname: row.kurzname,
pruefungId: row.pruefung_id,
pruefungArt: row.pruefung_art as PruefungArt,
faelligAm: row.faellig_am,
tage: parseInt(row.tage, 10),
})) as InspectionAlert[];
} catch (error) {
logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead });
throw new Error('Failed to fetch inspection alerts');
}
}
// =========================================================================
// MAINTENANCE LOG
// =========================================================================
async addWartungslog(
fahrzeugId: string,
data: CreateWartungslogData,
createdBy: string
): Promise<FahrzeugWartungslog> {
try {
const result = await pool.query(
`INSERT INTO fahrzeug_wartungslog (
fahrzeug_id, datum, art, beschreibung,
km_stand, kraftstoff_liter, kosten, externe_werkstatt, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`,
[
fahrzeugId,
data.datum,
data.art ?? null,
data.beschreibung,
data.km_stand ?? null,
data.kraftstoff_liter ?? null,
data.kosten ?? null,
data.externe_werkstatt ?? null,
createdBy,
]
);
const entry = result.rows[0] as FahrzeugWartungslog;
logger.info('Wartungslog entry added', {
entryId: entry.id,
fahrzeugId,
by: createdBy,
});
return entry;
} catch (error) {
logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId });
throw new Error('Failed to add maintenance log entry');
}
}
async getWartungslogForVehicle(fahrzeugId: string): Promise<FahrzeugWartungslog[]> {
try {
const result = await pool.query(
`SELECT * FROM fahrzeug_wartungslog
WHERE fahrzeug_id = $1
ORDER BY datum DESC, created_at DESC`,
[fahrzeugId]
);
return result.rows as FahrzeugWartungslog[];
} catch (error) {
logger.error('VehicleService.getWartungslogForVehicle failed', { error, fahrzeugId });
throw new Error('Failed to fetch maintenance log');
}
}
// =========================================================================
// DASHBOARD KPI
// =========================================================================
/**
* Returns aggregate counts for the dashboard stats strip.
* inspectionsDue = vehicles with at least one inspection due within 30 days
* inspectionsOverdue = vehicles with at least one inspection already overdue
*/
async getVehicleStats(): Promise<VehicleStats> {
try {
const result = await pool.query(`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
COUNT(*) FILTER (
WHERE status IN ('ausser_dienst_wartung','ausser_dienst_schaden')
) AS ausser_dienst,
COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang
FROM fahrzeuge
`);
const alertResult = await pool.query(`
SELECT
COUNT(DISTINCT fahrzeug_id) FILTER (
WHERE (faellig_am::date - CURRENT_DATE) BETWEEN 0 AND 30
) AS inspections_due,
COUNT(DISTINCT fahrzeug_id) FILTER (
WHERE faellig_am::date < CURRENT_DATE
) AS inspections_overdue
FROM fahrzeug_pruefungen
WHERE durchgefuehrt_am IS NULL
`);
const totals = result.rows[0];
const alerts = alertResult.rows[0];
return {
total: parseInt(totals.total, 10),
einsatzbereit: parseInt(totals.einsatzbereit, 10),
ausserDienst: parseInt(totals.ausser_dienst, 10),
inLehrgang: parseInt(totals.in_lehrgang, 10),
inspectionsDue: parseInt(alerts.inspections_due, 10),
inspectionsOverdue: parseInt(alerts.inspections_overdue, 10),
};
} catch (error) {
logger.error('VehicleService.getVehicleStats failed', { error });
throw new Error('Failed to fetch vehicle stats');
}
}
}
export default new VehicleService();