Files
myeasycms-v2/packages/features/member-management/src/schema/member.schema.ts
Zaid Marzguioui 5b169a381f fix: resolve 4 QA bugs found in Docker production build
- 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
2026-04-03 18:41:51 +02:00

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),
});