bug fix for atemschutz
This commit is contained in:
390
backend/src/services/booking.service.ts
Normal file
390
backend/src/services/booking.service.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
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.display_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.display_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.display_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();
|
||||
507
backend/src/services/events.service.ts
Normal file
507
backend/src/services/events.service.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
import {
|
||||
VeranstaltungKategorie,
|
||||
Veranstaltung,
|
||||
VeranstaltungListItem,
|
||||
CreateKategorieData,
|
||||
UpdateKategorieData,
|
||||
CreateVeranstaltungData,
|
||||
UpdateVeranstaltungData,
|
||||
} from '../models/events.model';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Map a raw DB row to a VeranstaltungListItem */
|
||||
function rowToListItem(row: any): VeranstaltungListItem {
|
||||
return {
|
||||
id: row.id,
|
||||
titel: row.titel,
|
||||
ort: row.ort ?? null,
|
||||
kategorie_id: row.kategorie_id ?? null,
|
||||
kategorie_name: row.kategorie_name ?? null,
|
||||
kategorie_farbe: row.kategorie_farbe ?? null,
|
||||
kategorie_icon: row.kategorie_icon ?? null,
|
||||
datum_von: new Date(row.datum_von),
|
||||
datum_bis: new Date(row.datum_bis),
|
||||
ganztaegig: row.ganztaegig,
|
||||
alle_gruppen: row.alle_gruppen,
|
||||
zielgruppen: row.zielgruppen ?? [],
|
||||
abgesagt: row.abgesagt,
|
||||
anmeldung_erforderlich: row.anmeldung_erforderlich,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a raw DB row to a full Veranstaltung */
|
||||
function rowToVeranstaltung(row: any): Veranstaltung {
|
||||
return {
|
||||
id: row.id,
|
||||
titel: row.titel,
|
||||
beschreibung: row.beschreibung ?? null,
|
||||
ort: row.ort ?? null,
|
||||
ort_url: row.ort_url ?? null,
|
||||
kategorie_id: row.kategorie_id ?? null,
|
||||
datum_von: new Date(row.datum_von),
|
||||
datum_bis: new Date(row.datum_bis),
|
||||
ganztaegig: row.ganztaegig,
|
||||
zielgruppen: row.zielgruppen ?? [],
|
||||
alle_gruppen: row.alle_gruppen,
|
||||
max_teilnehmer: row.max_teilnehmer ?? null,
|
||||
anmeldung_erforderlich: row.anmeldung_erforderlich,
|
||||
anmeldung_bis: row.anmeldung_bis ? new Date(row.anmeldung_bis) : null,
|
||||
erstellt_von: row.erstellt_von,
|
||||
abgesagt: row.abgesagt,
|
||||
abgesagt_grund: row.abgesagt_grund ?? null,
|
||||
abgesagt_am: row.abgesagt_am ? new Date(row.abgesagt_am) : null,
|
||||
erstellt_am: new Date(row.erstellt_am),
|
||||
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||
// Joined fields
|
||||
kategorie_name: row.kategorie_name ?? null,
|
||||
kategorie_farbe: row.kategorie_farbe ?? null,
|
||||
kategorie_icon: row.kategorie_icon ?? null,
|
||||
erstellt_von_name: row.erstellt_von_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a Date as YYYYMMDDTHHMMSSZ (UTC) for iCal output */
|
||||
function formatIcalDate(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
}
|
||||
|
||||
/** Fold long iCal lines at 75 octets (RFC 5545 §3.1) */
|
||||
function icalFold(line: string): string {
|
||||
if (line.length <= 75) return line;
|
||||
let folded = '';
|
||||
while (line.length > 75) {
|
||||
folded += line.slice(0, 75) + '\r\n ';
|
||||
line = line.slice(75);
|
||||
}
|
||||
folded += line;
|
||||
return folded;
|
||||
}
|
||||
|
||||
/** Escape special characters in iCal text values (RFC 5545 §3.3.11) */
|
||||
function icalEscape(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class EventsService {
|
||||
// -------------------------------------------------------------------------
|
||||
// KATEGORIEN
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Returns all event categories ordered by name. */
|
||||
async getKategorien(): Promise<VeranstaltungKategorie[]> {
|
||||
const result = await pool.query(`
|
||||
SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am
|
||||
FROM veranstaltung_kategorien
|
||||
ORDER BY name ASC
|
||||
`);
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
beschreibung: row.beschreibung ?? null,
|
||||
farbe: row.farbe ?? null,
|
||||
icon: row.icon ?? null,
|
||||
erstellt_von: row.erstellt_von ?? null,
|
||||
erstellt_am: new Date(row.erstellt_am),
|
||||
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Creates a new event category. */
|
||||
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`,
|
||||
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, userId]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
beschreibung: row.beschreibung ?? null,
|
||||
farbe: row.farbe ?? null,
|
||||
icon: row.icon ?? null,
|
||||
erstellt_von: row.erstellt_von ?? null,
|
||||
erstellt_am: new Date(row.erstellt_am),
|
||||
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||
};
|
||||
}
|
||||
|
||||
/** Updates an existing event category. Returns null if not found. */
|
||||
async updateKategorie(id: string, data: UpdateKategorieData): Promise<VeranstaltungKategorie | null> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.name !== undefined) { fields.push(`name = $${idx++}`); values.push(data.name); }
|
||||
if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung); }
|
||||
if (data.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); }
|
||||
if (data.icon !== undefined) { fields.push(`icon = $${idx++}`); values.push(data.icon); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
// Nothing to update — return the existing record
|
||||
const existing = await pool.query(
|
||||
`SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am
|
||||
FROM veranstaltung_kategorien WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (existing.rows.length === 0) return null;
|
||||
const row = existing.rows[0];
|
||||
return {
|
||||
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
||||
farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null,
|
||||
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
|
||||
};
|
||||
}
|
||||
|
||||
fields.push(`aktualisiert_am = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
|
||||
WHERE id = $${idx}
|
||||
RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`,
|
||||
values
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
||||
farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null,
|
||||
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an event category.
|
||||
* Throws if any events still reference this category.
|
||||
*/
|
||||
async deleteKategorie(id: string): Promise<void> {
|
||||
const refCheck = await pool.query(
|
||||
`SELECT COUNT(*) AS cnt FROM veranstaltungen WHERE kategorie_id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (Number(refCheck.rows[0].cnt) > 0) {
|
||||
throw new Error('Kategorie kann nicht gelöscht werden, da sie noch Veranstaltungen enthält');
|
||||
}
|
||||
const result = await pool.query(
|
||||
`DELETE FROM veranstaltung_kategorien WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error('Kategorie nicht gefunden');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EVENTS — queries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns events within [from, to] visible to the requesting user.
|
||||
* Visibility: alle_gruppen=TRUE OR zielgruppen overlap with userGroups
|
||||
* OR user is dashboard_admin.
|
||||
*/
|
||||
async getEventsByDateRange(
|
||||
from: Date,
|
||||
to: Date,
|
||||
userGroups: string[]
|
||||
): Promise<VeranstaltungListItem[]> {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
v.id, v.titel, v.ort, v.kategorie_id,
|
||||
k.name AS kategorie_name,
|
||||
k.farbe AS kategorie_farbe,
|
||||
k.icon AS kategorie_icon,
|
||||
v.datum_von, v.datum_bis, v.ganztaegig,
|
||||
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
|
||||
FROM veranstaltungen v
|
||||
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||
WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2)
|
||||
AND (
|
||||
v.alle_gruppen = TRUE
|
||||
OR v.zielgruppen && $3
|
||||
OR 'dashboard_admin' = ANY($3)
|
||||
)
|
||||
ORDER BY v.datum_von ASC`,
|
||||
[from, to, userGroups]
|
||||
);
|
||||
return result.rows.map(rowToListItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next N upcoming events (datum_von > NOW) visible to the user.
|
||||
*/
|
||||
async getUpcomingEvents(limit: number, userGroups: string[]): Promise<VeranstaltungListItem[]> {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
v.id, v.titel, v.ort, v.kategorie_id,
|
||||
k.name AS kategorie_name,
|
||||
k.farbe AS kategorie_farbe,
|
||||
k.icon AS kategorie_icon,
|
||||
v.datum_von, v.datum_bis, v.ganztaegig,
|
||||
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
|
||||
FROM veranstaltungen v
|
||||
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||
WHERE v.datum_von > NOW()
|
||||
AND (
|
||||
v.alle_gruppen = TRUE
|
||||
OR v.zielgruppen && $2
|
||||
OR 'dashboard_admin' = ANY($2)
|
||||
)
|
||||
ORDER BY v.datum_von ASC
|
||||
LIMIT $1`,
|
||||
[limit, userGroups]
|
||||
);
|
||||
return result.rows.map(rowToListItem);
|
||||
}
|
||||
|
||||
/** Returns a single event with joined kategorie and creator info. Returns null if not found. */
|
||||
async getById(id: string): Promise<Veranstaltung | null> {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
v.*,
|
||||
k.name AS kategorie_name,
|
||||
k.farbe AS kategorie_farbe,
|
||||
k.icon AS kategorie_icon,
|
||||
u.name AS erstellt_von_name
|
||||
FROM veranstaltungen v
|
||||
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||
LEFT JOIN users u ON u.id = v.erstellt_von
|
||||
WHERE v.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToVeranstaltung(result.rows[0]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EVENTS — mutations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Creates a new event and returns the full record. */
|
||||
async createEvent(data: CreateVeranstaltungData, userId: string): Promise<Veranstaltung> {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO veranstaltungen (
|
||||
titel, beschreibung, ort, ort_url, kategorie_id,
|
||||
datum_von, datum_bis, ganztaegig,
|
||||
zielgruppen, alle_gruppen,
|
||||
max_teilnehmer, anmeldung_erforderlich, anmeldung_bis,
|
||||
erstellt_von
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.titel,
|
||||
data.beschreibung ?? null,
|
||||
data.ort ?? null,
|
||||
data.ort_url ?? null,
|
||||
data.kategorie_id ?? null,
|
||||
data.datum_von,
|
||||
data.datum_bis,
|
||||
data.ganztaegig,
|
||||
data.zielgruppen,
|
||||
data.alle_gruppen,
|
||||
data.max_teilnehmer ?? null,
|
||||
data.anmeldung_erforderlich,
|
||||
data.anmeldung_bis ?? null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
return rowToVeranstaltung(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing event.
|
||||
* Returns the updated record or null if not found.
|
||||
*/
|
||||
async updateEvent(id: string, data: UpdateVeranstaltungData): Promise<Veranstaltung | null> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const fieldMap: Record<string, any> = {
|
||||
titel: data.titel,
|
||||
beschreibung: data.beschreibung,
|
||||
ort: data.ort,
|
||||
ort_url: data.ort_url,
|
||||
kategorie_id: data.kategorie_id,
|
||||
datum_von: data.datum_von,
|
||||
datum_bis: data.datum_bis,
|
||||
ganztaegig: data.ganztaegig,
|
||||
zielgruppen: data.zielgruppen,
|
||||
alle_gruppen: data.alle_gruppen,
|
||||
max_teilnehmer: data.max_teilnehmer,
|
||||
anmeldung_erforderlich: data.anmeldung_erforderlich,
|
||||
anmeldung_bis: data.anmeldung_bis,
|
||||
};
|
||||
|
||||
for (const [col, val] of Object.entries(fieldMap)) {
|
||||
if (val !== undefined) {
|
||||
fields.push(`${col} = $${idx++}`);
|
||||
values.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return this.getById(id);
|
||||
|
||||
fields.push(`aktualisiert_am = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE veranstaltungen SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToVeranstaltung(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-cancels an event by setting abgesagt=TRUE, recording the reason
|
||||
* and the timestamp.
|
||||
*/
|
||||
async cancelEvent(id: string, grund: string, userId: string): Promise<void> {
|
||||
logger.info('Cancelling event', { id, userId });
|
||||
const result = await pool.query(
|
||||
`UPDATE veranstaltungen
|
||||
SET abgesagt = TRUE, abgesagt_grund = $2, abgesagt_am = NOW(), aktualisiert_am = NOW()
|
||||
WHERE id = $1`,
|
||||
[id, grund]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ICAL TOKEN
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns (or creates) the personal iCal subscription token for a user.
|
||||
*
|
||||
* The subscribeUrl is built from ICAL_BASE_URL (env) so it can be used
|
||||
* directly in calendar clients without any further transformation.
|
||||
*/
|
||||
async getOrCreateIcalToken(userId: string): Promise<{ token: string; subscribeUrl: string }> {
|
||||
// Attempt to fetch an existing token first
|
||||
const existing = await pool.query(
|
||||
`SELECT token FROM veranstaltung_ical_tokens WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
let token: string;
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
token = existing.rows[0].token;
|
||||
} else {
|
||||
// Insert a new row — the DEFAULT clause generates the token via gen_random_bytes
|
||||
const inserted = await pool.query(
|
||||
`INSERT INTO veranstaltung_ical_tokens (user_id)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id
|
||||
RETURNING token`,
|
||||
[userId]
|
||||
);
|
||||
token = inserted.rows[0].token;
|
||||
}
|
||||
|
||||
const baseUrl = (process.env.ICAL_BASE_URL ?? '').replace(/\/$/, '');
|
||||
const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`;
|
||||
|
||||
return { token, subscribeUrl };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ICAL EXPORT
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generates an iCal feed for a given token.
|
||||
*
|
||||
* NOTE — Group visibility limitation:
|
||||
* Groups are issued by Authentik and embedded only in the short-lived JWT.
|
||||
* They are NOT persisted in the database. For token-based iCal access we
|
||||
* therefore cannot look up which Authentik groups a user belongs to.
|
||||
* As a safe fallback this export includes only events where alle_gruppen=TRUE
|
||||
* (i.e. events intended for everyone). Authenticated users who request the
|
||||
* .ics directly via Bearer token already get group-filtered results through
|
||||
* the normal API endpoints.
|
||||
*
|
||||
* Returns null if the token is invalid.
|
||||
*/
|
||||
async getIcalExport(token: string): Promise<string | null> {
|
||||
// Validate token and update last-used timestamp
|
||||
const tokenResult = await pool.query(
|
||||
`UPDATE veranstaltung_ical_tokens
|
||||
SET zuletzt_verwendet_am = NOW()
|
||||
WHERE token = $1
|
||||
RETURNING user_id`,
|
||||
[token]
|
||||
);
|
||||
|
||||
if (tokenResult.rows.length === 0) return null;
|
||||
|
||||
// Fetch public events: all future events + those that ended in the last 30 days
|
||||
// Only alle_gruppen=TRUE events — see NOTE above about group limitation
|
||||
const eventsResult = await pool.query(
|
||||
`SELECT v.id, v.titel, v.beschreibung, v.ort, v.datum_von, v.datum_bis, v.ganztaegig, v.abgesagt
|
||||
FROM veranstaltungen v
|
||||
WHERE v.alle_gruppen = TRUE
|
||||
AND v.datum_bis >= NOW() - INTERVAL '30 days'
|
||||
ORDER BY v.datum_von ASC`
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const lines: string[] = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Feuerwehr Dashboard//Veranstaltungen//DE',
|
||||
'X-WR-CALNAME:Feuerwehr Veranstaltungen',
|
||||
'X-WR-TIMEZONE:Europe/Berlin',
|
||||
'CALSCALE:GREGORIAN',
|
||||
];
|
||||
|
||||
for (const row of eventsResult.rows) {
|
||||
const datumVon = new Date(row.datum_von);
|
||||
const datumBis = new Date(row.datum_bis);
|
||||
|
||||
lines.push('BEGIN:VEVENT');
|
||||
lines.push(icalFold(`UID:${row.id}@feuerwehr-veranstaltungen`));
|
||||
lines.push(`DTSTAMP:${formatIcalDate(now)}`);
|
||||
lines.push(`DTSTART:${formatIcalDate(datumVon)}`);
|
||||
lines.push(`DTEND:${formatIcalDate(datumBis)}`);
|
||||
lines.push(icalFold(`SUMMARY:${row.abgesagt ? '[ABGESAGT] ' : ''}${icalEscape(row.titel)}`));
|
||||
if (row.beschreibung) {
|
||||
lines.push(icalFold(`DESCRIPTION:${icalEscape(row.beschreibung)}`));
|
||||
}
|
||||
if (row.ort) {
|
||||
lines.push(icalFold(`LOCATION:${icalEscape(row.ort)}`));
|
||||
}
|
||||
if (row.abgesagt) {
|
||||
lines.push('STATUS:CANCELLED');
|
||||
}
|
||||
lines.push('END:VEVENT');
|
||||
}
|
||||
|
||||
lines.push('END:VCALENDAR');
|
||||
|
||||
// RFC 5545 requires CRLF line endings
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
}
|
||||
|
||||
export default new EventsService();
|
||||
Reference in New Issue
Block a user