Files
dashboard/backend/src/services/booking.service.ts
Matthias Hochmeister c15d4a50e0 update
2026-03-16 15:26:43 +01:00

453 lines
15 KiB
TypeScript

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),
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,
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<FahrzeugBuchungListItem[]> {
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,
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<FahrzeugBuchungListItem[]> {
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,
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<FahrzeugBuchung | null> {
const query = `
SELECT
b.id, b.fahrzeug_id, b.titel, b.beschreibung,
b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende,
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<boolean> {
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. */
async create(data: CreateBuchungData, userId: string): Promise<FahrzeugBuchung> {
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)
VALUES
($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9)
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,
]);
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<FahrzeugBuchung | null> {
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 (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<void> {
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<void> {
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<string | null> {
// 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();