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
- هنوز تکلیفی تعریف نشده است. -
+فهرست تکالیف هنوز از سرور بارگذاری نشده است.
++ پس از تکمیل هر یک از جلسات ۲ تا ۱۰، تکلیف همان جلسه اینجا باز میشود. +
+ +{fieldError.age}
: null} + {fieldError.age ? ( +{fieldError.age}
+ ) : ( ++ حداقل سن ورود {PROFILE_AGE_MIN} سال است +
+ )}