From 8c37c15697835797295b209f41905ddb1484fdf1 Mon Sep 17 00:00:00 2001 From: "ar.badiei" Date: Tue, 22 Sep 2026 07:24:40 +0330 Subject: [PATCH] Fix minimum age 12 and ensure assignment catalog seeds on startup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persian age validation messages, clearer homework entry points, and seed assignment definitions so «تکالیف من» is not empty after deploy. Co-authored-by: Cursor --- DEPLOY.md | 2 +- backend/README.md | 4 +- backend/src/middleware/errorHandler.ts | 3 +- .../assignments/assignments.service.ts | 39 ++++++++++++++++++- backend/src/modules/users/users.schemas.ts | 23 +++++------ backend/src/modules/users/users.service.ts | 2 +- backend/src/server.ts | 5 +++ backend/tests/profile.test.ts | 1 + src/lib/backendError.ts | 13 +++++++ src/pages/Account/AccountPage.tsx | 17 +++++++- src/pages/Assignments/AssignmentsPage.tsx | 23 +++++++---- src/pages/Profile/ProfileCompletionPage.tsx | 12 +++++- 12 files changed, 115 insertions(+), 29 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 8e45a14..4008cac 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -76,7 +76,7 @@ Set `VITE_BACKEND_URL` to the public API base URL **before** `npm run build`. ## 6. Checklist for the other deployer -- [ ] MySQL migrated + seeded (11 course sessions) +- [ ] MySQL migrated + seeded (11 course sessions + 9 assignment definitions) - [ ] Backend `CORS_ORIGIN` matches the real frontend origin - [ ] `OPENAI_API_KEY` set on backend - [ ] Frontend built with correct `VITE_BACKEND_URL` diff --git a/backend/README.md b/backend/README.md index 0cf7742..0557d02 100644 --- a/backend/README.md +++ b/backend/README.md @@ -33,7 +33,7 @@ npm run dev API default: `http://localhost:3001` PWA (CORS): `http://localhost:5173` -`prisma:seed` upserts the 11 course sessions. Server startup also upserts them as a safety net. +`prisma:seed` upserts the 11 course sessions and 9 assignment definitions. Server startup also upserts both as a safety net. ## Scripts @@ -49,7 +49,7 @@ PWA (CORS): `http://localhost:5173` | `npm run prisma:validate` | Validate schema | | `npm run prisma:migrate:dev` | Dev migrations | | `npm run prisma:migrate:deploy` | Apply migrations (prod) | -| `npm run prisma:seed` | Upsert 11 `course_sessions` | +| `npm run prisma:seed` | Upsert 11 `course_sessions` + 9 assignment definitions | ## Project Structure diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index a2eef56..6150cf1 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -27,10 +27,11 @@ export const errorHandler: ErrorHandler = (err, c) => { if (err instanceof ZodError) { log.warn({ issues: err.issues }, 'validation error'); + const firstMessage = err.issues[0]?.message?.trim(); return errorResponse(c, { code: ERROR_CODES.VALIDATION_ERROR, - message: 'Validation failed', + message: firstMessage || 'اطلاعات واردشده نامعتبر است', status: HTTP_STATUS.UNPROCESSABLE_ENTITY, details: err.issues, }); diff --git a/backend/src/modules/assignments/assignments.service.ts b/backend/src/modules/assignments/assignments.service.ts index 1f9a370..5331b97 100644 --- a/backend/src/modules/assignments/assignments.service.ts +++ b/backend/src/modules/assignments/assignments.service.ts @@ -485,7 +485,7 @@ export async function listAssignmentsForSession(userId: bigint, sessionOrder: nu /** All session assignment slots (definitions 2–10) with optional user progress. */ export async function listAssignmentCatalog(userId: bigint) { - await seedAssignmentDefinitions(); + const seededCount = await seedAssignmentDefinitions(); await backfillAssignmentsForCompletedSessions(userId); const completedOrders = await getCompletedSessionOrderSet(userId); @@ -494,6 +494,14 @@ export async function listAssignmentCatalog(userId: bigint) { orderBy: [{ displayOrder: 'asc' }, { sessionOrder: 'asc' }], }); + if (definitions.length === 0) { + throw new ValidationError( + seededCount === 0 + ? 'تعاریف تکالیف در پایگاه‌داده موجود نیست. لطفاً migrate و seed را اجرا کنید.' + : 'بارگذاری فهرست تکالیف ناموفق بود', + ); + } + const userRows = await prisma.userAssignment.findMany({ where: { userId }, include: { @@ -506,8 +514,35 @@ export async function listAssignmentCatalog(userId: bigint) { const byDefinitionId = new Map(userRows.map((r) => [r.definitionId.toString(), r])); + let activatedMissing = 0; + for (const definition of definitions) { + if ( + isAssignmentUnlockedByCompletedSessions(definition.sessionOrder, completedOrders) && + !byDefinitionId.has(definition.id.toString()) + ) { + activatedMissing += await activateAssignmentsForSession(userId, definition.sessionOrder, prisma, { + ensureSeeded: false, + }); + } + } + + const rows = + activatedMissing > 0 + ? await prisma.userAssignment.findMany({ + where: { userId }, + include: { + definition: true, + reports: { orderBy: { createdAt: 'desc' }, take: 1 }, + dailyEntries: { orderBy: { entryDate: 'desc' } }, + _count: { select: { reports: true, dailyEntries: true } }, + }, + }) + : userRows; + + const byId = new Map(rows.map((r) => [r.definitionId.toString(), r])); + return definitions.map((definition) => { - const ua = byDefinitionId.get(definition.id.toString()); + const ua = byId.get(definition.id.toString()); const unlocked = isAssignmentUnlockedByCompletedSessions( definition.sessionOrder, completedOrders, diff --git a/backend/src/modules/users/users.schemas.ts b/backend/src/modules/users/users.schemas.ts index 703d55e..3b70649 100644 --- a/backend/src/modules/users/users.schemas.ts +++ b/backend/src/modules/users/users.schemas.ts @@ -2,21 +2,22 @@ import { z } from 'zod'; export const NAME_MAX_LENGTH = 100; -export const profileNameSchema = z - .string({ required_error: 'Name is required' }) - .trim() - .min(1, 'Name cannot be empty') - .max(NAME_MAX_LENGTH, `Name must be at most ${NAME_MAX_LENGTH} characters`); - +/** Minimum allowed profile age (years). Keep in sync with frontend ProfileCompletionPage. */ export const PROFILE_AGE_MIN = 12; +export const profileNameSchema = z + .string({ required_error: 'نام الزامی است' }) + .trim() + .min(1, 'نام نمی‌تواند خالی باشد') + .max(NAME_MAX_LENGTH, `نام حداکثر ${NAME_MAX_LENGTH} کاراکتر باشد`); + export const profileAgeSchema = z .number({ - required_error: 'Age is required', - invalid_type_error: 'Age must be an integer', + required_error: 'سن الزامی است', + invalid_type_error: 'سن باید یک عدد صحیح باشد', }) - .int('Age must be an integer') - .min(PROFILE_AGE_MIN, `Age must be at least ${PROFILE_AGE_MIN}`); + .int('سن باید یک عدد صحیح باشد') + .min(PROFILE_AGE_MIN, `حداقل سن ${PROFILE_AGE_MIN} سال است`); export const patchProfileSchema = z .object({ @@ -24,7 +25,7 @@ export const patchProfileSchema = z age: profileAgeSchema.optional(), }) .refine((value) => value.name !== undefined || value.age !== undefined, { - message: 'At least one of name or age is required', + message: 'نام یا سن لازم است', }); export type PatchProfileInput = z.infer; diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index a67e920..f22ed6e 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -49,7 +49,7 @@ export async function updateOwnProfile( if (!existing.profile) { if (input.name === undefined || input.age === undefined) { - throw new ValidationError('Name and age are both required to create a profile'); + throw new ValidationError('برای تکمیل مشخصات، نام و سن هر دو لازم است'); } await prisma.userProfile.create({ diff --git a/backend/src/server.ts b/backend/src/server.ts index 7c9517f..bbc5654 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -16,8 +16,13 @@ async function startServer() { await connectPrisma(); logger.info('database connection established'); const { seedCourseSessions } = await import('./modules/sessions/sessions.service.js'); + const { seedAssignmentDefinitions } = await import( + './modules/assignments/assignments.service.js' + ); await seedCourseSessions(); logger.info('course sessions seeded'); + const assignmentCount = await seedAssignmentDefinitions(); + logger.info({ assignmentCount }, 'assignment definitions seeded'); } catch (err) { logger.warn({ err }, 'database connection failed at startup; /ready will report unavailable'); } diff --git a/backend/tests/profile.test.ts b/backend/tests/profile.test.ts index f917881..19d69e8 100644 --- a/backend/tests/profile.test.ts +++ b/backend/tests/profile.test.ts @@ -109,6 +109,7 @@ describe('profile API', () => { body: JSON.stringify({ name: 'Sara', age: 11 }), }); assert.equal(underage.status, 422); + assert.match(String(underage.body.error?.message ?? ''), /حداقل سن 12/); const teenOk = await requestJson(app, '/users/me/profile', { method: 'PATCH', diff --git a/src/lib/backendError.ts b/src/lib/backendError.ts index e52fa9c..19aa5d7 100644 --- a/src/lib/backendError.ts +++ b/src/lib/backendError.ts @@ -91,12 +91,25 @@ export function getLoginErrorMessage(error: unknown): string { function localizeValidationMessage(message: string | undefined): string | null { if (!message) return null; const lower = message.toLowerCase(); + if (/حداقل سن|سن باید|سن الزامی/.test(message)) { + return message; + } + if (/age must be at least\s*(\d+)/i.test(message)) { + const min = message.match(/age must be at least\s*(\d+)/i)?.[1] ?? '12'; + return `حداقل سن ${min} سال است`; + } + if (/age must be an integer|age is required/i.test(message)) { + return 'سن باید یک عدد صحیح باشد'; + } if (lower.includes('email')) { return 'فرمت ایمیل نامعتبر است.'; } if (lower.includes('password')) { return 'رمز عبور نامعتبر است.'; } + if (/^validation failed$/i.test(message.trim())) { + return 'اطلاعات واردشده نامعتبر است.'; + } return localizeKnownEnglishMessage(message); } diff --git a/src/pages/Account/AccountPage.tsx b/src/pages/Account/AccountPage.tsx index 47aa5f6..3a70944 100644 --- a/src/pages/Account/AccountPage.tsx +++ b/src/pages/Account/AccountPage.tsx @@ -6,12 +6,13 @@ import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions'; import { getProgramProgress } from '@/services/programService'; import { Progress } from '@/components/ui/progress'; import { Navigate, useNavigate } from 'react-router-dom'; -import { Check, ChevronLeft, Lock } from 'lucide-react'; +import { Check, ChevronLeft, Lock, NotebookPen } from 'lucide-react'; import { cn } from '@/lib/utils'; import { StaggeredList, StaggerItem, AnimationWrapper } from '@/components/motion'; import { motion } from 'framer-motion'; import { fastTransition } from '@/lib/motion'; import { CourseStatusSkeleton } from '@/components/skeletons'; +import { Button } from '@/components/ui/button'; type StepState = 'open' | 'locked' | 'done'; @@ -85,6 +86,20 @@ export default function AccountPage() {

)} + {!isGuest ? ( + + ) : null} diff --git a/src/pages/Assignments/AssignmentsPage.tsx b/src/pages/Assignments/AssignmentsPage.tsx index faf1a6c..a5546cb 100644 --- a/src/pages/Assignments/AssignmentsPage.tsx +++ b/src/pages/Assignments/AssignmentsPage.tsx @@ -8,6 +8,7 @@ import { fetchAssignmentCatalog, type AssignmentCatalogItem, } from '@/services/assignmentService'; +import { getBackendErrorMessage } from '@/lib/backendError'; import { AnimationWrapper, StaggerItem, StaggeredList } from '@/components/motion'; import { PageSkeleton } from '@/components/skeletons'; import { Button } from '@/components/ui/button'; @@ -40,7 +41,7 @@ export default function AssignmentsPage() { const rows = await fetchAssignmentCatalog(); setItems(rows); } catch (err) { - setError(err instanceof Error ? err.message : 'بارگذاری تکالیف ناموفق بود'); + setError(getBackendErrorMessage(err, 'بارگذاری تکالیف ناموفق بود')); setItems([]); } finally { setLoading(false); @@ -109,14 +110,20 @@ export default function AssignmentsPage() { ) : items.length === 0 ? ( -

- هنوز تکلیفی تعریف نشده است. -

+
+

فهرست تکالیف هنوز از سرور بارگذاری نشده است.

+

+ پس از تکمیل هر یک از جلسات ۲ تا ۱۰، تکلیف همان جلسه اینجا باز می‌شود. +

+ +
) : ( {items.map((item) => { const ua = item.userAssignment; - const unlocked = item.unlocked && ua; + const isOpen = item.unlocked && Boolean(ua); const completed = ua?.status === 'COMPLETED'; const content = ( @@ -130,7 +137,7 @@ export default function AssignmentsPage() { {item.definition.title} - {unlocked ? ( + {isOpen ? ( ) : ( @@ -143,7 +150,7 @@ export default function AssignmentsPage() { {assignmentTypeLabel(item.definition.assignmentType)} - {unlocked && ua ? ( + {isOpen && ua ? ( <> - {unlocked ? ( + {isOpen && ua ? ( { setAge(event.target.value); @@ -152,9 +152,17 @@ export default function ProfileCompletionPage() { }} disabled={isLoading} autoComplete="off" + min={PROFILE_AGE_MIN} aria-invalid={Boolean(fieldError.age)} + aria-describedby="profile-age-hint" /> - {fieldError.age ?

{fieldError.age}

: null} + {fieldError.age ? ( +

{fieldError.age}

+ ) : ( +

+ حداقل سن ورود {PROFILE_AGE_MIN} سال است +

+ )}