add features

This commit is contained in:
Matthias Hochmeister
2026-03-03 17:01:53 +01:00
parent 92b05726d4
commit 5a6fc85a75
30 changed files with 1104 additions and 198 deletions

View File

@@ -118,6 +118,19 @@ class EquipmentService {
}
}
async getCategoryById(id: string): Promise<AusruestungKategorie | null> {
try {
const result = await pool.query(
`SELECT * FROM ausruestung_kategorien WHERE id = $1`,
[id]
);
return result.rows.length > 0 ? (result.rows[0] as AusruestungKategorie) : null;
} catch (error) {
logger.error('EquipmentService.getCategoryById failed', { error, id });
throw new Error('Failed to fetch equipment category');
}
}
// =========================================================================
// CRUD
// =========================================================================

View File

@@ -110,7 +110,7 @@ class EventsService {
/** Returns all event categories ordered by name. */
async getKategorien(): Promise<VeranstaltungKategorie[]> {
const result = await pool.query(`
SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am
SELECT id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am
FROM veranstaltung_kategorien
ORDER BY name ASC
`);
@@ -121,6 +121,7 @@ class EventsService {
farbe: row.farbe ?? null,
icon: row.icon ?? null,
zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen ?? false,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
@@ -130,10 +131,10 @@ class EventsService {
/** 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, zielgruppen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`,
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], userId]
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am`,
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], data.alle_gruppen ?? false, userId]
);
const row = result.rows[0];
return {
@@ -143,6 +144,7 @@ class EventsService {
farbe: row.farbe ?? null,
icon: row.icon ?? null,
zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen ?? false,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
@@ -160,11 +162,12 @@ class EventsService {
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 (data.zielgruppen !== undefined) { fields.push(`zielgruppen = $${idx++}`); values.push(data.zielgruppen); }
if (data.alle_gruppen !== undefined) { fields.push(`alle_gruppen = $${idx++}`); values.push(data.alle_gruppen); }
if (fields.length === 0) {
// Nothing to update — return the existing record
const existing = await pool.query(
`SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am
`SELECT id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am
FROM veranstaltung_kategorien WHERE id = $1`,
[id]
);
@@ -173,6 +176,7 @@ class EventsService {
return {
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen ?? false,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
};
@@ -184,7 +188,7 @@ class EventsService {
const result = await pool.query(
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
WHERE id = $${idx}
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`,
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am`,
values
);
if (result.rows.length === 0) return null;
@@ -192,6 +196,7 @@ class EventsService {
return {
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
alle_gruppen: row.alle_gruppen ?? false,
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
};

View File

@@ -0,0 +1,131 @@
// =============================================================================
// Notification Service
// =============================================================================
import pool from '../config/database';
import logger from '../utils/logger';
import { Notification, CreateNotificationData } from '../models/notification.model';
function rowToNotification(row: any): Notification {
return {
id: row.id,
user_id: row.user_id,
typ: row.typ,
titel: row.titel,
nachricht: row.nachricht,
schwere: row.schwere,
gelesen: row.gelesen,
gelesen_am: row.gelesen_am ? new Date(row.gelesen_am) : null,
link: row.link ?? null,
quell_id: row.quell_id ?? null,
quell_typ: row.quell_typ ?? null,
erstellt_am: new Date(row.erstellt_am),
};
}
class NotificationService {
/** Returns all notifications for a user (newest first, max 100). */
async getByUser(userId: string): Promise<Notification[]> {
try {
const result = await pool.query(
`SELECT * FROM notifications WHERE user_id = $1 ORDER BY erstellt_am DESC LIMIT 100`,
[userId]
);
return result.rows.map(rowToNotification);
} catch (error) {
logger.error('NotificationService.getByUser failed', { error, userId });
throw new Error('Notifications konnten nicht geladen werden');
}
}
/** Returns the count of unread notifications for a user. */
async getUnreadCount(userId: string): Promise<number> {
try {
const result = await pool.query(
`SELECT COUNT(*) AS cnt FROM notifications WHERE user_id = $1 AND gelesen = FALSE`,
[userId]
);
return parseInt(result.rows[0].cnt, 10);
} catch (error) {
logger.error('NotificationService.getUnreadCount failed', { error, userId });
throw new Error('Ungelesene Notifications konnten nicht gezählt werden');
}
}
/** Marks a single notification as read. Returns false if not found or not owned by user. */
async markAsRead(id: string, userId: string): Promise<boolean> {
try {
const result = await pool.query(
`UPDATE notifications
SET gelesen = TRUE, gelesen_am = NOW()
WHERE id = $1 AND user_id = $2 AND gelesen = FALSE
RETURNING id`,
[id, userId]
);
return (result.rowCount ?? 0) > 0;
} catch (error) {
logger.error('NotificationService.markAsRead failed', { error, id, userId });
throw new Error('Notification konnte nicht als gelesen markiert werden');
}
}
/** Marks all notifications as read for a user. */
async markAllRead(userId: string): Promise<void> {
try {
await pool.query(
`UPDATE notifications SET gelesen = TRUE, gelesen_am = NOW()
WHERE user_id = $1 AND gelesen = FALSE`,
[userId]
);
} catch (error) {
logger.error('NotificationService.markAllRead failed', { error, userId });
throw new Error('Notifications konnten nicht als gelesen markiert werden');
}
}
/**
* Creates a notification. If quell_typ + quell_id are provided the insert is
* silently ignored when an identical unread notification already exists
* (dedup via unique index).
*/
async createNotification(data: CreateNotificationData): Promise<void> {
try {
await pool.query(
`INSERT INTO notifications
(user_id, typ, titel, nachricht, schwere, link, quell_id, quell_typ)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id, quell_typ, quell_id) WHERE NOT gelesen AND quell_typ IS NOT NULL AND quell_id IS NOT NULL
DO NOTHING`,
[
data.user_id,
data.typ,
data.titel,
data.nachricht,
data.schwere ?? 'info',
data.link ?? null,
data.quell_id ?? null,
data.quell_typ ?? null,
]
);
} catch (error) {
logger.error('NotificationService.createNotification failed', { error });
// Non-fatal — don't propagate to callers
}
}
/** Deletes read notifications older than 90 days for all users. */
async deleteOldRead(): Promise<void> {
try {
const result = await pool.query(
`DELETE FROM notifications WHERE gelesen = TRUE AND gelesen_am < NOW() - INTERVAL '90 days'`
);
if ((result.rowCount ?? 0) > 0) {
logger.info(`NotificationService.deleteOldRead: removed ${result.rowCount} old notifications`);
}
} catch (error) {
logger.error('NotificationService.deleteOldRead failed', { error });
}
}
}
export default new NotificationService();