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

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