new features
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
131
backend/src/services/cleanup.service.ts
Normal file
131
backend/src/services/cleanup.service.ts
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user