452 lines
18 KiB
TypeScript
452 lines
18 KiB
TypeScript
import { Request, Response } from 'express';
|
|
import { z } from 'zod';
|
|
import vehicleService from '../services/vehicle.service';
|
|
import equipmentService from '../services/equipment.service';
|
|
import { FahrzeugStatus } from '../models/vehicle.model';
|
|
import logger from '../utils/logger';
|
|
|
|
// ── UUID validation ───────────────────────────────────────────────────────────
|
|
|
|
function isValidUUID(s: string): boolean {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
|
}
|
|
|
|
// ── Zod Validation Schemas ────────────────────────────────────────────────────
|
|
|
|
const FahrzeugStatusEnum = z.enum([
|
|
FahrzeugStatus.Einsatzbereit,
|
|
FahrzeugStatus.AusserDienstWartung,
|
|
FahrzeugStatus.AusserDienstSchaden,
|
|
]);
|
|
|
|
const isoDate = z.string().regex(
|
|
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
|
'Erwartet ISO-Datum im 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().min(1).max(100).optional(),
|
|
bild_url: z.string().url().max(500).refine(
|
|
(url) => /^https?:\/\//i.test(url),
|
|
'Nur http/https URLs erlaubt'
|
|
).optional(),
|
|
paragraph57a_faellig_am: isoDate.optional(),
|
|
naechste_wartung_am: isoDate.optional(),
|
|
});
|
|
|
|
const UpdateFahrzeugSchema = z.object({
|
|
bezeichnung: z.string().min(1).max(100).optional(),
|
|
kurzname: z.string().max(20).nullable().optional(),
|
|
amtliches_kennzeichen: z.string().max(20).nullable().optional(),
|
|
fahrgestellnummer: z.string().max(50).nullable().optional(),
|
|
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
|
|
hersteller: z.string().max(100).nullable().optional(),
|
|
typ_schluessel: z.string().max(30).nullable().optional(),
|
|
besatzung_soll: z.string().max(10).nullable().optional(),
|
|
status: FahrzeugStatusEnum.optional(),
|
|
status_bemerkung: z.string().max(500).nullable().optional(),
|
|
standort: z.string().min(1).max(100).optional(),
|
|
bild_url: z.string().url().max(500).refine(
|
|
(url) => /^https?:\/\//i.test(url),
|
|
'Nur http/https URLs erlaubt'
|
|
).nullable().optional(),
|
|
paragraph57a_faellig_am: isoDate.nullable().optional(),
|
|
naechste_wartung_am: isoDate.nullable().optional(),
|
|
});
|
|
|
|
const isoDatetime = z.string().datetime({ offset: true, message: 'Erwartet ISO-8601 Datum mit Zeitzone' });
|
|
|
|
const UpdateStatusSchema = z.object({
|
|
status: FahrzeugStatusEnum,
|
|
bemerkung: z.string().max(500).optional().default(''),
|
|
ausserDienstVon: isoDatetime.optional(),
|
|
ausserDienstBis: isoDatetime.optional(),
|
|
}).refine(
|
|
(d) => {
|
|
const isAusserDienst = d.status === FahrzeugStatus.AusserDienstWartung || d.status === FahrzeugStatus.AusserDienstSchaden;
|
|
if (!isAusserDienst) return true;
|
|
return !!d.ausserDienstVon && !!d.ausserDienstBis;
|
|
},
|
|
{ message: 'Außer-Dienst-Zeitraum (von + bis) ist bei diesem Status erforderlich', path: ['ausserDienstVon'] }
|
|
).refine(
|
|
(d) => {
|
|
if (!d.ausserDienstVon || !d.ausserDienstBis) return true;
|
|
return new Date(d.ausserDienstBis) > new Date(d.ausserDienstVon);
|
|
},
|
|
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
|
|
);
|
|
|
|
const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']);
|
|
|
|
const CreateWartungslogSchema = z.object({
|
|
datum: isoDate,
|
|
art: z.enum(['§57a Prüfung', 'Service', '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(),
|
|
ergebnis: ErgebnisEnum.optional(),
|
|
naechste_faelligkeit: isoDate.optional(),
|
|
});
|
|
|
|
const UpdateWartungslogSchema = z.object({
|
|
datum: isoDate,
|
|
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
|
|
beschreibung: z.string().min(1).max(2000),
|
|
km_stand: z.number().int().min(0).optional(),
|
|
externe_werkstatt: z.string().max(150).optional(),
|
|
ergebnis: ErgebnisEnum.optional(),
|
|
naechste_faelligkeit: isoDate.optional(),
|
|
});
|
|
|
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
|
|
|
function getUserId(req: Request): string {
|
|
return req.user!.id;
|
|
}
|
|
|
|
// ── Controller ────────────────────────────────────────────────────────────────
|
|
|
|
class VehicleController {
|
|
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' });
|
|
}
|
|
}
|
|
|
|
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' });
|
|
}
|
|
}
|
|
|
|
async getAlerts(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
|
|
if (isNaN(raw) || raw < 0) {
|
|
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
|
|
return;
|
|
}
|
|
const daysAhead = Math.min(raw, 365);
|
|
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' });
|
|
}
|
|
}
|
|
|
|
async exportAlerts(_req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const escape = (v: unknown): string => {
|
|
if (v === null || v === undefined) return '';
|
|
const str = String(v);
|
|
let safe = str.replace(/"/g, '""');
|
|
if (/^[=+@\-]/.test(safe)) safe = "'" + safe;
|
|
return `"${safe}"`;
|
|
};
|
|
|
|
const formatDate = (d: Date | string | null): string => {
|
|
if (!d) return '';
|
|
const date = typeof d === 'string' ? new Date(d) : d;
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const year = date.getFullYear();
|
|
return `${day}.${month}.${year}`;
|
|
};
|
|
|
|
const [vehicleAlerts, equipmentAlerts] = await Promise.all([
|
|
vehicleService.getUpcomingInspections(365),
|
|
equipmentService.getUpcomingInspections(365),
|
|
]);
|
|
|
|
const header = 'Typ,Bezeichnung,Kurzname,Prüfungsart,Fällig am,Tage verbleibend';
|
|
const rows: string[] = [];
|
|
|
|
for (const a of vehicleAlerts) {
|
|
const pruefungsart = a.type === '57a' ? '§57a Überprüfung' : 'Nächste Wartung';
|
|
rows.push([
|
|
escape('Fahrzeug'),
|
|
escape(a.bezeichnung),
|
|
escape(a.kurzname),
|
|
escape(pruefungsart),
|
|
escape(formatDate(a.faelligAm)),
|
|
escape(a.tage),
|
|
].join(','));
|
|
}
|
|
|
|
for (const e of equipmentAlerts) {
|
|
rows.push([
|
|
escape('Ausrüstung'),
|
|
escape(e.bezeichnung),
|
|
escape(''),
|
|
escape('Prüfung'),
|
|
escape(formatDate(e.naechste_pruefung_am)),
|
|
escape(e.pruefung_tage_bis_faelligkeit),
|
|
].join(','));
|
|
}
|
|
|
|
const today = new Date();
|
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
const csv = '\uFEFF' + header + '\n' + rows.join('\n');
|
|
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
res.setHeader('Content-Disposition', `attachment; filename="pruefungen_${dateStr}.csv"`);
|
|
res.status(200).send(csv);
|
|
} catch (error) {
|
|
logger.error('exportAlerts error', { error });
|
|
res.status(500).json({ success: false, message: 'Prüfungsexport konnte nicht erstellt werden' });
|
|
}
|
|
}
|
|
|
|
async getVehicle(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { id } = req.params as Record<string, string>;
|
|
if (!isValidUUID(id)) {
|
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
|
return;
|
|
}
|
|
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' });
|
|
}
|
|
}
|
|
|
|
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' });
|
|
}
|
|
}
|
|
|
|
async updateVehicle(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { id } = req.params as Record<string, string>;
|
|
if (!isValidUUID(id)) {
|
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
|
return;
|
|
}
|
|
const parsed = UpdateFahrzeugSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({
|
|
success: false,
|
|
message: 'Validierungsfehler',
|
|
errors: parsed.error.flatten().fieldErrors,
|
|
});
|
|
return;
|
|
}
|
|
if (Object.keys(parsed.data).length === 0) {
|
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
|
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' });
|
|
}
|
|
}
|
|
|
|
async updateVehicleStatus(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { id } = req.params as Record<string, string>;
|
|
if (!isValidUUID(id)) {
|
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
|
return;
|
|
}
|
|
const parsed = UpdateStatusSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({
|
|
success: false,
|
|
message: 'Validierungsfehler',
|
|
errors: parsed.error.flatten().fieldErrors,
|
|
});
|
|
return;
|
|
}
|
|
const io = req.app.get('io') ?? undefined;
|
|
const result = await vehicleService.updateVehicleStatus(
|
|
id,
|
|
parsed.data.status,
|
|
parsed.data.bemerkung,
|
|
getUserId(req),
|
|
io,
|
|
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
|
|
parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null,
|
|
);
|
|
res.status(200).json({ success: true, data: result });
|
|
} 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' });
|
|
}
|
|
}
|
|
|
|
async addWartung(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { id } = req.params as Record<string, string>;
|
|
if (!isValidUUID(id)) {
|
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
|
return;
|
|
}
|
|
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: any) {
|
|
if (error?.message === 'Vehicle not found') {
|
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
|
return;
|
|
}
|
|
logger.error('addWartung error', { error, id: req.params.id });
|
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
|
|
}
|
|
}
|
|
|
|
async deleteVehicle(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { id } = req.params as Record<string, string>;
|
|
if (!isValidUUID(id)) {
|
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
|
return;
|
|
}
|
|
await vehicleService.deleteVehicle(id, getUserId(req));
|
|
res.status(204).send();
|
|
} catch (error: any) {
|
|
if (error?.message === 'Vehicle not found') {
|
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
|
return;
|
|
}
|
|
logger.error('deleteVehicle error', { error, id: req.params.id });
|
|
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht gelöscht werden' });
|
|
}
|
|
}
|
|
|
|
async getWartung(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { id } = req.params as Record<string, string>;
|
|
if (!isValidUUID(id)) {
|
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
|
return;
|
|
}
|
|
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' });
|
|
}
|
|
}
|
|
|
|
async getStatusHistory(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { id } = req.params as Record<string, string>;
|
|
const history = await vehicleService.getStatusHistory(id);
|
|
res.status(200).json({ success: true, data: history });
|
|
} catch (error) {
|
|
logger.error('getStatusHistory error', { error, id: req.params.id });
|
|
res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' });
|
|
}
|
|
}
|
|
|
|
async updateWartung(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { id, wartungId } = req.params as Record<string, string>;
|
|
if (!isValidUUID(id)) {
|
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
|
return;
|
|
}
|
|
const parsed = UpdateWartungslogSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({
|
|
success: false,
|
|
message: 'Validierungsfehler',
|
|
errors: parsed.error.flatten().fieldErrors,
|
|
});
|
|
return;
|
|
}
|
|
const entry = await vehicleService.updateWartungslog(wartungId, id, parsed.data, getUserId(req));
|
|
res.status(200).json({ success: true, data: entry });
|
|
} catch (error: any) {
|
|
if (error?.message === 'Wartungseintrag nicht gefunden') {
|
|
res.status(404).json({ success: false, message: 'Wartungseintrag nicht gefunden' });
|
|
return;
|
|
}
|
|
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
|
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
|
|
}
|
|
}
|
|
|
|
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
|
const { wartungId } = req.params as Record<string, string>;
|
|
const id = parseInt(wartungId, 10);
|
|
if (isNaN(id)) {
|
|
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
|
|
return;
|
|
}
|
|
const file = (req as any).file;
|
|
if (!file) {
|
|
res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await vehicleService.updateWartungslogFile(id, file.path);
|
|
res.status(200).json({ success: true, data: result });
|
|
} catch (error) {
|
|
logger.error('uploadWartungFile error', { error, wartungId });
|
|
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
|
|
}
|
|
}
|
|
}
|
|
|
|
export default new VehicleController();
|