new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 14:01:39 +01:00
parent d2dc64d54a
commit 3326156b15
35 changed files with 1341 additions and 257 deletions

View File

@@ -35,13 +35,13 @@ async function getVendorById(id: number) {
}
}
async function createVendor(data: { name: string; kontakt_person?: string; email?: string; telefon?: string; adresse?: string; notizen?: string }, userId: string) {
async function createVendor(data: { name: string; kontakt_name?: string; email?: string; telefon?: string; adresse?: string; website?: string; notizen?: string }, userId: string) {
try {
const result = await pool.query(
`INSERT INTO lieferanten (name, kontakt_person, email, telefon, adresse, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO lieferanten (name, kontakt_name, email, telefon, adresse, website, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[data.name, data.kontakt_person || null, data.email || null, data.telefon || null, data.adresse || null, data.notizen || null, userId]
[data.name, data.kontakt_name || null, data.email || null, data.telefon || null, data.adresse || null, data.website || null, data.notizen || null, userId]
);
return result.rows[0];
} catch (error) {
@@ -50,20 +50,21 @@ async function createVendor(data: { name: string; kontakt_person?: string; email
}
}
async function updateVendor(id: number, data: { name?: string; kontakt_person?: string; email?: string; telefon?: string; adresse?: string; notizen?: string }, userId: string) {
async function updateVendor(id: number, data: { name?: string; kontakt_name?: string; email?: string; telefon?: string; adresse?: string; website?: string; notizen?: string }, userId: string) {
try {
const result = await pool.query(
`UPDATE lieferanten
SET name = COALESCE($1, name),
kontakt_person = COALESCE($2, kontakt_person),
kontakt_name = COALESCE($2, kontakt_name),
email = COALESCE($3, email),
telefon = COALESCE($4, telefon),
adresse = COALESCE($5, adresse),
notizen = COALESCE($6, notizen),
website = COALESCE($6, website),
notizen = COALESCE($7, notizen),
aktualisiert_am = NOW()
WHERE id = $7
WHERE id = $8
RETURNING *`,
[data.name, data.kontakt_person, data.email, data.telefon, data.adresse, data.notizen, id]
[data.name, data.kontakt_name, data.email, data.telefon, data.adresse, data.website, data.notizen, id]
);
if (result.rows.length === 0) return null;
@@ -157,7 +158,7 @@ async function getOrderById(id: number) {
pool.query(`SELECT * FROM bestellpositionen WHERE bestellung_id = $1 ORDER BY id`, [id]),
pool.query(`SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, [id]),
pool.query(`SELECT * FROM bestellung_erinnerungen WHERE bestellung_id = $1 ORDER BY faellig_am`, [id]),
pool.query(`SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.benutzer_id WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]),
pool.query(`SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]),
]);
return {
@@ -173,16 +174,16 @@ async function getOrderById(id: number) {
}
}
async function createOrder(data: { titel: string; lieferant_id?: number; beschreibung?: string; prioritaet?: string }, userId: string) {
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; notizen?: string; budget?: number }, userId: string) {
try {
const result = await pool.query(
`INSERT INTO bestellungen (titel, lieferant_id, beschreibung, prioritaet, erstellt_von)
`INSERT INTO bestellungen (bezeichnung, lieferant_id, notizen, budget, erstellt_von)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[data.titel, data.lieferant_id || null, data.beschreibung || null, data.prioritaet || 'normal', userId]
[data.bezeichnung, data.lieferant_id || null, data.notizen || null, data.budget || null, userId]
);
const order = result.rows[0];
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.titel}" erstellt`, userId);
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
return order;
} catch (error) {
logger.error('BestellungService.createOrder failed', { error });
@@ -190,7 +191,7 @@ async function createOrder(data: { titel: string; lieferant_id?: number; beschre
}
}
async function updateOrder(id: number, data: { titel?: string; lieferant_id?: number; beschreibung?: string; prioritaet?: string; status?: string }, userId: string) {
async function updateOrder(id: number, data: { bezeichnung?: string; lieferant_id?: number; notizen?: string; budget?: number; status?: string }, userId: string) {
try {
// Check current order for status change detection
const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]);
@@ -213,25 +214,25 @@ async function updateOrder(id: number, data: { titel?: string; lieferant_id?: nu
const result = await pool.query(
`UPDATE bestellungen
SET titel = COALESCE($1, titel),
SET bezeichnung = COALESCE($1, bezeichnung),
lieferant_id = COALESCE($2, lieferant_id),
beschreibung = COALESCE($3, beschreibung),
prioritaet = COALESCE($4, prioritaet),
notizen = COALESCE($3, notizen),
budget = COALESCE($4, budget),
status = COALESCE($5, status),
bestellt_am = $6,
abgeschlossen_am = $7,
aktualisiert_am = NOW()
WHERE id = $8
RETURNING *`,
[data.titel, data.lieferant_id, data.beschreibung, data.prioritaet, data.status, bestellt_am, abgeschlossen_am, id]
[data.bezeichnung, data.lieferant_id, data.notizen, data.budget, data.status, bestellt_am, abgeschlossen_am, id]
);
if (result.rows.length === 0) return null;
const changes: string[] = [];
if (data.titel) changes.push(`Titel geändert`);
if (data.bezeichnung) changes.push(`Bezeichnung geändert`);
if (data.lieferant_id) changes.push(`Lieferant geändert`);
if (data.status && data.status !== oldStatus) changes.push(`Status: ${oldStatus}${data.status}`);
if (data.prioritaet) changes.push(`Priorität geändert`);
if (data.budget) changes.push(`Budget geändert`);
await logAction(id, 'Bestellung aktualisiert', changes.join(', ') || 'Bestellung bearbeitet', userId);
return result.rows[0];
@@ -275,12 +276,12 @@ async function deleteOrder(id: number, _userId: string) {
}
const VALID_STATUS_TRANSITIONS: Record<string, string[]> = {
entwurf: ['bestellt', 'storniert'],
bestellt: ['teillieferung', 'vollstaendig', 'storniert'],
teillieferung: ['vollstaendig', 'storniert'],
entwurf: ['erstellt', 'bestellt'],
erstellt: ['bestellt'],
bestellt: ['teillieferung', 'vollstaendig'],
teillieferung: ['vollstaendig'],
vollstaendig: ['abgeschlossen'],
abgeschlossen: [],
storniert: ['entwurf'],
};
async function updateOrderStatus(id: number, status: string, userId: string) {
@@ -323,15 +324,15 @@ async function updateOrderStatus(id: number, status: string, userId: string) {
// Line Items (Bestellpositionen)
// ---------------------------------------------------------------------------
async function addLineItem(bestellungId: number, data: { artikel: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
try {
const result = await pool.query(
`INSERT INTO bestellpositionen (bestellung_id, artikel, menge, einheit, einzelpreis, notizen)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[bestellungId, data.artikel, data.menge, data.einheit || 'Stück', data.einzelpreis || 0, data.notizen || null]
[bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null]
);
await logAction(bestellungId, 'Position hinzugefügt', `"${data.artikel}" x${data.menge}`, userId);
await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId);
return result.rows[0];
} catch (error) {
logger.error('BestellungService.addLineItem failed', { error, bestellungId });
@@ -339,23 +340,24 @@ async function addLineItem(bestellungId: number, data: { artikel: string; menge:
}
}
async function updateLineItem(id: number, data: { artikel?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
async function updateLineItem(id: number, data: { bezeichnung?: string; artikelnummer?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
try {
const result = await pool.query(
`UPDATE bestellpositionen
SET artikel = COALESCE($1, artikel),
menge = COALESCE($2, menge),
einheit = COALESCE($3, einheit),
einzelpreis = COALESCE($4, einzelpreis),
notizen = COALESCE($5, notizen)
WHERE id = $6
SET bezeichnung = COALESCE($1, bezeichnung),
artikelnummer = COALESCE($2, artikelnummer),
menge = COALESCE($3, menge),
einheit = COALESCE($4, einheit),
einzelpreis = COALESCE($5, einzelpreis),
notizen = COALESCE($6, notizen)
WHERE id = $7
RETURNING *`,
[data.artikel, data.menge, data.einheit, data.einzelpreis, data.notizen, id]
[data.bezeichnung, data.artikelnummer, data.menge, data.einheit, data.einzelpreis, data.notizen, id]
);
if (result.rows.length === 0) return null;
const item = result.rows[0];
await logAction(item.bestellung_id, 'Position aktualisiert', `"${item.artikel}" bearbeitet`, userId);
await logAction(item.bestellung_id, 'Position aktualisiert', `"${item.bezeichnung}" bearbeitet`, userId);
return item;
} catch (error) {
logger.error('BestellungService.updateLineItem failed', { error, id });
@@ -369,7 +371,7 @@ async function deleteLineItem(id: number, userId: string) {
if (item.rows.length === 0) return false;
await pool.query(`DELETE FROM bestellpositionen WHERE id = $1`, [id]);
await logAction(item.rows[0].bestellung_id, 'Position entfernt', `"${item.rows[0].artikel}" entfernt`, userId);
await logAction(item.rows[0].bestellung_id, 'Position entfernt', `"${item.rows[0].bezeichnung}" entfernt`, userId);
return true;
} catch (error) {
logger.error('BestellungService.deleteLineItem failed', { error, id });
@@ -386,7 +388,7 @@ async function updateReceivedQuantity(id: number, menge: number, userId: string)
if (result.rows.length === 0) return null;
const item = result.rows[0];
await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.artikel}": ${menge} von ${item.menge} erhalten`, userId);
await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.bezeichnung}": ${menge} von ${item.menge} erhalten`, userId);
// Check if all items for this order are fully received
const allItems = await pool.query(
@@ -477,15 +479,15 @@ async function getFilesByOrder(bestellungId: number) {
// Reminders (Bestellung Erinnerungen)
// ---------------------------------------------------------------------------
async function addReminder(bestellungId: number, data: { titel: string; faellig_am: string; notizen?: string }, userId: string) {
async function addReminder(bestellungId: number, data: { nachricht: string; faellig_am: string }, userId: string) {
try {
const result = await pool.query(
`INSERT INTO bestellung_erinnerungen (bestellung_id, titel, faellig_am, notizen, erstellt_von)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO bestellung_erinnerungen (bestellung_id, faellig_am, nachricht, erstellt_von)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[bestellungId, data.titel, data.faellig_am, data.notizen || null, userId]
[bestellungId, data.faellig_am, data.nachricht || null, userId]
);
await logAction(bestellungId, 'Erinnerung erstellt', `"${data.titel}" fällig am ${data.faellig_am}`, userId);
await logAction(bestellungId, 'Erinnerung erstellt', `Erinnerung fällig am ${data.faellig_am}`, userId);
return result.rows[0];
} catch (error) {
logger.error('BestellungService.addReminder failed', { error, bestellungId });
@@ -502,7 +504,7 @@ async function markReminderDone(id: number, userId: string) {
if (result.rows.length === 0) return null;
const reminder = result.rows[0];
await logAction(reminder.bestellung_id, 'Erinnerung erledigt', `"${reminder.titel}"`, userId);
await logAction(reminder.bestellung_id, 'Erinnerung erledigt', `Erinnerung #${reminder.id}`, userId);
return reminder;
} catch (error) {
logger.error('BestellungService.markReminderDone failed', { error, id });
@@ -526,7 +528,7 @@ async function deleteReminder(id: number) {
async function getDueReminders() {
try {
const result = await pool.query(
`SELECT e.*, b.titel AS bestellung_titel, b.erstellt_von AS besteller_id
`SELECT e.*, b.bezeichnung AS bestellung_bezeichnung, b.erstellt_von AS besteller_id
FROM bestellung_erinnerungen e
JOIN bestellungen b ON b.id = e.bestellung_id
WHERE e.faellig_am <= NOW() AND e.erledigt = FALSE
@@ -546,9 +548,9 @@ async function getDueReminders() {
async function logAction(bestellungId: number, aktion: string, details: string, userId: string) {
try {
await pool.query(
`INSERT INTO bestellung_historie (bestellung_id, benutzer_id, aktion, details)
VALUES ($1, $2, $3, $4)`,
[bestellungId, userId, aktion, details]
`INSERT INTO bestellung_historie (bestellung_id, erstellt_von, aktion, details)
VALUES ($1, $2, $3, $4::jsonb)`,
[bestellungId, userId, aktion, JSON.stringify({ text: details })]
);
} catch (error) {
logger.error('BestellungService.logAction failed', { error, bestellungId, aktion });
@@ -561,7 +563,7 @@ async function getHistory(bestellungId: number) {
const result = await pool.query(
`SELECT h.*, u.display_name AS benutzer_name
FROM bestellung_historie h
LEFT JOIN users u ON u.id = h.benutzer_id
LEFT JOIN users u ON u.id = h.erstellt_von
WHERE h.bestellung_id = $1
ORDER BY h.erstellt_am DESC`,
[bestellungId]

View File

@@ -201,11 +201,13 @@ class BookingService {
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');
/** 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<FahrzeugBuchung> {
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(
@@ -219,9 +221,9 @@ class BookingService {
const query = `
INSERT INTO fahrzeug_buchungen
(fahrzeug_id, titel, beschreibung, beginn, ende, buchungs_art, gebucht_von, kontakt_person, kontakt_telefon)
(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)
($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9, $10)
RETURNING id
`;
@@ -235,6 +237,7 @@ class BookingService {
userId,
data.kontaktPerson ?? null,
data.kontaktTelefon ?? null,
data.ganztaegig ?? false,
]);
const newId: string = rows[0].id;

View File

@@ -0,0 +1,131 @@
import pool from '../config/database';
import logger from '../utils/logger';
export interface CleanupResult {
count: number;
deleted: boolean;
}
class CleanupService {
async cleanupNotifications(olderThanDays: number, confirm: boolean): Promise<CleanupResult> {
const cutoff = `${olderThanDays} days`;
if (!confirm) {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS count FROM benachrichtigungen WHERE erstellt_am < NOW() - $1::interval`,
[cutoff]
);
return { count: rows[0].count, deleted: false };
}
const { rowCount } = await pool.query(
`DELETE FROM benachrichtigungen WHERE erstellt_am < NOW() - $1::interval`,
[cutoff]
);
logger.info(`Cleanup: deleted ${rowCount} notifications older than ${olderThanDays} days`);
return { count: rowCount ?? 0, deleted: true };
}
async cleanupAuditLog(olderThanDays: number, confirm: boolean): Promise<CleanupResult> {
const cutoff = `${olderThanDays} days`;
if (!confirm) {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS count FROM audit_log WHERE created_at < NOW() - $1::interval`,
[cutoff]
);
return { count: rows[0].count, deleted: false };
}
const { rowCount } = await pool.query(
`DELETE FROM audit_log WHERE created_at < NOW() - $1::interval`,
[cutoff]
);
logger.info(`Cleanup: deleted ${rowCount} audit_log entries older than ${olderThanDays} days`);
return { count: rowCount ?? 0, deleted: true };
}
async cleanupEvents(olderThanDays: number, confirm: boolean): Promise<CleanupResult> {
const cutoff = `${olderThanDays} days`;
if (!confirm) {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS count FROM events WHERE end_date < NOW() - $1::interval`,
[cutoff]
);
return { count: rows[0].count, deleted: false };
}
const { rowCount } = await pool.query(
`DELETE FROM events WHERE end_date < NOW() - $1::interval`,
[cutoff]
);
logger.info(`Cleanup: deleted ${rowCount} events older than ${olderThanDays} days`);
return { count: rowCount ?? 0, deleted: true };
}
async cleanupBookings(olderThanDays: number, confirm: boolean): Promise<CleanupResult> {
const cutoff = `${olderThanDays} days`;
if (!confirm) {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS count FROM fahrzeug_buchungen WHERE end_date < NOW() - $1::interval AND status IN ('completed', 'cancelled')`,
[cutoff]
);
return { count: rows[0].count, deleted: false };
}
const { rowCount } = await pool.query(
`DELETE FROM fahrzeug_buchungen WHERE end_date < NOW() - $1::interval AND status IN ('completed', 'cancelled')`,
[cutoff]
);
logger.info(`Cleanup: deleted ${rowCount} bookings older than ${olderThanDays} days`);
return { count: rowCount ?? 0, deleted: true };
}
async cleanupOrders(olderThanDays: number, confirm: boolean): Promise<CleanupResult> {
const cutoff = `${olderThanDays} days`;
if (!confirm) {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS count FROM bestellungen WHERE updated_at < NOW() - $1::interval AND status = 'abgeschlossen'`,
[cutoff]
);
return { count: rows[0].count, deleted: false };
}
const { rowCount } = await pool.query(
`DELETE FROM bestellungen WHERE updated_at < NOW() - $1::interval AND status = 'abgeschlossen'`,
[cutoff]
);
logger.info(`Cleanup: deleted ${rowCount} orders older than ${olderThanDays} days`);
return { count: rowCount ?? 0, deleted: true };
}
async cleanupVehicleHistory(olderThanDays: number, confirm: boolean): Promise<CleanupResult> {
const cutoff = `${olderThanDays} days`;
if (!confirm) {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS count FROM fahrzeug_wartungslog WHERE datum < NOW() - $1::interval`,
[cutoff]
);
return { count: rows[0].count, deleted: false };
}
const { rowCount } = await pool.query(
`DELETE FROM fahrzeug_wartungslog WHERE datum < NOW() - $1::interval`,
[cutoff]
);
logger.info(`Cleanup: deleted ${rowCount} vehicle history entries older than ${olderThanDays} days`);
return { count: rowCount ?? 0, deleted: true };
}
async cleanupEquipmentHistory(olderThanDays: number, confirm: boolean): Promise<CleanupResult> {
const cutoff = `${olderThanDays} days`;
if (!confirm) {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS count FROM ausruestung_wartungslog WHERE datum < NOW() - $1::interval`,
[cutoff]
);
return { count: rows[0].count, deleted: false };
}
const { rowCount } = await pool.query(
`DELETE FROM ausruestung_wartungslog WHERE datum < NOW() - $1::interval`,
[cutoff]
);
logger.info(`Cleanup: deleted ${rowCount} equipment history entries older than ${olderThanDays} days`);
return { count: rowCount ?? 0, deleted: true };
}
}
export default new CleanupService();

View File

@@ -256,6 +256,13 @@ class EquipmentService {
updatedBy: string
): Promise<void> {
try {
// Get old status for history
const oldResult = await pool.query(
`SELECT status FROM ausruestung WHERE id = $1 AND deleted_at IS NULL`,
[id]
);
const oldStatus = oldResult.rows[0]?.status;
const result = await pool.query(
`UPDATE ausruestung
SET status = $1, status_bemerkung = $2, updated_at = NOW()
@@ -268,6 +275,15 @@ class EquipmentService {
throw new Error('Equipment not found');
}
// Record status change history
if (oldStatus && oldStatus !== status) {
await pool.query(
`INSERT INTO ausruestung_status_historie (ausruestung_id, alter_status, neuer_status, bemerkung, geaendert_von)
VALUES ($1, $2, $3, $4, $5)`,
[id, oldStatus, status, bemerkung || null, updatedBy]
);
}
logger.info('Equipment status updated', { id, status, by: updatedBy });
} catch (error) {
logger.error('EquipmentService.updateStatus failed', { error, id });
@@ -422,6 +438,48 @@ class EquipmentService {
throw new Error('Failed to fetch upcoming inspections');
}
}
// =========================================================================
// STATUS HISTORY
// =========================================================================
async getStatusHistory(equipmentId: string) {
try {
const result = await pool.query(
`SELECT h.*, u.display_name AS geaendert_von_name
FROM ausruestung_status_historie h
LEFT JOIN users u ON u.id = h.geaendert_von
WHERE h.ausruestung_id = $1
ORDER BY h.erstellt_am DESC
LIMIT 50`,
[equipmentId]
);
return result.rows;
} catch (error) {
logger.error('EquipmentService.getStatusHistory failed', { error, equipmentId });
throw new Error('Status-Historie konnte nicht geladen werden');
}
}
// =========================================================================
// WARTUNGSLOG FILE UPLOAD
// =========================================================================
async updateWartungslogFile(wartungId: number, filePath: string) {
try {
const result = await pool.query(
`UPDATE ausruestung_wartungslog SET dokument_url = $1 WHERE id = $2 RETURNING *`,
[filePath, wartungId]
);
if (result.rows.length === 0) {
throw new Error('Wartungseintrag nicht gefunden');
}
return result.rows[0];
} catch (error) {
logger.error('EquipmentService.updateWartungslogFile failed', { error, wartungId });
throw error;
}
}
}
export default new EquipmentService();

View File

@@ -390,60 +390,63 @@ class EventsService {
* Capped at 100 instances and 2 years from the start date. */
private generateRecurrenceDates(startDate: Date, _endDate: Date, config: WiederholungConfig): Date[] {
const dates: Date[] = [];
const limitDate = new Date(config.bis);
const limitDate = config.bis ? new Date(config.bis + 'T23:59:59Z') : new Date(0);
const interval = config.intervall ?? 1;
// Cap at 100 instances max, and 2 years
const maxDate = new Date(startDate);
maxDate.setFullYear(maxDate.getFullYear() + 2);
maxDate.setUTCFullYear(maxDate.getUTCFullYear() + 2);
const effectiveLimit = limitDate < maxDate ? limitDate : maxDate;
let current = new Date(startDate);
const originalDay = startDate.getDate();
// Work in UTC to avoid timezone shifts
let currentMs = startDate.getTime();
const originalDay = startDate.getUTCDate();
const startHours = startDate.getUTCHours();
const startMinutes = startDate.getUTCMinutes();
while (dates.length < 100) {
let current = new Date(currentMs);
// Advance to next occurrence
switch (config.typ) {
case 'wöchentlich':
current = new Date(current);
current.setDate(current.getDate() + 7 * interval);
current.setUTCDate(current.getUTCDate() + 7 * interval);
break;
case 'zweiwöchentlich':
current = new Date(current);
current.setDate(current.getDate() + 14);
current.setUTCDate(current.getUTCDate() + 14);
break;
case 'monatlich_datum': {
current = new Date(current);
const targetMonth = current.getMonth() + 1;
current.setDate(1);
current.setMonth(targetMonth);
const lastDay = new Date(current.getFullYear(), current.getMonth() + 1, 0).getDate();
current.setDate(Math.min(originalDay, lastDay));
const targetMonth = current.getUTCMonth() + interval;
current.setUTCDate(1);
current.setUTCMonth(targetMonth);
const lastDay = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() + 1, 0)).getUTCDate();
current.setUTCDate(Math.min(originalDay, lastDay));
current.setUTCHours(startHours, startMinutes, 0, 0);
break;
}
case 'monatlich_erster_wochentag': {
const targetWeekday = config.wochentag ?? 0; // 0=Mon
current = new Date(current);
current.setMonth(current.getMonth() + 1);
current.setDate(1);
current.setUTCMonth(current.getUTCMonth() + 1);
current.setUTCDate(1);
// Convert JS Sunday=0 to Monday=0: (getDay()+6)%7
while ((current.getDay() + 6) % 7 !== targetWeekday) {
current.setDate(current.getDate() + 1);
while ((current.getUTCDay() + 6) % 7 !== targetWeekday) {
current.setUTCDate(current.getUTCDate() + 1);
}
current.setUTCHours(startHours, startMinutes, 0, 0);
break;
}
case 'monatlich_letzter_wochentag': {
const targetWeekday = config.wochentag ?? 0;
current = new Date(current);
// Go to last day of next month
current.setMonth(current.getMonth() + 2);
current.setDate(0);
while ((current.getDay() + 6) % 7 !== targetWeekday) {
current.setDate(current.getDate() - 1);
current.setUTCMonth(current.getUTCMonth() + 2);
current.setUTCDate(0);
while ((current.getUTCDay() + 6) % 7 !== targetWeekday) {
current.setUTCDate(current.getUTCDate() - 1);
}
current.setUTCHours(startHours, startMinutes, 0, 0);
break;
}
}
if (current > effectiveLimit) break;
currentMs = current.getTime();
dates.push(new Date(current));
}
return dates;
@@ -515,16 +518,63 @@ class EventsService {
* Hard-deletes an event (and any recurrence children) from the database.
* Returns true if the event was found and deleted, false if not found.
*/
async deleteEvent(id: string): Promise<boolean> {
logger.info('Hard-deleting event', { id });
// Delete recurrence children first (wiederholung_parent_id references)
await pool.query(
`DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`,
async deleteEvent(id: string, mode: 'all' | 'single' | 'future' = 'all'): Promise<boolean> {
logger.info('Hard-deleting event', { id, mode });
if (mode === 'single') {
// Delete only this single instance
const result = await pool.query(
`DELETE FROM veranstaltungen WHERE id = $1`,
[id]
);
return (result.rowCount ?? 0) > 0;
}
if (mode === 'future') {
// Delete this instance and all later instances in the same series
const event = await pool.query(
`SELECT id, datum_von, wiederholung_parent_id FROM veranstaltungen WHERE id = $1`,
[id]
);
if (event.rows.length === 0) return false;
const row = event.rows[0];
const parentId = row.wiederholung_parent_id ?? row.id;
const datumVon = new Date(row.datum_von);
// Delete this instance and all siblings/children with datum_von >= this one
await pool.query(
`DELETE FROM veranstaltungen
WHERE (wiederholung_parent_id = $1 OR id = $1)
AND datum_von >= $2
AND id != $1`,
[parentId, datumVon]
);
// Also delete the selected instance itself
await pool.query(
`DELETE FROM veranstaltungen WHERE id = $1`,
[id]
);
return true;
}
// mode === 'all': Delete parent + all children (original behavior)
// First check if this is a child instance — find the parent
const event = await pool.query(
`SELECT id, wiederholung_parent_id FROM veranstaltungen WHERE id = $1`,
[id]
);
if (event.rows.length === 0) return false;
const parentId = event.rows[0].wiederholung_parent_id ?? id;
// Delete all children of the parent
await pool.query(
`DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`,
[parentId]
);
// Delete the parent itself
const result = await pool.query(
`DELETE FROM veranstaltungen WHERE id = $1`,
[id]
[parentId]
);
return (result.rowCount ?? 0) > 0;
}
@@ -603,9 +653,9 @@ class EventsService {
FROM (
SELECT unnest(authentik_groups) AS group_name
FROM users
WHERE is_active = true
WHERE authentik_groups IS NOT NULL
) g
WHERE group_name LIKE 'dashboard_%'
WHERE group_name != 'dashboard_admin'
ORDER BY group_name`
);

View File

@@ -36,17 +36,17 @@ async function createItem(
bezeichnung: string;
beschreibung?: string;
kategorie?: string;
geschaetzte_kosten?: number;
geschaetzter_preis?: number;
url?: string;
aktiv?: boolean;
},
userId: string,
) {
const result = await pool.query(
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzte_kosten, url, aktiv, erstellt_von)
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, url, aktiv, erstellt_von)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true), $7)
RETURNING *`,
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzte_kosten || null, data.url || null, data.aktiv ?? true, userId],
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.url || null, data.aktiv ?? true, userId],
);
return result.rows[0];
}
@@ -57,7 +57,7 @@ async function updateItem(
bezeichnung?: string;
beschreibung?: string;
kategorie?: string;
geschaetzte_kosten?: number;
geschaetzter_preis?: number;
url?: string;
aktiv?: boolean;
},
@@ -78,9 +78,9 @@ async function updateItem(
params.push(data.kategorie);
fields.push(`kategorie = $${params.length}`);
}
if (data.geschaetzte_kosten !== undefined) {
params.push(data.geschaetzte_kosten);
fields.push(`geschaetzte_kosten = $${params.length}`);
if (data.geschaetzter_preis !== undefined) {
params.push(data.geschaetzter_preis);
fields.push(`geschaetzter_preis = $${params.length}`);
}
if (data.url !== undefined) {
params.push(data.url);

View File

@@ -299,6 +299,15 @@ class VehicleService {
]
);
// Record status change history
if (oldStatus !== status) {
await client.query(
`INSERT INTO fahrzeug_status_historie (fahrzeug_id, alter_status, neuer_status, bemerkung, geaendert_von)
VALUES ($1, $2, $3, $4, $5)`,
[id, oldStatus, status, bemerkung || null, updatedBy]
);
}
await client.query('COMMIT');
logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy });
@@ -574,6 +583,48 @@ class VehicleService {
throw new Error('Failed to fetch inspection alerts');
}
}
// =========================================================================
// STATUS HISTORY
// =========================================================================
async getStatusHistory(fahrzeugId: string) {
try {
const result = await pool.query(
`SELECT h.*, u.display_name AS geaendert_von_name
FROM fahrzeug_status_historie h
LEFT JOIN users u ON u.id = h.geaendert_von
WHERE h.fahrzeug_id = $1
ORDER BY h.erstellt_am DESC
LIMIT 50`,
[fahrzeugId]
);
return result.rows;
} catch (error) {
logger.error('VehicleService.getStatusHistory failed', { error, fahrzeugId });
throw new Error('Status-Historie konnte nicht geladen werden');
}
}
// =========================================================================
// WARTUNGSLOG FILE UPLOAD
// =========================================================================
async updateWartungslogFile(wartungId: number, filePath: string) {
try {
const result = await pool.query(
`UPDATE fahrzeug_wartungslog SET dokument_url = $1 WHERE id = $2 RETURNING *`,
[filePath, wartungId]
);
if (result.rows.length === 0) {
throw new Error('Wartungseintrag nicht gefunden');
}
return result.rows[0];
} catch (error) {
logger.error('VehicleService.updateWartungslogFile failed', { error, wartungId });
throw error;
}
}
}
export default new VehicleService();