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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 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 = { 'Ü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();