391 lines
12 KiB
TypeScript
391 lines
12 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_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,
|
|
f.name AS fahrzeug_name, f.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,
|
|
f.name AS fahrzeug_name, f.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.name AS fahrzeug_name, f.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 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. */
|
|
async create(data: CreateBuchungData, userId: string): Promise<FahrzeugBuchung> {
|
|
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 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> {
|
|
await pool.query(
|
|
`UPDATE fahrzeug_buchungen
|
|
SET abgesagt = TRUE, abgesagt_grund = $2, aktualisiert_am = NOW()
|
|
WHERE id = $1`,
|
|
[id, abgesagt_grund]
|
|
);
|
|
}
|
|
|
|
/** Permanently deletes a booking record. */
|
|
async delete(id: string): Promise<void> {
|
|
await pool.query('DELETE FROM fahrzeug_buchungen WHERE id = $1', [id]);
|
|
}
|
|
|
|
/**
|
|
* 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 || 'http://localhost:3000';
|
|
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.name 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());
|
|
|
|
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` +
|
|
`SUMMARY:${row.titel} - ${row.fahrzeug_name}\r\n` +
|
|
`DESCRIPTION:${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();
|