import { z } from 'zod'; export const MembershipStatusEnum = z.enum([ 'active', 'inactive', 'pending', 'resigned', 'excluded', 'deceased', ]); export type MembershipStatus = z.infer; export const SepaMandateStatusEnum = z.enum([ 'active', 'pending', 'revoked', 'expired', ]); // --- Shared validators --- /** IBAN validation with mod-97 checksum (ISO 13616) */ export function validateIban(iban: string): boolean { const cleaned = iban.replace(/\s/g, '').toUpperCase(); if (!/^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/.test(cleaned)) return false; const rearranged = cleaned.slice(4) + cleaned.slice(0, 4); let numStr = ''; for (const char of rearranged) { const code = char.charCodeAt(0); numStr += code >= 65 && code <= 90 ? (code - 55).toString() : char; } let remainder = 0; for (const digit of numStr) { remainder = (remainder * 10 + parseInt(digit, 10)) % 97; } return remainder === 1; } const ibanSchema = z .string() .max(34) .optional() .refine((v) => !v || v.trim() === '' || validateIban(v), { message: 'Ungültige IBAN (Prüfsumme fehlerhaft)', }); const dateNotFutureSchema = (fieldName: string) => z .string() .optional() .refine((v) => !v || new Date(v) <= new Date(), { message: `${fieldName} darf nicht in der Zukunft liegen`, }); // --- Main schemas --- // Base object without refinements — used for .partial() in UpdateMemberSchema const CreateMemberBaseSchema = z.object({ accountId: z.string().uuid(), memberNumber: z.string().optional(), firstName: z.string().min(1).max(128), lastName: z.string().min(1).max(128), dateOfBirth: dateNotFutureSchema('Geburtsdatum'), gender: z.enum(['male', 'female', 'diverse']).optional(), title: z.string().max(32).optional(), email: z.string().email().optional().or(z.literal('')), phone: z.string().max(32).optional(), mobile: z.string().max(32).optional(), street: z.string().max(256).optional(), houseNumber: z.string().max(16).optional(), postalCode: z.string().max(10).optional(), city: z.string().max(128).optional(), country: z.string().max(2).default('DE'), status: MembershipStatusEnum.default('active'), entryDate: z .string() .default(() => new Date().toISOString().split('T')[0]!), duesCategoryId: z.string().uuid().optional(), iban: ibanSchema, bic: z.string().max(11).optional(), accountHolder: z.string().max(128).optional(), gdprConsent: z.boolean().default(false), notes: z.string().optional(), salutation: z.string().optional(), street2: z.string().optional(), phone2: z.string().optional(), fax: z.string().optional(), birthplace: z.string().optional(), birthCountry: z.string().default('DE'), isHonorary: z.boolean().default(false), isFoundingMember: z.boolean().default(false), isYouth: z.boolean().default(false), isRetiree: z.boolean().default(false), isProbationary: z.boolean().default(false), isTransferred: z.boolean().default(false), exitDate: z.string().optional(), exitReason: z.string().optional(), guardianName: z.string().optional(), guardianPhone: z.string().optional(), guardianEmail: z.string().optional(), duesYear: z.number().int().optional(), duesPaid: z.boolean().default(false), additionalFees: z.number().default(0), exemptionType: z.string().optional(), exemptionReason: z.string().optional(), exemptionAmount: z.number().optional(), gdprNewsletter: z.boolean().default(false), gdprInternet: z.boolean().default(false), gdprPrint: z.boolean().default(false), gdprBirthdayInfo: z.boolean().default(false), sepaMandateReference: z.string().optional(), }); /** Cross-field refinement shared by create/update */ function memberCrossFieldRefinement( data: Record, ctx: z.RefinementCtx, ) { // Cross-field: exit_date must be after entry_date if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen', path: ['exitDate'], }); } // Cross-field: entry_date must be after date_of_birth if ( data.dateOfBirth && data.entryDate && data.entryDate < data.dateOfBirth ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen', path: ['entryDate'], }); } // Cross-field: youth members should have guardian info if (data.isYouth && !data.guardianName) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten', path: ['guardianName'], }); } } export const CreateMemberSchema = CreateMemberBaseSchema.superRefine(memberCrossFieldRefinement); export type CreateMemberInput = z.infer; export const UpdateMemberSchema = CreateMemberBaseSchema.partial() .extend({ memberId: z.string().uuid(), isArchived: z.boolean().optional(), version: z.number().int().optional(), }) .superRefine(memberCrossFieldRefinement); export type UpdateMemberInput = z.infer; export const CreateDuesCategorySchema = z.object({ accountId: z.string().uuid(), name: z.string().min(1).max(128), description: z.string().optional(), amount: z.number().min(0), interval: z .enum(['monthly', 'quarterly', 'half_yearly', 'yearly']) .default('yearly'), isDefault: z.boolean().default(false), isYouth: z.boolean().default(false), isExit: z.boolean().default(false), }); export type CreateDuesCategoryInput = z.infer; export const RejectApplicationSchema = z.object({ applicationId: z.string().uuid(), accountId: z.string().uuid(), reviewNotes: z.string().optional(), }); export const CreateDepartmentSchema = z.object({ accountId: z.string().uuid(), name: z.string().min(1).max(128), description: z.string().optional(), }); export const CreateMemberRoleSchema = z.object({ memberId: z.string().uuid(), accountId: z.string().uuid(), roleName: z.string().min(1), fromDate: z.string().optional(), untilDate: z.string().optional(), }); export const CreateMemberHonorSchema = z.object({ memberId: z.string().uuid(), accountId: z.string().uuid(), honorName: z.string().min(1), honorDate: z.string().optional(), description: z.string().optional(), }); export const CreateSepaMandateSchema = z.object({ memberId: z.string().uuid(), accountId: z.string().uuid(), mandateReference: z.string().min(1), iban: z .string() .min(15) .max(34) .refine((v) => validateIban(v), { message: 'Ungültige IBAN (Prüfsumme fehlerhaft)', }), bic: z.string().optional(), accountHolder: z.string().min(1), mandateDate: z.string(), sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).default('RCUR'), }); export const UpdateDuesCategorySchema = z.object({ categoryId: z.string().uuid(), name: z.string().min(1).optional(), description: z.string().optional(), amount: z.number().min(0).optional(), interval: z .enum(['monthly', 'quarterly', 'half_yearly', 'yearly']) .optional(), isDefault: z.boolean().optional(), }); export type UpdateDuesCategoryInput = z.infer; export const UpdateMandateSchema = z.object({ mandateId: z.string().uuid(), iban: z .string() .min(15) .max(34) .refine((v) => validateIban(v), { message: 'Ungültige IBAN (Prüfsumme fehlerhaft)', }) .optional(), bic: z.string().optional(), accountHolder: z.string().optional(), sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).optional(), }); export type UpdateMandateInput = z.infer; export const ExportMembersSchema = z.object({ accountId: z.string().uuid(), status: z.string().optional(), format: z.enum(['csv', 'excel']).default('csv'), }); export const AssignDepartmentSchema = z.object({ memberId: z.string().uuid(), departmentId: z.string().uuid(), }); // --- Bulk operations & advanced search schemas --- export const BulkStatusUpdateSchema = z.object({ memberIds: z.array(z.string().uuid()).min(1), accountId: z.string().uuid(), status: MembershipStatusEnum, }); export const BulkDepartmentAssignSchema = z.object({ memberIds: z.array(z.string().uuid()).min(1), accountId: z.string().uuid(), departmentId: z.string().uuid(), }); export const BulkArchiveSchema = z.object({ memberIds: z.array(z.string().uuid()).min(1), accountId: z.string().uuid(), }); export const MemberSearchFiltersSchema = z.object({ accountId: z.string().uuid(), search: z.string().optional(), status: z.array(MembershipStatusEnum).optional(), departmentIds: z.array(z.string().uuid()).optional(), tagIds: z.array(z.string().uuid()).optional(), duesCategoryId: z.string().uuid().optional(), flags: z .array( z.enum([ 'honorary', 'founding', 'youth', 'retiree', 'probationary', 'transferred', ]), ) .optional(), entryDateFrom: z.string().optional(), entryDateTo: z.string().optional(), hasEmail: z.boolean().optional(), sortBy: z.string().default('last_name'), sortDirection: z.enum(['asc', 'desc']).default('asc'), page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(25), }); export type MemberSearchFilters = z.infer; export const QuickSearchSchema = z.object({ accountId: z.string().uuid(), query: z.string().min(1), limit: z.number().int().min(1).max(20).default(8), });