Files
dashboard/backend/src/models/member.model.ts
Matthias Hochmeister 3ecae37d72 update
2026-03-13 14:13:39 +01:00

234 lines
7.2 KiB
TypeScript

import { z } from 'zod';
// ============================================================
// Domain enumerations — used both as Zod schemas and runtime
// arrays (for building <Select> options in the frontend).
// ============================================================
export const DIENSTGRAD_VALUES = [
'Feuerwehranwärter',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann',
] as const;
export const STATUS_VALUES = [
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten',
] as const;
export const FUNKTION_VALUES = [
'Kommandant',
'Stellv. Kommandant',
'Gruppenführer',
'Truppführer',
'Gerätewart',
'Kassier',
'Schriftführer',
'Atemschutzwart',
'Ausbildungsbeauftragter',
] as const;
export const TSHIRT_GROESSE_VALUES = [
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL',
] as const;
export type DienstgradEnum = typeof DIENSTGRAD_VALUES[number];
export type StatusEnum = typeof STATUS_VALUES[number];
export type FunktionEnum = typeof FUNKTION_VALUES[number];
export type TshirtGroesseEnum = typeof TSHIRT_GROESSE_VALUES[number];
// ============================================================
// Core DB row interface — mirrors the mitglieder_profile table
// exactly (snake_case, nullable columns use `| null`)
// ============================================================
export interface MitgliederProfile {
id: string;
user_id: string;
mitglieds_nr: string | null;
fdisk_standesbuch_nr: string | null;
dienstgrad: DienstgradEnum | null;
dienstgrad_seit: Date | null;
funktion: FunktionEnum[];
status: StatusEnum;
eintrittsdatum: Date | null;
austrittsdatum: Date | null;
geburtsdatum: Date | null; // sensitive field
telefon_mobil: string | null;
telefon_privat: string | null;
notfallkontakt_name: string | null; // sensitive field
notfallkontakt_telefon: string | null; // sensitive field
fuehrerscheinklassen: string[];
tshirt_groesse: TshirtGroesseEnum | null;
schuhgroesse: string | null;
bemerkungen: string | null;
bild_url: string | null;
created_at: Date;
updated_at: Date;
}
// ============================================================
// Rank-change history row
// ============================================================
export interface DienstgradVerlaufEntry {
id: string;
user_id: string;
dienstgrad_neu: string;
dienstgrad_alt: string | null;
datum: Date;
durch_user_id: string | null;
bemerkung: string | null;
created_at: Date;
}
// ============================================================
// Joined view: users row + mitglieder_profile (nullable)
// This is what the service returns for GET /members/:userId
// ============================================================
export interface MemberWithProfile {
// --- from users ---
id: string;
email: string;
name: string | null;
given_name: string | null;
family_name: string | null;
preferred_username: string | null;
profile_picture_url: string | null;
is_active: boolean;
last_login_at: Date | null;
created_at: Date;
// --- from mitglieder_profile (null when no profile exists) ---
profile: MitgliederProfile | null;
// --- rank history (populated on detail requests) ---
dienstgrad_verlauf?: DienstgradVerlaufEntry[];
}
// ============================================================
// List-view projection — only the fields needed for the table
// ============================================================
export interface MemberListItem {
// user fields
id: string;
name: string | null;
given_name: string | null;
family_name: string | null;
email: string;
profile_picture_url: string | null;
is_active: boolean;
// profile fields (null when no profile exists)
profile_id: string | null;
mitglieds_nr: string | null;
dienstgrad: DienstgradEnum | null;
funktion: FunktionEnum[];
status: StatusEnum | null;
eintrittsdatum: Date | null;
telefon_mobil: string | null;
}
// ============================================================
// Filter parameters for getAllMembers()
// ============================================================
export interface MemberFilters {
search?: string; // matches name, email, mitglieds_nr
status?: StatusEnum[];
dienstgrad?: DienstgradEnum[];
page?: number; // 1-based
pageSize?: number;
}
// ============================================================
// Zod validation schemas
// ============================================================
/**
* Schema for POST /members/:userId/profile
* All fields are optional at creation except user_id (in URL).
* status has a default; every other field may be omitted.
*/
export const CreateMemberProfileSchema = z.object({
mitglieds_nr: z.string().max(32).optional(),
fdisk_standesbuch_nr: z.string().max(32).optional(),
dienstgrad: z.enum(DIENSTGRAD_VALUES).optional(),
dienstgrad_seit: z.coerce.date().optional(),
funktion: z.array(z.enum(FUNKTION_VALUES)).default([]),
status: z.enum(STATUS_VALUES).default('aktiv'),
eintrittsdatum: z.coerce.date().optional(),
austrittsdatum: z.coerce.date().optional(),
geburtsdatum: z.coerce.date().optional(),
telefon_mobil: z.string().max(32).optional(),
telefon_privat: z.string().max(32).optional(),
notfallkontakt_name: z.string().max(255).optional(),
notfallkontakt_telefon: z.string().max(32).optional(),
fuehrerscheinklassen: z.array(z.string().max(8)).default([]),
tshirt_groesse: z.enum(TSHIRT_GROESSE_VALUES).optional(),
schuhgroesse: z.string().max(8).optional(),
bemerkungen: z.string().optional(),
bild_url: z.string().url().optional(),
});
export type CreateMemberProfileData = z.infer<typeof CreateMemberProfileSchema>;
/**
* Schema for PATCH /members/:userId
* Every field is optional — only provided fields are written.
*/
export const UpdateMemberProfileSchema = CreateMemberProfileSchema.partial();
export type UpdateMemberProfileData = z.infer<typeof UpdateMemberProfileSchema>;
/**
* Schema for the "self-edit" subset available to the Mitglied role.
* Only non-sensitive, personally-owned fields.
*/
export const SelfUpdateMemberProfileSchema = z.object({
telefon_mobil: z.string().max(32).optional(),
telefon_privat: z.string().max(32).optional(),
notfallkontakt_name: z.string().max(255).optional(),
notfallkontakt_telefon: z.string().max(32).optional(),
tshirt_groesse: z.enum(TSHIRT_GROESSE_VALUES).optional(),
schuhgroesse: z.string().max(8).optional(),
bild_url: z.string().url().optional(),
fdisk_standesbuch_nr: z.string().max(32).optional(),
});
export type SelfUpdateMemberProfileData = z.infer<typeof SelfUpdateMemberProfileSchema>;
// ============================================================
// Stats response shape (for dashboard KPI endpoint)
// ============================================================
export interface MemberStats {
total: number;
aktiv: number;
passiv: number;
ehrenmitglied: number;
jugendfeuerwehr: number;
anwärter: number;
ausgetreten: number;
}