add now features
This commit is contained in:
@@ -20,26 +20,28 @@ app.use(cors({
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Rate limiting - general API routes
|
||||
const limiter = rateLimit({
|
||||
windowMs: environment.rateLimit.windowMs,
|
||||
max: environment.rateLimit.max,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
// Rate limiting - general API routes (applied below, after auth limiter)
|
||||
|
||||
// Rate limiting - auth routes (more generous to avoid blocking logins)
|
||||
// Rate limiting - auth routes (generous to avoid blocking logins during
|
||||
// normal use; each OAuth flow = 1 callback + token exchange)
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 30, // 30 auth attempts per window
|
||||
max: 60, // 60 auth attempts per window (allows ~20 full login cycles)
|
||||
message: 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
app.use('/api/auth', authLimiter);
|
||||
app.use('/api', limiter);
|
||||
// General rate limiter — skip auth routes (they have their own limiter above)
|
||||
app.use('/api', rateLimit({
|
||||
windowMs: environment.rateLimit.windowMs,
|
||||
max: environment.rateLimit.max,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.path.startsWith('/auth'),
|
||||
}));
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
@@ -74,16 +76,18 @@ import trainingRoutes from './routes/training.routes';
|
||||
import vehicleRoutes from './routes/vehicle.routes';
|
||||
import incidentRoutes from './routes/incident.routes';
|
||||
import equipmentRoutes from './routes/equipment.routes';
|
||||
import nextcloudRoutes from './routes/nextcloud.routes';
|
||||
import nextcloudRoutes from './routes/nextcloud.routes';
|
||||
import atemschutzRoutes from './routes/atemschutz.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
app.use('/api/members', memberRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/training', trainingRoutes);
|
||||
app.use('/api/vehicles', vehicleRoutes);
|
||||
app.use('/api/incidents', incidentRoutes);
|
||||
app.use('/api/equipment', equipmentRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
app.use('/api/members', memberRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/training', trainingRoutes);
|
||||
app.use('/api/vehicles', vehicleRoutes);
|
||||
app.use('/api/incidents', incidentRoutes);
|
||||
app.use('/api/equipment', equipmentRoutes);
|
||||
app.use('/api/atemschutz', atemschutzRoutes);
|
||||
app.use('/api/nextcloud/talk', nextcloudRoutes);
|
||||
|
||||
// 404 handler
|
||||
|
||||
139
backend/src/controllers/atemschutz.controller.ts
Normal file
139
backend/src/controllers/atemschutz.controller.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Request, Response } from 'express';
|
||||
import atemschutzService from '../services/atemschutz.service';
|
||||
import { CreateAtemschutzSchema, UpdateAtemschutzSchema } from '../models/atemschutz.model';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
// ── UUID validation ───────────────────────────────────────────────────────────
|
||||
|
||||
function isValidUUID(s: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getUserId(req: Request): string {
|
||||
return req.user!.id;
|
||||
}
|
||||
|
||||
// ── Controller ────────────────────────────────────────────────────────────────
|
||||
|
||||
class AtemschutzController {
|
||||
async list(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const records = await atemschutzService.getAll();
|
||||
res.status(200).json({ success: true, data: records });
|
||||
} catch (error) {
|
||||
logger.error('Atemschutz list error', { error });
|
||||
res.status(500).json({ success: false, message: 'Atemschutzträger konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getOne(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' });
|
||||
return;
|
||||
}
|
||||
const record = await atemschutzService.getById(id);
|
||||
if (!record) {
|
||||
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: record });
|
||||
} catch (error) {
|
||||
logger.error('Atemschutz getOne error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stats = await atemschutzService.getStats();
|
||||
res.status(200).json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
logger.error('Atemschutz getStats error', { error });
|
||||
res.status(500).json({ success: false, message: 'Atemschutz-Statistiken konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const parsed = CreateAtemschutzSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = await atemschutzService.create(parsed.data, getUserId(req));
|
||||
res.status(201).json({ success: true, data: record });
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('bereits ein Atemschutz-Eintrag')) {
|
||||
res.status(409).json({ success: false, message: error.message });
|
||||
return;
|
||||
}
|
||||
logger.error('Atemschutz create error', { error });
|
||||
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' });
|
||||
return;
|
||||
}
|
||||
const parsed = UpdateAtemschutzSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Object.keys(parsed.data).length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||
return;
|
||||
}
|
||||
const record = await atemschutzService.update(id, parsed.data, getUserId(req));
|
||||
if (!record) {
|
||||
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: record });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'No fields to update') {
|
||||
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||
return;
|
||||
}
|
||||
logger.error('Atemschutz update error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' });
|
||||
return;
|
||||
}
|
||||
const deleted = await atemschutzService.delete(id, getUserId(req));
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, message: 'Atemschutzträger gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Atemschutz delete error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AtemschutzController();
|
||||
129
backend/src/database/migrations/014_create_atemschutz.sql
Normal file
129
backend/src/database/migrations/014_create_atemschutz.sql
Normal file
@@ -0,0 +1,129 @@
|
||||
-- Migration: 014_create_atemschutz
|
||||
-- Atemschutz-Traegerverwaltung (Breathing Apparatus Carrier Management)
|
||||
-- Depends on: 001_create_users_table (users table, uuid-ossp, update_updated_at_column)
|
||||
-- 003_create_mitglieder_profile (mitglieder_profile for the view)
|
||||
-- Rollback:
|
||||
-- DROP VIEW IF EXISTS atemschutz_uebersicht;
|
||||
-- DROP TABLE IF EXISTS atemschutz_traeger;
|
||||
|
||||
-- ============================================================
|
||||
-- TABLE: atemschutz_traeger (BA Carrier Registry)
|
||||
-- Each row = one firefighter qualified/tracked for BA use.
|
||||
-- One record per user (enforced by UNIQUE constraint).
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS atemschutz_traeger (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Atemschutz-Lehrgang (BA course qualification)
|
||||
atemschutz_lehrgang BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
lehrgang_datum DATE,
|
||||
|
||||
-- G26.3 Aerztliche Untersuchung (doctor's examination)
|
||||
untersuchung_datum DATE,
|
||||
untersuchung_gueltig_bis DATE, -- typically valid 3 years
|
||||
untersuchung_ergebnis VARCHAR(30)
|
||||
CHECK (untersuchung_ergebnis IS NULL OR untersuchung_ergebnis IN (
|
||||
'tauglich', 'bedingt_tauglich', 'nicht_tauglich'
|
||||
)),
|
||||
|
||||
-- Leistungstest / Finnentest (performance test)
|
||||
leistungstest_datum DATE,
|
||||
leistungstest_gueltig_bis DATE, -- typically valid 1 year
|
||||
leistungstest_bestanden BOOLEAN,
|
||||
|
||||
-- Free-text notes (Kommandant / Atemschutzwart)
|
||||
bemerkung TEXT,
|
||||
|
||||
-- Audit timestamps
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- One record per user
|
||||
CONSTRAINT uq_atemschutz_user UNIQUE (user_id)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- Indexes for the most common query patterns
|
||||
-- ============================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_atemschutz_user
|
||||
ON atemschutz_traeger(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atemschutz_untersuchung
|
||||
ON atemschutz_traeger(untersuchung_gueltig_bis)
|
||||
WHERE untersuchung_gueltig_bis IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atemschutz_leistungstest
|
||||
ON atemschutz_traeger(leistungstest_gueltig_bis)
|
||||
WHERE leistungstest_gueltig_bis IS NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Auto-update trigger for updated_at
|
||||
-- Reuses the function already created by migration 001.
|
||||
-- ============================================================
|
||||
CREATE TRIGGER update_atemschutz_updated_at
|
||||
BEFORE UPDATE ON atemschutz_traeger
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ============================================================
|
||||
-- VIEW: atemschutz_uebersicht
|
||||
-- Dashboard view with user info and computed validity status.
|
||||
-- Joins user + mitglieder_profile for display name, rank, etc.
|
||||
-- Computes expiry flags so the frontend can render traffic-light
|
||||
-- indicators without any date math on the client side.
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE VIEW atemschutz_uebersicht AS
|
||||
SELECT
|
||||
at.*,
|
||||
u.name AS user_name,
|
||||
u.given_name AS user_given_name,
|
||||
u.family_name AS user_family_name,
|
||||
u.email AS user_email,
|
||||
mp.status AS mitglied_status,
|
||||
mp.dienstgrad,
|
||||
|
||||
-- Computed: is the medical exam still valid?
|
||||
CASE
|
||||
WHEN at.untersuchung_gueltig_bis IS NOT NULL
|
||||
THEN at.untersuchung_gueltig_bis >= CURRENT_DATE
|
||||
ELSE FALSE
|
||||
END AS untersuchung_gueltig,
|
||||
|
||||
-- Computed: days until medical exam expires (negative = expired)
|
||||
CASE
|
||||
WHEN at.untersuchung_gueltig_bis IS NOT NULL
|
||||
THEN at.untersuchung_gueltig_bis::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS untersuchung_tage_rest,
|
||||
|
||||
-- Computed: is the performance test still valid?
|
||||
CASE
|
||||
WHEN at.leistungstest_gueltig_bis IS NOT NULL
|
||||
THEN at.leistungstest_gueltig_bis >= CURRENT_DATE
|
||||
ELSE FALSE
|
||||
END AS leistungstest_gueltig,
|
||||
|
||||
-- Computed: days until performance test expires (negative = expired)
|
||||
CASE
|
||||
WHEN at.leistungstest_gueltig_bis IS NOT NULL
|
||||
THEN at.leistungstest_gueltig_bis::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS leistungstest_tage_rest,
|
||||
|
||||
-- Computed: fully qualified for BA use (einsatzbereit)?
|
||||
-- Requires: course done + valid medical (tauglich) + valid performance test (bestanden)
|
||||
CASE
|
||||
WHEN at.atemschutz_lehrgang = TRUE
|
||||
AND at.untersuchung_gueltig_bis IS NOT NULL
|
||||
AND at.untersuchung_gueltig_bis >= CURRENT_DATE
|
||||
AND at.untersuchung_ergebnis = 'tauglich'
|
||||
AND at.leistungstest_gueltig_bis IS NOT NULL
|
||||
AND at.leistungstest_gueltig_bis >= CURRENT_DATE
|
||||
AND at.leistungstest_bestanden = TRUE
|
||||
THEN TRUE
|
||||
ELSE FALSE
|
||||
END AS einsatzbereit
|
||||
|
||||
FROM atemschutz_traeger at
|
||||
JOIN users u ON u.id = at.user_id
|
||||
LEFT JOIN mitglieder_profile mp ON mp.user_id = at.user_id;
|
||||
99
backend/src/models/atemschutz.model.ts
Normal file
99
backend/src/models/atemschutz.model.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// =============================================================================
|
||||
// Atemschutz (Breathing Apparatus) — Domain Model
|
||||
// =============================================================================
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Enums ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const UNTERSUCHUNG_ERGEBNIS_VALUES = ['tauglich', 'bedingt_tauglich', 'nicht_tauglich'] as const;
|
||||
export type UntersuchungErgebnis = typeof UNTERSUCHUNG_ERGEBNIS_VALUES[number];
|
||||
|
||||
// ── Core DB Row ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AtemschutzTraeger {
|
||||
id: string;
|
||||
user_id: string;
|
||||
atemschutz_lehrgang: boolean;
|
||||
lehrgang_datum: Date | null;
|
||||
untersuchung_datum: Date | null;
|
||||
untersuchung_gueltig_bis: Date | null;
|
||||
untersuchung_ergebnis: UntersuchungErgebnis | null;
|
||||
leistungstest_datum: Date | null;
|
||||
leistungstest_gueltig_bis: Date | null;
|
||||
leistungstest_bestanden: boolean | null;
|
||||
bemerkung: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ── View Row (extended with user info + computed fields) ────────────────────
|
||||
|
||||
export interface AtemschutzUebersicht extends AtemschutzTraeger {
|
||||
user_name: string | null;
|
||||
user_given_name: string | null;
|
||||
user_family_name: string | null;
|
||||
user_email: string;
|
||||
mitglied_status: string | null;
|
||||
dienstgrad: string | null;
|
||||
untersuchung_gueltig: boolean;
|
||||
untersuchung_tage_rest: number | null;
|
||||
leistungstest_gueltig: boolean;
|
||||
leistungstest_tage_rest: number | null;
|
||||
einsatzbereit: boolean;
|
||||
}
|
||||
|
||||
// ── Dashboard KPI ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AtemschutzStats {
|
||||
total: number; // total tracked members
|
||||
mitLehrgang: number; // have completed course
|
||||
untersuchungGueltig: number; // valid medical exam
|
||||
untersuchungAbgelaufen: number; // expired medical exam
|
||||
untersuchungBaldFaellig: number; // expires within 90 days
|
||||
leistungstestGueltig: number; // valid performance test
|
||||
leistungstestAbgelaufen: number; // expired performance test
|
||||
leistungstestBaldFaellig: number; // expires within 30 days
|
||||
einsatzbereit: number; // fully qualified
|
||||
}
|
||||
|
||||
// ── Zod Validation Schemas ──────────────────────────────────────────────────
|
||||
|
||||
const uuidString = z.string().regex(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
'Ungültige UUID'
|
||||
);
|
||||
|
||||
const isoDate = z.string().regex(
|
||||
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||||
'Erwartet ISO-Datum im Format YYYY-MM-DD'
|
||||
);
|
||||
|
||||
export const CreateAtemschutzSchema = z.object({
|
||||
user_id: uuidString,
|
||||
atemschutz_lehrgang: z.boolean().default(false),
|
||||
lehrgang_datum: isoDate.optional(),
|
||||
untersuchung_datum: isoDate.optional(),
|
||||
untersuchung_gueltig_bis: isoDate.optional(),
|
||||
untersuchung_ergebnis: z.enum(UNTERSUCHUNG_ERGEBNIS_VALUES).optional(),
|
||||
leistungstest_datum: isoDate.optional(),
|
||||
leistungstest_gueltig_bis: isoDate.optional(),
|
||||
leistungstest_bestanden: z.boolean().optional(),
|
||||
bemerkung: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export type CreateAtemschutzData = z.infer<typeof CreateAtemschutzSchema>;
|
||||
|
||||
export const UpdateAtemschutzSchema = z.object({
|
||||
atemschutz_lehrgang: z.boolean().optional(),
|
||||
lehrgang_datum: isoDate.nullable().optional(),
|
||||
untersuchung_datum: isoDate.nullable().optional(),
|
||||
untersuchung_gueltig_bis: isoDate.nullable().optional(),
|
||||
untersuchung_ergebnis: z.enum(UNTERSUCHUNG_ERGEBNIS_VALUES).nullable().optional(),
|
||||
leistungstest_datum: isoDate.nullable().optional(),
|
||||
leistungstest_gueltig_bis: isoDate.nullable().optional(),
|
||||
leistungstest_bestanden: z.boolean().nullable().optional(),
|
||||
bemerkung: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdateAtemschutzData = z.infer<typeof UpdateAtemschutzSchema>;
|
||||
26
backend/src/routes/atemschutz.routes.ts
Normal file
26
backend/src/routes/atemschutz.routes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Router } from 'express';
|
||||
import atemschutzController from '../controllers/atemschutz.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requireGroups } from '../middleware/rbac.middleware';
|
||||
|
||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommandant'];
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Read-only (any authenticated user) ───────────────────────────────────────
|
||||
|
||||
router.get('/', authenticate, atemschutzController.list.bind(atemschutzController));
|
||||
router.get('/stats', authenticate, atemschutzController.getStats.bind(atemschutzController));
|
||||
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
|
||||
|
||||
// ── Write — admin + kommandant ───────────────────────────────────────────────
|
||||
|
||||
router.post('/', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.create.bind(atemschutzController));
|
||||
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.update.bind(atemschutzController));
|
||||
|
||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
||||
|
||||
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), atemschutzController.delete.bind(atemschutzController));
|
||||
|
||||
export default router;
|
||||
262
backend/src/services/atemschutz.service.ts
Normal file
262
backend/src/services/atemschutz.service.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
import {
|
||||
AtemschutzTraeger,
|
||||
AtemschutzUebersicht,
|
||||
AtemschutzStats,
|
||||
CreateAtemschutzData,
|
||||
UpdateAtemschutzData,
|
||||
} from '../models/atemschutz.model';
|
||||
|
||||
class AtemschutzService {
|
||||
// =========================================================================
|
||||
// ÜBERSICHT (ALL RECORDS)
|
||||
// =========================================================================
|
||||
|
||||
async getAll(): Promise<AtemschutzUebersicht[]> {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT *
|
||||
FROM atemschutz_uebersicht
|
||||
WHERE mitglied_status IN ('aktiv', 'anwärter')
|
||||
ORDER BY user_family_name, user_given_name
|
||||
`);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
...row,
|
||||
untersuchung_tage_rest: row.untersuchung_tage_rest != null
|
||||
? parseInt(row.untersuchung_tage_rest, 10) : null,
|
||||
leistungstest_tage_rest: row.leistungstest_tage_rest != null
|
||||
? parseInt(row.leistungstest_tage_rest, 10) : null,
|
||||
})) as AtemschutzUebersicht[];
|
||||
} catch (error) {
|
||||
logger.error('AtemschutzService.getAll fehlgeschlagen', { error });
|
||||
throw new Error('Atemschutzträger konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EINZELNER DATENSATZ (BY ID)
|
||||
// =========================================================================
|
||||
|
||||
async getById(id: string): Promise<AtemschutzUebersicht | null> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM atemschutz_uebersicht WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
...row,
|
||||
untersuchung_tage_rest: row.untersuchung_tage_rest != null
|
||||
? parseInt(row.untersuchung_tage_rest, 10) : null,
|
||||
leistungstest_tage_rest: row.leistungstest_tage_rest != null
|
||||
? parseInt(row.leistungstest_tage_rest, 10) : null,
|
||||
} as AtemschutzUebersicht;
|
||||
} catch (error) {
|
||||
logger.error('AtemschutzService.getById fehlgeschlagen', { error, id });
|
||||
throw new Error('Atemschutzträger konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EINZELNER DATENSATZ (BY USER_ID)
|
||||
// =========================================================================
|
||||
|
||||
async getByUserId(userId: string): Promise<AtemschutzUebersicht | null> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM atemschutz_uebersicht WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
...row,
|
||||
untersuchung_tage_rest: row.untersuchung_tage_rest != null
|
||||
? parseInt(row.untersuchung_tage_rest, 10) : null,
|
||||
leistungstest_tage_rest: row.leistungstest_tage_rest != null
|
||||
? parseInt(row.leistungstest_tage_rest, 10) : null,
|
||||
} as AtemschutzUebersicht;
|
||||
} catch (error) {
|
||||
logger.error('AtemschutzService.getByUserId fehlgeschlagen', { error, userId });
|
||||
throw new Error('Atemschutzträger konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CREATE
|
||||
// =========================================================================
|
||||
|
||||
async create(data: CreateAtemschutzData, createdBy: string): Promise<AtemschutzTraeger> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO atemschutz_traeger (
|
||||
id, user_id, atemschutz_lehrgang, lehrgang_datum,
|
||||
untersuchung_datum, untersuchung_gueltig_bis, untersuchung_ergebnis,
|
||||
leistungstest_datum, leistungstest_gueltig_bis, leistungstest_bestanden,
|
||||
bemerkung
|
||||
) VALUES (uuid_generate_v4(),$1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.user_id,
|
||||
data.atemschutz_lehrgang ?? false,
|
||||
data.lehrgang_datum ?? null,
|
||||
data.untersuchung_datum ?? null,
|
||||
data.untersuchung_gueltig_bis ?? null,
|
||||
data.untersuchung_ergebnis ?? null,
|
||||
data.leistungstest_datum ?? null,
|
||||
data.leistungstest_gueltig_bis ?? null,
|
||||
data.leistungstest_bestanden ?? null,
|
||||
data.bemerkung ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
const record = result.rows[0] as AtemschutzTraeger;
|
||||
logger.info('Atemschutzträger erstellt', { id: record.id, userId: data.user_id, by: createdBy });
|
||||
return record;
|
||||
} catch (error: any) {
|
||||
if (error?.code === '23505') {
|
||||
// unique constraint on user_id
|
||||
throw new Error('Für diesen Benutzer existiert bereits ein Atemschutz-Eintrag');
|
||||
}
|
||||
logger.error('AtemschutzService.create fehlgeschlagen', { error, createdBy });
|
||||
throw new Error('Atemschutzträger konnte nicht erstellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UPDATE
|
||||
// =========================================================================
|
||||
|
||||
async update(id: string, data: UpdateAtemschutzData, updatedBy: string): Promise<AtemschutzTraeger | null> {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let p = 1;
|
||||
|
||||
const addField = (col: string, value: unknown) => {
|
||||
fields.push(`${col} = $${p++}`);
|
||||
values.push(value);
|
||||
};
|
||||
|
||||
if (data.atemschutz_lehrgang !== undefined) addField('atemschutz_lehrgang', data.atemschutz_lehrgang);
|
||||
if (data.lehrgang_datum !== undefined) addField('lehrgang_datum', data.lehrgang_datum);
|
||||
if (data.untersuchung_datum !== undefined) addField('untersuchung_datum', data.untersuchung_datum);
|
||||
if (data.untersuchung_gueltig_bis !== undefined) addField('untersuchung_gueltig_bis', data.untersuchung_gueltig_bis);
|
||||
if (data.untersuchung_ergebnis !== undefined) addField('untersuchung_ergebnis', data.untersuchung_ergebnis);
|
||||
if (data.leistungstest_datum !== undefined) addField('leistungstest_datum', data.leistungstest_datum);
|
||||
if (data.leistungstest_gueltig_bis !== undefined) addField('leistungstest_gueltig_bis', data.leistungstest_gueltig_bis);
|
||||
if (data.leistungstest_bestanden !== undefined) addField('leistungstest_bestanden', data.leistungstest_bestanden);
|
||||
if (data.bemerkung !== undefined) addField('bemerkung', data.bemerkung);
|
||||
|
||||
if (fields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
// always bump updated_at
|
||||
fields.push(`updated_at = NOW()`);
|
||||
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE atemschutz_traeger SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = result.rows[0] as AtemschutzTraeger;
|
||||
logger.info('Atemschutzträger aktualisiert', { id, by: updatedBy });
|
||||
return record;
|
||||
} catch (error) {
|
||||
logger.error('AtemschutzService.update fehlgeschlagen', { error, id, updatedBy });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DELETE (hard delete — qualification record)
|
||||
// =========================================================================
|
||||
|
||||
async delete(id: string, deletedBy: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM atemschutz_traeger WHERE id = $1 RETURNING id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('Atemschutzträger gelöscht', { id, by: deletedBy });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('AtemschutzService.delete fehlgeschlagen', { error, id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DASHBOARD KPI / STATISTIKEN
|
||||
// =========================================================================
|
||||
|
||||
async getStats(): Promise<AtemschutzStats> {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE atemschutz_lehrgang = TRUE) AS mit_lehrgang,
|
||||
COUNT(*) FILTER (WHERE untersuchung_gueltig = TRUE) AS untersuchung_gueltig,
|
||||
COUNT(*) FILTER (
|
||||
WHERE untersuchung_gueltig_bis IS NOT NULL
|
||||
AND untersuchung_gueltig_bis < CURRENT_DATE
|
||||
) AS untersuchung_abgelaufen,
|
||||
COUNT(*) FILTER (
|
||||
WHERE untersuchung_gueltig_bis IS NOT NULL
|
||||
AND untersuchung_gueltig_bis >= CURRENT_DATE
|
||||
AND untersuchung_gueltig_bis <= CURRENT_DATE + INTERVAL '90 days'
|
||||
) AS untersuchung_bald_faellig,
|
||||
COUNT(*) FILTER (WHERE leistungstest_gueltig = TRUE) AS leistungstest_gueltig,
|
||||
COUNT(*) FILTER (
|
||||
WHERE leistungstest_gueltig_bis IS NOT NULL
|
||||
AND leistungstest_gueltig_bis < CURRENT_DATE
|
||||
) AS leistungstest_abgelaufen,
|
||||
COUNT(*) FILTER (
|
||||
WHERE leistungstest_gueltig_bis IS NOT NULL
|
||||
AND leistungstest_gueltig_bis >= CURRENT_DATE
|
||||
AND leistungstest_gueltig_bis <= CURRENT_DATE + INTERVAL '30 days'
|
||||
) AS leistungstest_bald_faellig,
|
||||
COUNT(*) FILTER (WHERE einsatzbereit = TRUE) AS einsatzbereit
|
||||
FROM atemschutz_uebersicht
|
||||
WHERE mitglied_status IN ('aktiv', 'anwärter')
|
||||
`);
|
||||
|
||||
const row = result.rows[0] ?? {};
|
||||
|
||||
return {
|
||||
total: parseInt(row.total ?? '0', 10),
|
||||
mitLehrgang: parseInt(row.mit_lehrgang ?? '0', 10),
|
||||
untersuchungGueltig: parseInt(row.untersuchung_gueltig ?? '0', 10),
|
||||
untersuchungAbgelaufen: parseInt(row.untersuchung_abgelaufen ?? '0', 10),
|
||||
untersuchungBaldFaellig: parseInt(row.untersuchung_bald_faellig ?? '0', 10),
|
||||
leistungstestGueltig: parseInt(row.leistungstest_gueltig ?? '0', 10),
|
||||
leistungstestAbgelaufen: parseInt(row.leistungstest_abgelaufen ?? '0', 10),
|
||||
leistungstestBaldFaellig: parseInt(row.leistungstest_bald_faellig ?? '0', 10),
|
||||
einsatzbereit: parseInt(row.einsatzbereit ?? '0', 10),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('AtemschutzService.getStats fehlgeschlagen', { error });
|
||||
throw new Error('Atemschutz-Statistiken konnten nicht geladen werden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AtemschutzService();
|
||||
Reference in New Issue
Block a user