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
This commit is contained in:
Zaid Marzguioui
2026-04-03 18:41:51 +02:00
parent 4d538a5668
commit 5b169a381f
9 changed files with 261 additions and 107 deletions

View File

@@ -56,102 +56,112 @@ const dateNotFutureSchema = (fieldName: string) =>
// --- Main schemas ---
export const CreateMemberSchema = 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(),
})
.superRefine((data, ctx) => {
// 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'],
});
}
// 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: 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 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: 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'],
});
}
});
// 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 = CreateMemberSchema.partial().extend({
memberId: z.string().uuid(),
isArchived: z.boolean().optional(),
version: z.number().int().optional(),
});
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>;