235 lines
7.2 KiB
TypeScript
235 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;
|
|
fdisk_standesbuch_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;
|
|
}
|