add features
This commit is contained in:
169
backend/src/routes/admin.routes.ts
Normal file
169
backend/src/routes/admin.routes.ts
Normal 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;
|
||||
139
backend/src/routes/incident.routes.ts
Normal file
139
backend/src/routes/incident.routes.ts
Normal 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;
|
||||
139
backend/src/routes/member.routes.ts
Normal file
139
backend/src/routes/member.routes.ts
Normal 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;
|
||||
149
backend/src/routes/training.routes.ts
Normal file
149
backend/src/routes/training.routes.ts
Normal 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;
|
||||
147
backend/src/routes/vehicle.routes.ts
Normal file
147
backend/src/routes/vehicle.routes.ts
Normal 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;
|
||||
Reference in New Issue
Block a user