Files
dashboard/backend/src/models/training.model.ts
Matthias Hochmeister 8b3842a9fc fix backend
2026-02-27 20:39:11 +01:00

207 lines
6.3 KiB
TypeScript

import { z } from 'zod';
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
export const UEBUNG_TYPEN = [
'Übungsabend',
'Lehrgang',
'Sonderdienst',
'Versammlung',
'Gemeinschaftsübung',
'Sonstiges',
] as const;
export type UebungTyp = (typeof UEBUNG_TYPEN)[number];
export const TEILNAHME_STATUSES = [
'zugesagt',
'abgesagt',
'erschienen',
'entschuldigt',
'unbekannt',
] as const;
export type TeilnahmeStatus = (typeof TEILNAHME_STATUSES)[number];
// ---------------------------------------------------------------------------
// Core DB-mapped interfaces
// ---------------------------------------------------------------------------
export interface Uebung {
id: string;
titel: string;
beschreibung?: string | null;
typ: UebungTyp;
datum_von: Date;
datum_bis: Date;
ort?: string | null;
treffpunkt?: string | null;
pflichtveranstaltung: boolean;
mindest_teilnehmer?: number | null;
max_teilnehmer?: number | null;
angelegt_von?: string | null;
erstellt_am: Date;
aktualisiert_am: Date;
abgesagt: boolean;
absage_grund?: string | null;
}
export interface Teilnahme {
uebung_id: string;
user_id: string;
status: TeilnahmeStatus;
antwort_am?: Date | null;
erschienen_erfasst_am?: Date | null;
erschienen_erfasst_von?: string | null;
bemerkung?: string | null;
// Joined from users table
user_name?: string | null;
user_email?: string | null;
}
// ---------------------------------------------------------------------------
// Enriched / view-based interfaces
// ---------------------------------------------------------------------------
export interface AttendanceCounts {
gesamt_eingeladen: number;
anzahl_zugesagt: number;
anzahl_abgesagt: number;
anzahl_erschienen: number;
anzahl_entschuldigt: number;
anzahl_unbekannt: number;
}
/** Full event object including all attendance data — used in detail page */
export interface UebungWithAttendance extends Uebung, AttendanceCounts {
/** Only populated for users with training:write or own role >= Gruppenführer */
teilnahmen?: Teilnahme[];
/** The requesting user's own RSVP status, always included */
eigener_status?: TeilnahmeStatus;
/** Name of the person who created the event */
angelegt_von_name?: string | null;
}
/** Lightweight list item — used in calendar, upcoming list widget */
export interface UebungListItem {
id: string;
titel: string;
typ: UebungTyp;
datum_von: Date;
datum_bis: Date;
ort?: string | null;
pflichtveranstaltung: boolean;
abgesagt: boolean;
anzahl_zugesagt: number;
anzahl_erschienen: number;
gesamt_eingeladen: number;
/** Requesting user's own status — undefined when called without userId */
eigener_status?: TeilnahmeStatus;
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
/** Monthly training summary for dashboard stats card */
export interface TrainingStats {
/** e.g. "2026-02" */
month: string;
/** Total events that month */
total: number;
/** Pflichtveranstaltungen that month */
pflicht: number;
/** % of events (Übungsabende only) a given user attended — 0-100 */
attendanceRate: number;
}
/** Per-member annual participation stats for Jahresbericht (Tier 3) */
export interface MemberParticipationStats {
userId: string;
name: string;
totalUebungen: number;
attended: number;
attendancePercent: number;
pflichtGesamt: number;
pflichtErschienen: number;
uebungsabendQuotePct: number;
}
// ---------------------------------------------------------------------------
// Zod validation schemas — used in service layer and route middleware
// ---------------------------------------------------------------------------
const UebungBaseSchema = z.object({
titel: z
.string()
.min(3, 'Titel muss mindestens 3 Zeichen haben')
.max(255),
beschreibung: z.string().max(5000).optional().nullable(),
typ: z.enum(UEBUNG_TYPEN),
datum_von: z
.string()
.datetime({ offset: true, message: 'datum_von muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s)),
datum_bis: z
.string()
.datetime({ offset: true, message: 'datum_bis muss ein ISO-8601 Datum mit Zeitzone sein' })
.transform((s) => new Date(s)),
ort: z.string().max(255).optional().nullable(),
treffpunkt: z.string().max(255).optional().nullable(),
pflichtveranstaltung: z.boolean().default(false),
mindest_teilnehmer: z.number().int().positive().optional().nullable(),
max_teilnehmer: z.number().int().positive().optional().nullable(),
});
export const CreateUebungSchema = UebungBaseSchema.refine(
(d) => d.datum_bis >= d.datum_von,
{ message: 'datum_bis muss nach datum_von liegen', path: ['datum_bis'] }
).refine(
(d) =>
d.max_teilnehmer == null ||
d.mindest_teilnehmer == null ||
d.max_teilnehmer >= d.mindest_teilnehmer,
{ message: 'max_teilnehmer muss >= mindest_teilnehmer sein', path: ['max_teilnehmer'] }
);
export type CreateUebungData = z.infer<typeof CreateUebungSchema>;
export const UpdateUebungSchema = UebungBaseSchema.partial().refine(
(d) => d.datum_bis == null || d.datum_von == null || d.datum_bis >= d.datum_von,
{ message: 'datum_bis muss nach datum_von liegen', path: ['datum_bis'] }
).refine(
(d) =>
d.max_teilnehmer == null ||
d.mindest_teilnehmer == null ||
d.max_teilnehmer >= d.mindest_teilnehmer,
{ message: 'max_teilnehmer muss >= mindest_teilnehmer sein', path: ['max_teilnehmer'] }
);
export type UpdateUebungData = z.infer<typeof UpdateUebungSchema>;
export const UpdateRsvpSchema = z.object({
status: z.enum(['zugesagt', 'abgesagt']),
bemerkung: z.string().max(500).optional().nullable(),
});
export type UpdateRsvpData = z.infer<typeof UpdateRsvpSchema>;
export const MarkAttendanceSchema = z.object({
userIds: z
.array(z.string().uuid())
.min(1, 'Mindestens eine Person muss ausgewählt werden'),
});
export type MarkAttendanceData = z.infer<typeof MarkAttendanceSchema>;
export const CancelEventSchema = z.object({
absage_grund: z
.string()
.min(5, 'Bitte gib einen Grund für die Absage an (min. 5 Zeichen)')
.max(1000),
});
export type CancelEventData = z.infer<typeof CancelEventSchema>;