- fix(member-management): Zod v4 .partial() on refined schema crash Separated CreateMemberBaseSchema from superRefine so .partial() works for UpdateMemberSchema. Fixes members-cms page crash. - fix(course-management): snake_case→camelCase stats normalization getQuickStats RPC returns snake_case keys but templates expect camelCase. Added normalization layer so stats cards display values. - fix(blog): add missing cover images for 5 German blog posts Posts referenced /images/posts/*.webp that didn't exist. - fix(docker): remove non-existent catch_entries table from bootstrap dev-bootstrap.sh granted permissions on catch_entries which has no migration. Removed the stale reference. - docs: add qa-checklist.md with full test report
321 lines
9.5 KiB
TypeScript
321 lines
9.5 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
export const MembershipStatusEnum = z.enum([
|
|
'active',
|
|
'inactive',
|
|
'pending',
|
|
'resigned',
|
|
'excluded',
|
|
'deceased',
|
|
]);
|
|
export type MembershipStatus = z.infer<typeof MembershipStatusEnum>;
|
|
|
|
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<string, unknown>,
|
|
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<typeof CreateMemberSchema>;
|
|
|
|
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<typeof UpdateMemberSchema>;
|
|
|
|
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<typeof CreateDuesCategorySchema>;
|
|
|
|
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<typeof UpdateDuesCategorySchema>;
|
|
|
|
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<typeof UpdateMandateSchema>;
|
|
|
|
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<typeof MemberSearchFiltersSchema>;
|
|
|
|
export const QuickSearchSchema = z.object({
|
|
accountId: z.string().uuid(),
|
|
query: z.string().min(1),
|
|
limit: z.number().int().min(1).max(20).default(8),
|
|
});
|