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; 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; export const UpdateRsvpSchema = z.object({ status: z.enum(['zugesagt', 'abgesagt']), bemerkung: z.string().max(500).optional().nullable(), }); export type UpdateRsvpData = z.infer; 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; 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;