Files
dashboard/backend/src/services/training.service.ts
Matthias Hochmeister 620bacc6b5 add features
2026-02-27 19:50:14 +01:00

615 lines
20 KiB
TypeScript

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();