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} سال است
+
+ )}