Fix minimum age 12 and ensure assignment catalog seeds on startup.

Persian age validation messages, clearer homework entry points, and seed assignment definitions so «تکالیف من» is not empty after deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ar.badiei
2026-09-22 07:24:40 +03:30
co-authored by Cursor
parent c6243caa35
commit 8c37c15697
12 changed files with 115 additions and 29 deletions
+1 -1
View File
@@ -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`
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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,
});
@@ -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,
+12 -11
View File
@@ -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<typeof patchProfileSchema>;
+1 -1
View File
@@ -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({
+5
View File
@@ -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');
}
+1
View File
@@ -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',
+13
View File
@@ -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);
}
+16 -1
View File
@@ -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() {
</span>
</p>
)}
{!isGuest ? (
<Button
type="button"
variant="outline"
className="mt-3 w-full justify-between"
onClick={() => navigate('/assignments')}
>
<span className="inline-flex items-center gap-2">
<NotebookPen className="h-4 w-4 text-sky-600 dark:text-sky-400" />
تکالیف من
</span>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
</Button>
) : null}
</AnimationWrapper>
<StaggeredList itemCount={3} className="space-y-3" role="region" aria-label="وضعیت دوره">
+15 -8
View File
@@ -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() {
</Button>
</div>
) : items.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground dark:border-slate-600">
هنوز تکلیفی تعریف نشده است.
</p>
<div className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground dark:border-slate-600">
<p>فهرست تکالیف هنوز از سرور بارگذاری نشده است.</p>
<p className="mt-2 text-xs">
پس از تکمیل هر یک از جلسات ۲ تا ۱۰، تکلیف همان جلسه اینجا باز می‌شود.
</p>
<Button variant="link" className="mt-2 px-0" onClick={() => void load()}>
تلاش مجدد
</Button>
</div>
) : (
<StaggeredList itemCount={items.length} className="space-y-3">
{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}
</h2>
</div>
{unlocked ? (
{isOpen ? (
<ChevronLeft className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Lock className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
@@ -143,7 +150,7 @@ export default function AssignmentsPage() {
<span className="rounded-md bg-slate-100 px-2 py-0.5 dark:bg-slate-700">
{assignmentTypeLabel(item.definition.assignmentType)}
</span>
{unlocked && ua ? (
{isOpen && ua ? (
<>
<span
className={cn(
@@ -169,7 +176,7 @@ export default function AssignmentsPage() {
return (
<StaggerItem key={item.definition.id}>
{unlocked ? (
{isOpen && ua ? (
<Link
to={`/assignments/${ua.id}`}
className="block rounded-xl border border-slate-200/80 bg-white p-4 shadow-sm transition hover:border-sky-300 dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-600"
+10 -2
View File
@@ -144,7 +144,7 @@ export default function ProfileCompletionPage() {
id="profile-age"
type="text"
inputMode="numeric"
placeholder="سن"
placeholder={`حداقل ${PROFILE_AGE_MIN} سال`}
value={age}
onChange={(event) => {
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 ? <p className="text-xs text-red-400">{fieldError.age}</p> : null}
{fieldError.age ? (
<p className="text-xs text-red-400">{fieldError.age}</p>
) : (
<p id="profile-age-hint" className="text-xs text-muted-foreground">
حداقل سن ورود {PROFILE_AGE_MIN} سال است
</p>
)}
</div>
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>