diff --git a/apps/web/public/images/posts/digitale-verwaltung.webp b/apps/web/public/images/posts/digitale-verwaltung.webp new file mode 100644 index 000000000..ebb9a66b5 Binary files /dev/null and b/apps/web/public/images/posts/digitale-verwaltung.webp differ diff --git a/apps/web/public/images/posts/dsgvo-vereine.webp b/apps/web/public/images/posts/dsgvo-vereine.webp new file mode 100644 index 000000000..f9a09d8de Binary files /dev/null and b/apps/web/public/images/posts/dsgvo-vereine.webp differ diff --git a/apps/web/public/images/posts/mitgliederverwaltung.webp b/apps/web/public/images/posts/mitgliederverwaltung.webp new file mode 100644 index 000000000..ebb9a66b5 Binary files /dev/null and b/apps/web/public/images/posts/mitgliederverwaltung.webp differ diff --git a/apps/web/public/images/posts/sepa-lastschrift.webp b/apps/web/public/images/posts/sepa-lastschrift.webp new file mode 100644 index 000000000..407cf07f9 Binary files /dev/null and b/apps/web/public/images/posts/sepa-lastschrift.webp differ diff --git a/apps/web/public/images/posts/vereinswebsite.webp b/apps/web/public/images/posts/vereinswebsite.webp new file mode 100644 index 000000000..f9a09d8de Binary files /dev/null and b/apps/web/public/images/posts/vereinswebsite.webp differ diff --git a/docker/db/dev-bootstrap.sh b/docker/db/dev-bootstrap.sh index dbc33fd1f..8184bd158 100755 --- a/docker/db/dev-bootstrap.sh +++ b/docker/db/dev-bootstrap.sh @@ -59,7 +59,6 @@ $PSQL -c " GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_stocking TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_leases TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_books TO authenticated; - GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_entries TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_permits TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_competitions TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated; diff --git a/packages/features/course-management/src/server/services/course-statistics.service.ts b/packages/features/course-management/src/server/services/course-statistics.service.ts index 3e86bcc07..8f1b385fa 100644 --- a/packages/features/course-management/src/server/services/course-statistics.service.ts +++ b/packages/features/course-management/src/server/services/course-statistics.service.ts @@ -13,21 +13,31 @@ export function createCourseStatisticsService( { p_account_id: accountId }, ); if (error) throw error; - // RPC returns a single row as an array - const stats = Array.isArray(data) ? data[0] : data; - return ( - stats ?? { - total_courses: 0, - open_courses: 0, - running_courses: 0, - completed_courses: 0, - cancelled_courses: 0, - total_participants: 0, - total_waitlisted: 0, - avg_occupancy_rate: 0, - total_revenue: 0, - } - ); + // RPC returns a single row as an array with snake_case keys + const raw = Array.isArray(data) ? data[0] : data; + const s = raw ?? { + total_courses: 0, + open_courses: 0, + running_courses: 0, + completed_courses: 0, + cancelled_courses: 0, + total_participants: 0, + total_waitlisted: 0, + avg_occupancy_rate: 0, + total_revenue: 0, + }; + // Normalise to camelCase for consumers + return { + totalCourses: s.total_courses ?? s.totalCourses ?? 0, + openCourses: s.open_courses ?? s.openCourses ?? 0, + runningCourses: s.running_courses ?? s.runningCourses ?? 0, + completedCourses: s.completed_courses ?? s.completedCourses ?? 0, + cancelledCourses: s.cancelled_courses ?? s.cancelledCourses ?? 0, + totalParticipants: s.total_participants ?? s.totalParticipants ?? 0, + totalWaitlisted: s.total_waitlisted ?? s.totalWaitlisted ?? 0, + avgOccupancyRate: s.avg_occupancy_rate ?? s.avgOccupancyRate ?? 0, + totalRevenue: s.total_revenue ?? s.totalRevenue ?? 0, + }; }, async getAttendanceSummary(courseId: string) { diff --git a/packages/features/member-management/src/schema/member.schema.ts b/packages/features/member-management/src/schema/member.schema.ts index 7f6399d49..20963b2da 100644 --- a/packages/features/member-management/src/schema/member.schema.ts +++ b/packages/features/member-management/src/schema/member.schema.ts @@ -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, + 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; -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; diff --git a/qa-checklist.md b/qa-checklist.md new file mode 100644 index 000000000..8f22f7aa3 --- /dev/null +++ b/qa-checklist.md @@ -0,0 +1,135 @@ +# QA Checklist — MYeasyCMS v2 + +**Date**: 2026-04-03 +**Test method**: Docker production build (`docker compose -f docker-compose.local.yml up --build`) +**Test user**: `test@makerkit.dev` / `testingpassword` (super-admin) + +--- + +## BUGS FOUND & FIXED + +### BUG #1 — Members CMS crashes with Zod v4 error (CRITICAL) ✅ FIXED +- **Route**: `/home/[account]/members-cms` +- **Error**: `Error: .partial() cannot be used on object schemas containing refinements` +- **Root cause**: `CreateMemberSchema` uses `.superRefine()` for cross-field validation, then `UpdateMemberSchema = CreateMemberSchema.partial()` fails in Zod v4. +- **Fix**: Separated base object schema from refinements. `CreateMemberBaseSchema` (plain object) is used for `.partial()`, and the refinement function `memberCrossFieldRefinement` is applied separately to both Create and Update schemas. +- **File**: `packages/features/member-management/src/schema/member.schema.ts` +- **Status**: ✅ Verified in Docker rebuild — members page loads with 30 members displayed + +### BUG #2 — Course stats cards show empty values (MEDIUM) ✅ FIXED +- **Route**: `/home/[account]/courses` +- **Symptom**: Stats cards showed labels (Gesamt, Aktiv, Abgeschlossen, Teilnehmer) but no numbers +- **Root cause**: The `getQuickStats` RPC returns snake_case keys (`total_courses`, `open_courses`) but the template uses camelCase (`stats.totalCourses`, `stats.openCourses`), resulting in `undefined` +- **Fix**: Added camelCase normalization in `createCourseStatisticsService.getQuickStats()` +- **File**: `packages/features/course-management/src/server/services/course-statistics.service.ts` +- **Status**: ✅ Verified in Docker rebuild — all 4 stats cards show "0" correctly + +### BUG #3 — Blog post images missing (MEDIUM) ✅ FIXED +- **Route**: `/blog` +- **Symptom**: All blog post cards showed broken image alt text instead of cover images +- **Root cause**: Blog posts reference images like `/images/posts/mitgliederverwaltung.webp` but only 3 default Makerkit images existed in `public/images/posts/` +- **Fix**: Created placeholder images for all 5 German blog posts +- **Files added**: `apps/web/public/images/posts/{mitgliederverwaltung,dsgvo-vereine,sepa-lastschrift,digitale-verwaltung,vereinswebsite}.webp` +- **Status**: ✅ Verified in Docker rebuild — all blog images load + +### BUG #4 — Dev bootstrap references non-existent table (LOW) ✅ FIXED +- **Error**: `ERROR: relation "public.catch_entries" does not exist` during DB seeding +- **Root cause**: `docker/db/dev-bootstrap.sh` grants permissions on `catch_entries` table, but no migration creates it. The actual table is `catch_books`. +- **Fix**: Removed the `catch_entries` GRANT line from dev-bootstrap.sh +- **File**: `docker/db/dev-bootstrap.sh` +- **Status**: ✅ Fixed + +--- + +## KNOWN ISSUES (NOT FIXED — LOW PRIORITY) + +### ISSUE #5 — Admin panel not translated to German +- **Route**: `/admin` +- **Symptom**: Admin dashboard shows English labels ("Users", "Team Accounts", "Paying Customers") while rest of app is in German +- **Impact**: Low — admin panel is internal-facing only +- **Fix needed**: Add German translations for admin section in i18n + +### ISSUE #6 — Hydration mismatch warning (dev-only) +- **Source**: `next-runtime-env` `PublicEnvScript` component +- **Impact**: None — this is a known Next.js 16 framework-level issue with script serialization. The `suppressHydrationWarning` is already set on ``. +- **Fix needed**: None — wait for upstream fix in `next-runtime-env` + +--- + +## PAGES TESTED — ALL PASSING ✅ + +### Marketing Pages (Public) +| Route | Status | Notes | +|-------|--------|-------| +| `/` (Homepage) | ✅ Pass | Hero, stats, features, testimonials, pricing, CTA all render | +| `/blog` | ✅ Pass | Blog cards with images, dates, descriptions | +| `/docs` | ✅ Pass | Documentation sidebar navigation, category cards | +| `/pricing` | ✅ Pass | 4 pricing tiers, monthly/yearly toggle, feature lists | +| `/faq` | ✅ Pass | Accordion FAQ items with expand/collapse | +| `/contact` | ✅ Pass | Contact form with name, email, message fields | + +### Auth Pages +| Route | Status | Notes | +|-------|--------|-------| +| `/auth/sign-in` | ✅ Pass | Email/password login, social auth buttons (Google, Apple, Azure, GitHub) | +| `/auth/sign-up` | ✅ Pass (navigable) | Registration form | + +### Personal Dashboard +| Route | Status | Notes | +|-------|--------|-------| +| `/home` | ✅ Pass | Personal home with sidebar, account selector | +| `/home/settings` | ✅ Pass (navigable) | Profile settings | + +### Team Dashboard (Makerkit workspace) +| Route | Status | Notes | +|-------|--------|-------| +| `/home/makerkit` | ✅ Pass | Dashboard with member stats, course stats, invoices, newsletters, quick actions | +| `/home/makerkit/members-cms` | ✅ Pass | Member list with search, filters, status badges, pagination (30 members) | +| `/home/makerkit/courses` | ✅ Pass | Course list with stats cards, search, status filter | +| `/home/makerkit/events` | ✅ Pass | Events list with stats (Veranstaltungen, Orte, Kapazität) | +| `/home/makerkit/finance` | ✅ Pass | SEPA + Invoices overview with stats | +| `/home/makerkit/newsletter` | ✅ Pass | Newsletter list with stats | +| `/home/makerkit/bookings` | ✅ Pass | Booking management with rooms/active/revenue stats | +| `/home/makerkit/documents` | ✅ Pass | Document generator cards (Mitgliedsausweis, Rechnung, Etiketten, etc.) | +| `/home/makerkit/site-builder` | ✅ Pass | Page builder with settings, posts, status | +| `/home/makerkit/meetings` | ✅ Pass | Meeting protocols with stats (Protokolle, Aufgaben) | +| `/home/makerkit/fischerei` | ✅ Pass | Fishing module with 8 tabs, 6 stat cards | +| `/home/makerkit/verband` | ✅ Pass | Federation management with 9 tabs, 6 stat cards | +| `/home/makerkit/settings` | ✅ Pass | Team logo, team name, danger zone | + +### Admin Panel +| Route | Status | Notes | +|-------|--------|-------| +| `/admin` | ✅ Pass | Super admin dashboard with Users/Team/Paying/Trials stats | + +--- + +## INTERACTIVE ELEMENTS VERIFIED + +- ✅ Navigation links (Blog, Docs, Pricing, FAQ, Contact) +- ✅ Auth flow (sign-in with email/password) +- ✅ Account/workspace selector dropdown +- ✅ Sidebar navigation (collapsed/expanded) +- ✅ Stats cards (all modules) +- ✅ Member list table with avatars, status badges, tags +- ✅ Search inputs (members, courses, bookings, newsletter) +- ✅ Status filter dropdowns +- ✅ Tab navigation (Fischerei, Meetings, Verband) +- ✅ Quick action buttons (New Member, New Course, etc.) +- ✅ FAQ accordion expand/collapse +- ✅ Theme toggle button +- ✅ Sign In / Sign Up buttons +- ✅ Breadcrumb navigation + +--- + +## FILES MODIFIED + +1. `packages/features/member-management/src/schema/member.schema.ts` — Zod v4 partial() fix +2. `packages/features/course-management/src/server/services/course-statistics.service.ts` — snake_case→camelCase normalization +3. `apps/web/public/images/posts/mitgliederverwaltung.webp` — Added blog image +4. `apps/web/public/images/posts/dsgvo-vereine.webp` — Added blog image +5. `apps/web/public/images/posts/sepa-lastschrift.webp` — Added blog image +6. `apps/web/public/images/posts/digitale-verwaltung.webp` — Added blog image +7. `apps/web/public/images/posts/vereinswebsite.webp` — Added blog image +8. `docker/db/dev-bootstrap.sh` — Removed non-existent catch_entries table reference