add features

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

View File

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

View File

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

View File

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

View File

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

View File

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