add features
This commit is contained in:
302
backend/src/controllers/incident.controller.ts
Normal file
302
backend/src/controllers/incident.controller.ts
Normal 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();
|
||||
234
backend/src/controllers/member.controller.ts
Normal file
234
backend/src/controllers/member.controller.ts
Normal 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();
|
||||
346
backend/src/controllers/training.controller.ts
Normal file
346
backend/src/controllers/training.controller.ts
Normal 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();
|
||||
344
backend/src/controllers/vehicle.controller.ts
Normal file
344
backend/src/controllers/vehicle.controller.ts
Normal 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();
|
||||
Reference in New Issue
Block a user