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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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="وضعیت دوره">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user