import pool from '../config/database'; import logger from '../utils/logger'; import { FahrzeugBuchung, FahrzeugBuchungListItem, CreateBuchungData, UpdateBuchungData, } from '../models/booking.model'; // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** Format a Date to iCal YYYYMMDDTHHMMSSZ format (UTC) */ function toIcalDate(d: Date): string { return new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); } function rowToListItem(row: any): FahrzeugBuchungListItem { return { id: row.id, fahrzeug_id: row.fahrzeug_id, fahrzeug_name: row.fahrzeug_name ?? '', fahrzeug_kennzeichen: row.fahrzeug_kennzeichen ?? null, titel: row.titel, buchungs_art: row.buchungs_art, beginn: new Date(row.beginn), ende: new Date(row.ende), ganztaegig: row.ganztaegig ?? false, abgesagt: row.abgesagt, gebucht_von: row.gebucht_von, gebucht_von_name: row.gebucht_von_name ?? null, }; } function rowToBuchung(row: any): FahrzeugBuchung { return { id: row.id, fahrzeug_id: row.fahrzeug_id, titel: row.titel, beschreibung: row.beschreibung ?? null, beginn: new Date(row.beginn), ende: new Date(row.ende), buchungs_art: row.buchungs_art, ganztaegig: row.ganztaegig ?? false, gebucht_von: row.gebucht_von, kontakt_person: row.kontakt_person ?? null, kontakt_telefon: row.kontakt_telefon ?? null, abgesagt: row.abgesagt, abgesagt_grund: row.abgesagt_grund ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), fahrzeug_name: row.fahrzeug_name ?? null, fahrzeug_kennzeichen: row.fahrzeug_kennzeichen ?? null, gebucht_von_name: row.gebucht_von_name ?? null, }; } // --------------------------------------------------------------------------- // Booking Service // --------------------------------------------------------------------------- class BookingService { /** * Returns bookings overlapping the given date range, optionally filtered * to a single vehicle. Non-cancelled only. */ async getBookingsByRange( from: Date, to: Date, fahrzeugId?: string ): Promise { const params: unknown[] = [from, to]; let vehicleFilter = ''; if (fahrzeugId) { params.push(fahrzeugId); vehicleFilter = `AND b.fahrzeug_id = $${params.length}`; } const query = ` SELECT b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art, b.beginn, b.ende, b.abgesagt, b.gebucht_von, b.ganztaegig, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, u.name AS gebucht_von_name FROM fahrzeug_buchungen b JOIN fahrzeuge f ON f.id = b.fahrzeug_id JOIN users u ON u.id = b.gebucht_von WHERE b.abgesagt = FALSE AND ( b.beginn BETWEEN $1 AND $2 OR b.ende BETWEEN $1 AND $2 OR (b.beginn <= $1 AND b.ende >= $2) ) ${vehicleFilter} ORDER BY b.beginn ASC `; const { rows } = await pool.query(query, params); return rows.map(rowToListItem); } /** Returns the next N upcoming non-cancelled bookings sorted ascending. */ async getUpcoming(limit = 20): Promise { const query = ` SELECT b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art, b.beginn, b.ende, b.abgesagt, b.gebucht_von, b.ganztaegig, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, u.name AS gebucht_von_name FROM fahrzeug_buchungen b JOIN fahrzeuge f ON f.id = b.fahrzeug_id JOIN users u ON u.id = b.gebucht_von WHERE b.abgesagt = FALSE AND b.beginn > NOW() ORDER BY b.beginn ASC LIMIT $1 `; const { rows } = await pool.query(query, [limit]); return rows.map(rowToListItem); } /** Returns a single booking by ID including all joined fields, or null. */ async getById(id: string): Promise { const query = ` SELECT b.id, b.fahrzeug_id, b.titel, b.beschreibung, b.buchungs_art::text AS buchungs_art, b.beginn, b.ende, b.ganztaegig, b.gebucht_von, b.kontakt_person, b.kontakt_telefon, b.abgesagt, b.abgesagt_grund, b.erstellt_am, b.aktualisiert_am, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, u.name AS gebucht_von_name FROM fahrzeug_buchungen b JOIN fahrzeuge f ON f.id = b.fahrzeug_id JOIN users u ON u.id = b.gebucht_von WHERE b.id = $1 `; const { rows } = await pool.query(query, [id]); if (rows.length === 0) return null; return rowToBuchung(rows[0]); } /** * Checks whether a vehicle is out of service during the given interval. * Returns the out-of-service period if there IS a conflict, null otherwise. */ async checkOutOfServiceConflict( fahrzeugId: string, beginn: Date, ende: Date ): Promise<{ ausser_dienst_von: Date; ausser_dienst_bis: Date } | null> { const query = ` SELECT ausser_dienst_von, ausser_dienst_bis FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL AND status IN ('ausser_dienst_wartung', 'ausser_dienst_schaden') AND ausser_dienst_von IS NOT NULL AND ausser_dienst_bis IS NOT NULL AND ($2::timestamptz, $3::timestamptz) OVERLAPS (ausser_dienst_von, ausser_dienst_bis) LIMIT 1 `; const { rows } = await pool.query(query, [fahrzeugId, beginn, ende]); if (rows.length === 0) return null; return { ausser_dienst_von: new Date(rows[0].ausser_dienst_von), ausser_dienst_bis: new Date(rows[0].ausser_dienst_bis), }; } /** * Checks whether a vehicle is already booked for the given interval. * Returns true if there IS a conflict. * Pass excludeId to ignore a specific booking (used during updates). */ async checkConflict( fahrzeugId: string, beginn: Date, ende: Date, excludeId?: string ): Promise { const query = ` SELECT 1 FROM fahrzeug_buchungen WHERE fahrzeug_id = $1 AND abgesagt = FALSE AND ($2::timestamptz, $3::timestamptz) OVERLAPS (beginn, ende) AND ($4::uuid IS NULL OR id != $4) LIMIT 1 `; const { rows } = await pool.query(query, [ fahrzeugId, beginn, ende, excludeId ?? null, ]); return rows.length > 0; } /** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service (unless overridden). */ async create(data: CreateBuchungData, userId: string, ignoreOutOfService = false): Promise { if (!ignoreOutOfService) { const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende); if (outOfService) { throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst'); } } const hasConflict = await this.checkConflict( data.fahrzeugId, data.beginn, data.ende ); if (hasConflict) { throw new Error('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); } const query = ` INSERT INTO fahrzeug_buchungen (fahrzeug_id, titel, beschreibung, beginn, ende, buchungs_art, gebucht_von, kontakt_person, kontakt_telefon, ganztaegig) VALUES ($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9, $10) RETURNING id `; const { rows } = await pool.query(query, [ data.fahrzeugId, data.titel, data.beschreibung ?? null, data.beginn, data.ende, data.buchungsArt, userId, data.kontaktPerson ?? null, data.kontaktTelefon ?? null, data.ganztaegig ?? false, ]); const newId: string = rows[0].id; const booking = await this.getById(newId); if (!booking) throw new Error('Buchung konnte nach dem Erstellen nicht geladen werden'); return booking; } /** * Updates the provided fields of a booking. * Checks for conflicts when timing or vehicle fields change. */ async update(id: string, data: UpdateBuchungData): Promise { const existing = await this.getById(id); if (!existing) return null; // Resolve effective values for conflict check const effectiveFahrzeugId = data.fahrzeugId ?? existing.fahrzeug_id; const effectiveBeginn = data.beginn ?? existing.beginn; const effectiveEnde = data.ende ?? existing.ende; const timingChanged = data.fahrzeugId != null || data.beginn != null || data.ende != null; if (timingChanged) { const outOfService = await this.checkOutOfServiceConflict( effectiveFahrzeugId, effectiveBeginn, effectiveEnde ); if (outOfService) { throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst'); } const hasConflict = await this.checkConflict( effectiveFahrzeugId, effectiveBeginn, effectiveEnde, id ); if (hasConflict) { throw new Error('Fahrzeug ist im gewählten Zeitraum bereits gebucht'); } } // Build dynamic SET clause const setClauses: string[] = []; const params: unknown[] = []; const addField = (column: string, value: unknown, cast?: string) => { params.push(value); setClauses.push(`${column} = $${params.length}${cast ? `::${cast}` : ''}`); }; if (data.fahrzeugId !== undefined) addField('fahrzeug_id', data.fahrzeugId); if (data.titel !== undefined) addField('titel', data.titel); if (data.beschreibung !== undefined) addField('beschreibung', data.beschreibung); if (data.beginn !== undefined) addField('beginn', data.beginn); if (data.ende !== undefined) addField('ende', data.ende); if (data.buchungsArt !== undefined) addField('buchungs_art', data.buchungsArt, 'fahrzeug_buchung_art'); if (data.kontaktPerson !== undefined) addField('kontakt_person', data.kontaktPerson); if (data.kontaktTelefon !== undefined) addField('kontakt_telefon', data.kontaktTelefon); if (data.ganztaegig !== undefined) addField('ganztaegig', data.ganztaegig); if (setClauses.length === 0) { throw new Error('No fields to update'); } params.push(id); const query = ` UPDATE fahrzeug_buchungen SET ${setClauses.join(', ')}, aktualisiert_am = NOW() WHERE id = $${params.length} `; await pool.query(query, params); return this.getById(id); } /** Soft-cancels a booking by setting abgesagt=TRUE and recording the reason. */ async cancel(id: string, abgesagt_grund: string): Promise { const result = await pool.query( `UPDATE fahrzeug_buchungen SET abgesagt = TRUE, abgesagt_grund = $2, aktualisiert_am = NOW() WHERE id = $1`, [id, abgesagt_grund] ); if (result.rowCount === 0) throw new Error('Buchung nicht gefunden'); } /** Permanently deletes a booking record. */ async delete(id: string): Promise { const result = await pool.query('DELETE FROM fahrzeug_buchungen WHERE id = $1', [id]); if (result.rowCount === 0) throw new Error('Buchung nicht gefunden'); } /** * Returns an existing iCal token for the user, or creates a new one. * Also returns the subscribe URL the user can add to their calendar app. */ async getOrCreateIcalToken( userId: string ): Promise<{ token: string; subscribeUrl: string }> { const selectResult = await pool.query( 'SELECT token FROM fahrzeug_ical_tokens WHERE user_id = $1', [userId] ); let token: string; if (selectResult.rows.length > 0) { token = selectResult.rows[0].token; } else { const insertResult = await pool.query( `INSERT INTO fahrzeug_ical_tokens (user_id) VALUES ($1) RETURNING token`, [userId] ); token = insertResult.rows[0].token; logger.info('Created new iCal token for user', { userId }); } const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || 'http://localhost:3000').replace(/\/$/, ''); const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`; return { token, subscribeUrl }; } /** * Validates the iCal token and returns an iCal string for all (or one * vehicle's) non-cancelled bookings. Returns null when the token is invalid. */ async getIcalExport( token: string, fahrzeugId?: string ): Promise { // Validate token and update last-used timestamp const tokenResult = await pool.query( `UPDATE fahrzeug_ical_tokens SET zuletzt_verwendet_am = NOW() WHERE token = $1 RETURNING id`, [token] ); if (tokenResult.rows.length === 0) return null; // Fetch bookings const params: unknown[] = []; let vehicleFilter = ''; if (fahrzeugId) { params.push(fahrzeugId); vehicleFilter = `AND b.fahrzeug_id = $${params.length}`; } const query = ` SELECT b.id, b.titel, b.beschreibung, b.buchungs_art::text AS buchungs_art, b.beginn, b.ende, f.bezeichnung AS fahrzeug_name FROM fahrzeug_buchungen b JOIN fahrzeuge f ON f.id = b.fahrzeug_id WHERE b.abgesagt = FALSE ${vehicleFilter} ORDER BY b.beginn ASC `; const { rows } = await pool.query(query, params); const now = toIcalDate(new Date()); // iCal escaping and folding helpers const icalEscape = (val: string): string => val.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); const icalFold = (line: string): string => { if (Buffer.byteLength(line, 'utf-8') <= 75) return line; let folded = ''; let cur = ''; let bytes = 0; for (const ch of line) { const cb = Buffer.byteLength(ch, 'utf-8'); if (bytes + cb > 75) { folded += cur + '\r\n '; cur = ch; bytes = 1 + cb; } else { cur += ch; bytes += cb; } } return folded + cur; }; const events = rows .map((row: any) => { const beschreibung = [row.buchungs_art, row.beschreibung] .filter(Boolean) .join(' | '); return ( 'BEGIN:VEVENT\r\n' + `UID:${row.id}@feuerwehr-buchungen\r\n` + `DTSTAMP:${now}\r\n` + `DTSTART:${toIcalDate(new Date(row.beginn))}\r\n` + `DTEND:${toIcalDate(new Date(row.ende))}\r\n` + icalFold(`SUMMARY:${icalEscape(row.titel)} - ${icalEscape(row.fahrzeug_name)}`) + '\r\n' + icalFold(`DESCRIPTION:${icalEscape(beschreibung)}`) + '\r\n' + 'END:VEVENT\r\n' ); }) .join(''); return ( 'BEGIN:VCALENDAR\r\n' + 'VERSION:2.0\r\n' + 'PRODID:-//Feuerwehr Dashboard//Fahrzeugbuchungen//DE\r\n' + 'X-WR-CALNAME:Feuerwehr Fahrzeugbuchungen\r\n' + 'X-WR-TIMEZONE:Europe/Berlin\r\n' + 'CALSCALE:GREGORIAN\r\n' + events + 'END:VCALENDAR\r\n' ); } } export default new BookingService();