diff --git a/backend/README.md b/backend/README.md index e0955e7..b2b8235 100644 --- a/backend/README.md +++ b/backend/README.md @@ -121,6 +121,8 @@ Timestamps are authoritative. Derived steps: Permissions on `GET /journey`: `canTakePreTest`, `canOpenTraining`, `canTakePostTest`, `isCompleted`. +`canTakePostTest` is true after pre-test when either training is fully completed **or** the learner has completed sessions 1–10 (reached Session 11), and the post-test is not yet submitted. + ## API Endpoints ### Health diff --git a/backend/scripts/jump-to-session-11.ts b/backend/scripts/jump-to-session-11.ts new file mode 100644 index 0000000..6c519a4 --- /dev/null +++ b/backend/scripts/jump-to-session-11.ts @@ -0,0 +1,145 @@ +/** + * Dev helper: jump a password user to Session 11 chat (sessions 1–10 done, + * pre-test done, training/post-test NOT done) so you can test the post-test handoff. + * + * Usage: + * npx tsx scripts/jump-to-session-11.ts alireza@gmail.com + */ +import { AttemptPhase, AttemptStatus, SessionProgressStatus } from '@prisma/client'; +import { prisma } from '../src/lib/prisma.ts'; +import { PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.ts'; + +const emailArg = process.argv[2]?.trim().toLowerCase(); +if (!emailArg) { + console.error('Usage: npx tsx scripts/jump-to-session-11.ts '); + process.exit(1); +} + +async function main() { + const identity = await prisma.authIdentity.findFirst({ + where: { + provider: 'password', + OR: [{ email: emailArg }, { providerSubject: emailArg }], + }, + include: { + user: { include: { journey: true, profile: true } }, + }, + }); + + if (!identity) { + throw new Error(`No password account found for ${emailArg}`); + } + + const userId = identity.userId; + const now = new Date(); + const hourAgo = (n: number) => new Date(now.getTime() - n * 60 * 60 * 1000); + + await prisma.$transaction(async (tx) => { + await tx.learningJourney.upsert({ + where: { userId }, + create: { + userId, + currentStep: 'training_in_progress', + preTestCompletedAt: hourAgo(20), + trainingStartedAt: hourAgo(19), + trainingCompletedAt: null, + postTestCompletedAt: null, + lastActivityAt: now, + }, + update: { + currentStep: 'training_in_progress', + preTestCompletedAt: identity.user.journey?.preTestCompletedAt ?? hourAgo(20), + trainingStartedAt: identity.user.journey?.trainingStartedAt ?? hourAgo(19), + trainingCompletedAt: null, + postTestCompletedAt: null, + lastActivityAt: now, + }, + }); + + const existingPre = await tx.assessmentAttempt.findFirst({ + where: { userId, phase: AttemptPhase.pre, status: AttemptStatus.scored }, + }); + if (!existingPre) { + const preAt = hourAgo(20); + await tx.assessmentAttempt.create({ + data: { + userId, + phase: AttemptPhase.pre, + status: AttemptStatus.scored, + submittedAt: preAt, + scoredAt: preAt, + }, + }); + } + + // Clear final-session / post-test leftovers so the handoff can be retested. + await tx.sessionProgress.deleteMany({ + where: { userId, sessionOrder: PROGRAM_SESSION_COUNT }, + }); + await tx.chatSession.deleteMany({ + where: { userId, sessionOrder: PROGRAM_SESSION_COUNT }, + }); + await tx.assessmentAttempt.deleteMany({ + where: { userId, phase: AttemptPhase.post }, + }); + + for (let order = 1; order < PROGRAM_SESSION_COUNT; order += 1) { + const completedAt = hourAgo(PROGRAM_SESSION_COUNT - order); + await tx.sessionProgress.upsert({ + where: { userId_sessionOrder: { userId, sessionOrder: order } }, + create: { + userId, + sessionOrder: order, + status: SessionProgressStatus.completed, + startedAt: completedAt, + completedAt, + completionMetadata: { + reason: 'Dev jump-to-session-11 helper', + sessionComplete: true, + }, + }, + update: { + status: SessionProgressStatus.completed, + startedAt: completedAt, + completedAt, + completionMetadata: { + reason: 'Dev jump-to-session-11 helper', + sessionComplete: true, + }, + }, + }); + } + }); + + const progress = await prisma.sessionProgress.findMany({ + where: { userId, status: SessionProgressStatus.completed }, + select: { sessionOrder: true }, + orderBy: { sessionOrder: 'asc' }, + }); + const journey = await prisma.learningJourney.findUnique({ where: { userId } }); + + console.log( + JSON.stringify( + { + email: identity.email ?? identity.providerSubject, + profileName: identity.user.profile?.name ?? null, + completedSessions: progress.map((p) => p.sessionOrder), + nextSession: PROGRAM_SESSION_COUNT, + trainingCompletedAt: journey?.trainingCompletedAt ?? null, + postTestCompletedAt: journey?.postTestCompletedAt ?? null, + hint: 'Login → open Chat. You should land on Session 11 review. Finish the wrap-up to reach post-test.', + }, + null, + 2, + ), + ); +} + +main() + .catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/scripts/reset-user-password.ts b/backend/scripts/reset-user-password.ts new file mode 100644 index 0000000..750aabd --- /dev/null +++ b/backend/scripts/reset-user-password.ts @@ -0,0 +1,50 @@ +/** + * Dev helper: reset password for a password-auth user. + * Usage: npx tsx scripts/reset-user-password.ts + */ +import { prisma } from '../src/lib/prisma.ts'; +import { hashPassword } from '../src/modules/auth/password.ts'; + +const email = process.argv[2]?.trim().toLowerCase(); +const nextPassword = process.argv[3]; + +if (!email || !nextPassword) { + console.error('Usage: npx tsx scripts/reset-user-password.ts '); + process.exit(1); +} + +if (nextPassword.length < 8 || nextPassword.length > 128) { + console.error('Password must be 8–128 characters'); + process.exit(1); +} + +const identity = await prisma.authIdentity.findFirst({ + where: { + provider: 'password', + OR: [{ email }, { providerSubject: email }], + }, +}); + +if (!identity) { + console.error(`No password account found for ${email}`); + process.exit(1); +} + +const passwordHash = await hashPassword(nextPassword); +await prisma.authIdentity.update({ + where: { id: identity.id }, + data: { passwordHash }, +}); + +console.log( + JSON.stringify( + { + email: identity.email ?? identity.providerSubject, + passwordUpdated: true, + }, + null, + 2, + ), +); + +await prisma.$disconnect(); diff --git a/backend/scripts/verify-final-session-completion.ts b/backend/scripts/verify-final-session-completion.ts new file mode 100644 index 0000000..c348766 --- /dev/null +++ b/backend/scripts/verify-final-session-completion.ts @@ -0,0 +1,74 @@ +import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.ts'; +import { getSessionByOrder } from '../src/modules/sessions/catalog.ts'; +import { + processCoachAssistantResponse, + looksLikeFinalPostTestHandoff, + COACH_SESSION_COMPLETE_OPEN, + COACH_SESSION_COMPLETE_CLOSE, +} from '../src/modules/ai/sessionCompletion.ts'; + +const s11 = getSessionByOrder(11); +if (!s11) throw new Error('no s11'); +const ctx = buildAiCoachSessionContext(s11); + +const samples = [ + 'دوره آموزشی شما تمام شد. حالا پس‌آزمون را در اپ انجام دهید.', + 'جلسات تموم شد؛ نوبت پس‌آزمون است.', + 'مرور تمام شد. لطفا پس‌آزمون را انجام دهید.', + 'فقط یک جمله معمولی بدون اشاره.', +]; + +let failed = 0; +for (const t of samples) { + const handoff = looksLikeFinalPostTestHandoff(t); + const expect = t !== samples[3]; + if (handoff !== expect) { + failed += 1; + console.error('handoff mismatch', { t, handoff, expect }); + } +} + +const text = 'مرور جلسات تمام شد. حالا پس‌آزمون را در برنامه انجام دهید.'; +const r = processCoachAssistantResponse(text, ctx, { userTurnCount: 4, alreadyCompleted: false }); +if (!r.validation?.valid || r.signal?.sessionOrder !== 11 || r.signal?.readyForNextSession !== false) { + failed += 1; + console.error('synthetic completion failed', r.validation, r.signal); +} + +const badSignal = { + sessionComplete: true, + sessionId: 'wrong', + sessionOrder: 11, + reason: 'x', + coveredTopics: ['نامرتبط'], + completedExercises: [], + userConfirmed: true, + readyForNextSession: true, +}; + +// Without handoff text, a bad marker must NOT finish the course. +const badNoHandoff = processCoachAssistantResponse( + `ادامه مرور\n${COACH_SESSION_COMPLETE_OPEN}\n${JSON.stringify(badSignal)}\n${COACH_SESSION_COMPLETE_CLOSE}`, + ctx, + { userTurnCount: 3, alreadyCompleted: false }, +); +if (badNoHandoff.validation?.valid) { + failed += 1; + console.error('bad marker without handoff should not validate', badNoHandoff.validation); +} + +const bad = processCoachAssistantResponse( + `دوره تمام شد. حالا پس‌آزمون را در برنامه انجام دهید.\n${COACH_SESSION_COMPLETE_OPEN}\n${JSON.stringify(badSignal)}\n${COACH_SESSION_COMPLETE_CLOSE}`, + ctx, + { userTurnCount: 3, alreadyCompleted: false }, +); +if (!bad.validation?.valid || bad.signal?.sessionId !== ctx.sessionId || bad.signal?.readyForNextSession !== false) { + failed += 1; + console.error('repair with handoff failed', bad.validation, bad.signal); +} + +if (failed > 0) { + console.error(`FAILED ${failed}`); + process.exit(1); +} +console.log('session-11 completion recovery OK'); diff --git a/backend/src/modules/ai/coachPrompt.ts b/backend/src/modules/ai/coachPrompt.ts index 79b2095..cca8f0b 100644 --- a/backend/src/modules/ai/coachPrompt.ts +++ b/backend/src/modules/ai/coachPrompt.ts @@ -21,12 +21,23 @@ const stripHtml = (html: string): string => .replace(/\s+/g, ' ') .trim(); +const mentionsPretest = (text: string): boolean => + text.includes('پیش‌آزمون') || text.includes('پیش آزمون'); + const isPretestExercise = (block: ContentBlock): boolean => { if (block.type !== 'exercise') return false; const haystack = `${block.title ?? ''} ${block.prompt ?? ''}`; - return haystack.includes('پیش‌آزمون') || haystack.includes('پیش آزمون'); + return mentionsPretest(haystack); }; +/** Remove pre-test clauses so the model never sees them as a spoken «next section». */ +const stripPretestFromSpeech = (text: string): string => + text + .replace(/\s*و\s*پیش[‌\s]*آزمون[^.؟!]*[.؟!]?/g, '') + .replace(/\s*پیش[‌\s]*آزمون[^.؟!]*[.؟!]?/g, '') + .replace(/\s+/g, ' ') + .trim(); + const hasUserDirectedSpousePrompt = (text: string): boolean => text.includes('همسر خود') || text.includes('با همسر') || @@ -35,18 +46,26 @@ const hasUserDirectedSpousePrompt = (text: string): boolean => const formatBlock = (block: ContentBlock): string | null => { if (isPretestExercise(block)) { - return `[silent-skip exercise] «پیش‌آزمون» is an in-app assessment, NOT course teaching. NEVER mention پیش‌آزمون, پیش آزمون, pre-test, or PCQ to the user. Skip this block silently and continue the educational sequence.`; + return `[silent-skip exercise] Pre-test is OUTSIDE chat and already done before Session 1. NEVER say پیش‌آزمون / پیش آزمون / pre-test / PCQ. NEVER announce it as «بخش بعدی». NEVER say it is done separately in the app. Skip silently; after the last educational section, go to session wrap-up — do not invent a fake next teaching section about assessment.`; } switch (block.type) { case 'callout': return `[callout${block.title ? ` — ${block.title}` : ''}] ${block.body}`; case 'heading': + if (mentionsPretest(block.text)) { + // Omit from spoken roadmap/content — listing it causes the model to announce it. + return null; + } return `[heading h${block.level ?? 3}] ${block.text}`; case 'text': { const body = block.format === 'html' ? stripHtml(block.body) : block.body; - if (body.includes('پیش‌آزمون') || body.includes('پیش آزمون')) { - return `[text — OMIT پیش‌آزمون when speaking; it is not part of the educational conversation] ${body}`; + if (mentionsPretest(body)) { + const cleaned = stripPretestFromSpeech(body); + if (!cleaned || cleaned.length < 8) { + return null; + } + return `[text] ${cleaned}`; } return `[text] ${body}`; } @@ -131,6 +150,8 @@ const listSessionHeadings = (blocks: ContentBlock[], sessionTitle?: string): str if (b.type !== 'heading') return null; const text = b.text.replace(/\s+/g, ' ').trim(); if (titleNorm && text === titleNorm) return null; + // Never list pre-test in the spoken roadmap (learner already completed it in-app). + if (mentionsPretest(text)) return null; if (seen.has(text)) return null; seen.add(text); return `- ${text}`; @@ -183,10 +204,11 @@ SESSION 1 — FIRST EDUCATIONAL EXPERIENCE (mandatory): • that learning happens through conversation and interaction along a guided path - Introduce yourself as «نویا» at most ONCE in this first greeting. After that, never repeat identity lines. - Then introduce Session 1 «${context.title}», list THIS session's contents from SESSION OUTLINE, and begin teaching the first section. -- NEVER MENTION PRE-TEST IN USER-FACING CHAT: Do not say «پیش‌آزمون»، «پیش آزمون»، pre-test, PCQ, or that an assessment was already completed. The pre-test is a separate in-app flow and is NOT part of the educational conversation. -- The «پیش‌آزمون» exercise block is a silent skip. Do NOT treat it as unfinished work. Do NOT retake, recreate, simulate, or substitute it in chat. Do NOT repeat the pre-test inside Chat. Do NOT acknowledge or explain it to the user. When you reach it internally: skip it silently and continue Session 1 teaching. -- If a Session 1 text block mentions that a pre-test is administered: OMIT that phrase when speaking. Teach orientation only. -- If the user asks about پیش‌آزمون: do not explain it; say this chat is for the educational sessions, then continue the lesson. +- NEVER MENTION PRE-TEST IN USER-FACING CHAT: Do not say «پیش‌آزمون»، «پیش آزمون»، pre-test, PCQ, or that an assessment was already completed. The pre-test is a separate in-app flow done BEFORE chat and is NOT part of the educational conversation. +- The «پیش‌آزمون» exercise block is a silent skip. Do NOT treat it as unfinished work. Do NOT retake, recreate, simulate, or substitute it in chat. Do NOT repeat the pre-test inside Chat. Do NOT acknowledge or explain it to the user. +- CRITICAL: Do NOT announce pre-test as the next section. FORBIDDEN meta lines (and any paraphrase): «بخش بعدی پیش‌آزمون است»، «پیش‌آزمون به صورت جداگانه در اپلیکیشن انجام می‌شود»، «ما در اینجا به آن نمی‌پردازیم»، «پیش‌آزمون را اینجا انجام نمی‌دهیم». If you skip it, skip with ZERO words about it. +- After the last educational section («ضوابط مربوط به درمان»), Session 1 teaching is done. Silent-skip pre-test internally, then move to wrap-up / close confirmation — do not invent another teaching section about assessment. +- If the user asks about پیش‌آزمون: do not explain it; say this chat is for the educational sessions, then continue or wrap up the lesson. - Follow Session 1 blocks in their defined order (orientation → topic sections → later items). Do NOT ask the user to choose between «نقش‌ها / مسئولیت‌ها» and «چارچوب برنامه» or any other lesson branches. - CRITICAL THIN-CONTENT RULE: Several Session 1 sections are topic markers only (e.g. «نقش‌های روان‌شناس»، «مسئولیت‌های شرکت‌کنندگان»، «ضوابط مربوط به درمان»). Body text may only say that these are explained — without listing them. In that case: briefly restate the available wording / purpose of the section, ask ONE focused check-in when useful, then advance. Do NOT invent role lists, responsibility lists, treatment rules, clinical duties, ethics codes, or therapy-frame details from general psychological knowledge. - Preserve course framing words that appear in the blocks (e.g. «درمان»، «روان‌شناس») when teaching, but still present yourself as an educational coach delivering this program — not as a licensed clinician providing psychotherapy. @@ -327,7 +349,9 @@ STRICT COURSE GROUNDING (mandatory — highest priority after safety): ASSESSMENT BOUNDARY (mandatory — Chat is NOT an assessment tool): - PCQ-24 pre-test and post-test are separate in-app assessment flows handled outside Chat. They are NOT educational course content. -- NEVER mention «پیش‌آزمون»، «پیش آزمون», pre-test, or PCQ in user-facing Persian. Do not bring it up, explain it, or say it was already completed. +- NEVER mention «پیش‌آزمون»، «پیش آزمون», pre-test, or PCQ in user-facing Persian. Do not bring it up, explain it, disclaimer it, or say it was already completed. +- FORBIDDEN disclaimers / transitions (never say these or paraphrases): talking about پیش‌آزمون as «بخش بعدی», saying it happens separately in the app, saying you will not cover it here, or explaining why you skip it. Silent skip means ZERO user-facing words about it. +- SESSION OUTLINE must never include پیش‌آزمون. Do not list it in the roadmap. After «ضوابط مربوط به درمان», wrap up the session — do not invent a next educational block about assessment. - You must NEVER in chat: • administer another psychological assessment or screening • invent questionnaire items or substitute pre-test/post-test activities @@ -472,7 +496,7 @@ TEACHING LOOP: 6. Then advance to the next block in order without asking them to pick the path. 7. NEVER dump the entire lesson in one message. Listing the outline is not dumping the lesson. 8. NEVER invent exercises, reflections, quotes, role lists, questionnaires, rating scales, or topics not present in the session blocks above. -9. Introduce exercises only when you reach them in the teaching path. For Session 1 «پیش‌آزمون»: silent skip — NEVER mention it to the user. For Session 11 «پس‌آزمون»: never administer in chat; mention the in-app flow only when that block is reached. +9. Introduce exercises only when you reach them in the teaching path. For Session 1 «پیش‌آزمون»: silent skip with ZERO words about it — do not announce «بخش بعدی» as pre-test; after educational sections, wrap up. For Session 11 «پس‌آزمون»: never administer in chat; mention the in-app flow only when that block is reached. 10. NEVER convert a teaching question into a multi-item psychological survey or symptom checklist. 11. When criteria in SESSION COMPLETION SIGNAL are fully met, emit the structured completion block (see below). The application validates it and saves progress — you do NOT save progress yourself. 12. Stay on this session until completion is validated; do not teach locked/future sessions. diff --git a/backend/src/modules/ai/sessionCompletion.ts b/backend/src/modules/ai/sessionCompletion.ts index 218aa1a..94a8174 100644 --- a/backend/src/modules/ai/sessionCompletion.ts +++ b/backend/src/modules/ai/sessionCompletion.ts @@ -193,16 +193,84 @@ export function validateCoachSessionCompletionSignal( return { valid: true, signal: s as CoachSessionCompletionSignal }; } -/** Final session: coach directed learner to in-app post-test without emitting the hidden block. */ -const looksLikeFinalPostTestHandoff = (text: string): boolean => { - const normalized = text.replace(/\u200c/g, '').replace(/\s+/g, ' '); +/** Strip ZWNJ and collapse whitespace for Persian phrase matching. */ +const normalizePersian = (text: string): string => + text.replace(/\u200c/g, '').replace(/\s+/g, ' ').trim(); + +/** + * Final session: coach wrapped up the review and directed the learner to the in-app post-test, + * with or without a hidden completion block. + */ +export const looksLikeFinalPostTestHandoff = (text: string): boolean => { + const normalized = normalizePersian(text); + if (!normalized) return false; + const mentionsPostTest = /پس[\s\-]?آزمون/.test(normalized); - const directsToApp = - /(اپ|اپلیکیشن|برنامه|مراجعه|بروید|برید|انجام بده|بزن)/.test(normalized) || - /(جلسه|دوره).{0,40}(تمام|تموم|پایان|تمام شده)/.test(normalized); - return mentionsPostTest && directsToApp; + const courseFinished = + /(جلسه|جلسات|دوره|آموزش|برنامه).{0,50}(تمام|تموم|پایان|به پایان|تمام شده|تموم شده|به اتمام)/.test( + normalized, + ) || + /(تمام|تموم|پایان).{0,40}(جلسه|جلسات|دوره|آموزش|برنامه)/.test(normalized) || + /مرور.{0,30}(تمام|تموم|پایان)/.test(normalized); + + const directsToAppOrTest = + /(اپ|اپلیکیشن|برنامه|مراجعه|بروید|برید|بزن|انجام بده|انجام دهید|بدهید|هدایت)/.test(normalized) || + /(حالا|الان|اکنون).{0,30}(پس[\s\-]?آزمون|آزمون)/.test(normalized) || + /(نوبت|وقت).{0,20}(پس[\s\-]?آزمون|آزمون)/.test(normalized); + + // Strong signal: post-test + (app/direction OR course finished) + if (mentionsPostTest && (directsToAppOrTest || courseFinished)) { + return true; + } + + // Soft signal: post-test mentioned together with explicit closing language + if (mentionsPostTest && /(جمع[\s\-]?بندی نهایی|ختم دوره|خداحافظ|موفق باشید)/.test(normalized)) { + return true; + } + + return false; }; +/** Repair common AI mistakes on final-session completion payloads. */ +export function normalizeFinalSessionSignal( + signal: Partial, + context: AiCoachSessionContext, + reason?: string, +): CoachSessionCompletionSignal { + const base = exampleCompletionSignal(context); + const headings = extractSessionHeadings(context.blocks); + const minTopicsRequired = Math.max(1, Math.ceil(headings.length * 0.6)); + const incomingTopics = + isStringArray(signal.coveredTopics) && signal.coveredTopics.length > 0 + ? signal.coveredTopics + : []; + const matchedHeadings = headings.filter((heading) => listMatchesAny(incomingTopics, heading)); + const coveredTopics = + matchedHeadings.length >= minTopicsRequired ? incomingTopics : base.coveredTopics; + + const completedExercises = isStringArray(signal.completedExercises) + ? signal.completedExercises + : base.completedExercises; + const completedReflections = isStringArray(signal.completedReflections) + ? signal.completedReflections + : base.completedReflections; + + return { + sessionComplete: true, + sessionId: context.sessionId, + sessionOrder: context.order, + reason: + (isNonEmptyString(signal.reason) ? signal.reason : null) ?? + reason ?? + base.reason, + coveredTopics, + completedExercises: completedExercises.length > 0 ? completedExercises : base.completedExercises, + completedReflections, + userConfirmed: true, + readyForNextSession: false, + }; +} + export function processCoachAssistantResponse( rawAssistantText: string, context: AiCoachSessionContext, @@ -212,27 +280,59 @@ export function processCoachAssistantResponse( }, ): CoachCompletionProcessResult & { validation: CoachCompletionValidationResult | null } { const parsed = parseCoachCompletionFromAssistantText(rawAssistantText); + const handoff = + context.isFinalSession && looksLikeFinalPostTestHandoff(parsed.displayContent); let signal = parsed.signal; + + // Light repair only: fix identity/flags AI often gets wrong. Do not invent coverage. + if (signal && context.isFinalSession) { + signal = { + ...signal, + sessionComplete: true, + sessionId: context.sessionId, + sessionOrder: context.order, + readyForNextSession: false, + userConfirmed: signal.userConfirmed === true || handoff, + }; + } + if ( !signal && context.isFinalSession && !options.alreadyCompleted && options.userTurnCount >= 2 && - looksLikeFinalPostTestHandoff(parsed.displayContent) + handoff ) { - signal = { - ...exampleCompletionSignal(context), - reason: - 'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).', - }; + signal = normalizeFinalSessionSignal( + {}, + context, + 'Final-session review wrap-up detected; learner directed to in-app post-test (synthetic completion).', + ); } if (!signal) { return { ...parsed, validation: null }; } - const validation = validateCoachSessionCompletionSignal(signal, context, options); + let validation = validateCoachSessionCompletionSignal(signal, context, options); + + // Full catalog recovery only after a clear post-test handoff in the visible message. + if ( + !validation.valid && + context.isFinalSession && + !options.alreadyCompleted && + options.userTurnCount >= 2 && + handoff + ) { + signal = normalizeFinalSessionSignal( + signal, + context, + 'Final-session completion recovered with catalog defaults after wrap-up/handoff.', + ); + validation = validateCoachSessionCompletionSignal(signal, context, options); + } + return { ...parsed, signal, validation }; } diff --git a/backend/src/modules/assessments/assessments.service.ts b/backend/src/modules/assessments/assessments.service.ts index ba7f851..1dec4c4 100644 --- a/backend/src/modules/assessments/assessments.service.ts +++ b/backend/src/modules/assessments/assessments.service.ts @@ -5,6 +5,7 @@ import { getOrCreateJourney, deriveCurrentStep, derivePermissions, + countCompletedSessions, } from '../journey/journey.service.js'; import { PCQ_QUESTION_IDS, @@ -94,8 +95,11 @@ export function getPcq24Scale() { } export async function submitAttempt(userId: bigint, input: SubmitAttemptInput) { - const journey = await getOrCreateJourney(userId); - const permissions = derivePermissions(journey); + const [journey, completedSessionCount] = await Promise.all([ + getOrCreateJourney(userId), + countCompletedSessions(userId), + ]); + const permissions = derivePermissions(journey, { completedSessionCount }); if (input.phase === 'pre' && !permissions.canTakePreTest) { throw new ConflictError('Pre-test already completed'); @@ -212,10 +216,11 @@ export async function getLatestAttempt(userId: bigint, phase: AttemptPhase) { } export async function getAssessmentSummary(userId: bigint) { - const [pre, post, journey] = await Promise.all([ + const [pre, post, journey, completedSessionCount] = await Promise.all([ getLatestAttempt(userId, AttemptPhase.pre), getLatestAttempt(userId, AttemptPhase.post), getOrCreateJourney(userId), + countCompletedSessions(userId), ]); return { @@ -223,7 +228,7 @@ export async function getAssessmentSummary(userId: bigint) { post, journey: { currentStep: deriveCurrentStep(journey), - permissions: derivePermissions(journey), + permissions: derivePermissions(journey, { completedSessionCount }), }, }; } diff --git a/backend/src/modules/chat/chat.service.ts b/backend/src/modules/chat/chat.service.ts index b5139c8..831a3c0 100644 --- a/backend/src/modules/chat/chat.service.ts +++ b/backend/src/modules/chat/chat.service.ts @@ -8,6 +8,7 @@ import { } from '../../shared/errors/index.js'; import { getOrCreateJourney, derivePermissions } from '../journey/journey.service.js'; import { + completeSession, getCompletedOrders, getSessionCompletionTimes, isSessionUnlockedForProgress, @@ -299,6 +300,21 @@ async function runCoachTurn(params: { }, }); + // Persist progress as soon as the coach signal validates so session 11 + // reaches 100% / trainingCompletedAt even if the client never retries complete. + if (processed.validation?.valid && processed.signal && !alreadyCompleted) { + try { + await completeSession( + params.userId, + params.chatSession.sessionOrder, + processed.signal, + params.chatSession.id, + ); + } catch (err) { + console.error('[chat] Failed to auto-complete session after valid coach signal:', err); + } + } + return { message: serializeMessage(assistant), completion: processed.validation?.valid diff --git a/backend/src/modules/journey/journey.service.ts b/backend/src/modules/journey/journey.service.ts index cb11e11..ad8501c 100644 --- a/backend/src/modules/journey/journey.service.ts +++ b/backend/src/modules/journey/journey.service.ts @@ -1,5 +1,6 @@ +import { SessionProgressStatus, type LearningJourney } from '@prisma/client'; import { prisma } from '../../lib/prisma.js'; -import type { LearningJourney } from '@prisma/client'; +import { PROGRAM_SESSION_COUNT } from '../sessions/catalog.js'; export type LearningStep = | 'registered' @@ -26,6 +27,9 @@ export type PublicJourney = { permissions: JourneyPermissions; }; +/** Sessions 1..(N-1) done ⇒ learner is on final session and may open post-test manually. */ +export const POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS = PROGRAM_SESSION_COUNT - 1; + /** * Timestamps are authoritative. currentStep is a denormalized cache of this derivation: * registered → pre_test_done → training_in_progress → training_completed → completed @@ -47,12 +51,25 @@ export function deriveCurrentStep(journey: LearningJourney): LearningStep { return 'registered'; } -export function derivePermissions(journey: LearningJourney): JourneyPermissions { +export type DerivePermissionsOptions = { + /** Number of educational sessions marked completed (1..11). */ + completedSessionCount?: number; +}; + +export function derivePermissions( + journey: LearningJourney, + options: DerivePermissionsOptions = {}, +): JourneyPermissions { + const completedSessionCount = options.completedSessionCount ?? 0; + const reachedFinalSession = + completedSessionCount >= POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS || + Boolean(journey.trainingCompletedAt); + return { canTakePreTest: !journey.preTestCompletedAt, canOpenTraining: Boolean(journey.preTestCompletedAt), canTakePostTest: Boolean( - journey.preTestCompletedAt && journey.trainingCompletedAt && !journey.postTestCompletedAt, + journey.preTestCompletedAt && reachedFinalSession && !journey.postTestCompletedAt, ), isCompleted: Boolean(journey.postTestCompletedAt), }; @@ -62,7 +79,16 @@ function toIso(value: Date | null): string | null { return value ? value.toISOString() : null; } -export function toPublicJourney(journey: LearningJourney): PublicJourney { +export async function countCompletedSessions(userId: bigint): Promise { + return prisma.sessionProgress.count({ + where: { userId, status: SessionProgressStatus.completed }, + }); +} + +export function toPublicJourney( + journey: LearningJourney, + options: DerivePermissionsOptions = {}, +): PublicJourney { const currentStep = deriveCurrentStep(journey); return { currentStep, @@ -71,7 +97,7 @@ export function toPublicJourney(journey: LearningJourney): PublicJourney { trainingCompletedAt: toIso(journey.trainingCompletedAt), postTestCompletedAt: toIso(journey.postTestCompletedAt), lastActivityAt: journey.lastActivityAt.toISOString(), - permissions: derivePermissions(journey), + permissions: derivePermissions(journey, options), }; } @@ -87,16 +113,19 @@ export async function getOrCreateJourney(userId: bigint): Promise { - const journey = await getOrCreateJourney(userId); + const [journey, completedSessionCount] = await Promise.all([ + getOrCreateJourney(userId), + countCompletedSessions(userId), + ]); const currentStep = deriveCurrentStep(journey); if (journey.currentStep !== currentStep) { const healed = await prisma.learningJourney.update({ where: { userId }, data: { currentStep }, }); - return toPublicJourney(healed); + return toPublicJourney(healed, { completedSessionCount }); } - return toPublicJourney(journey); + return toPublicJourney(journey, { completedSessionCount }); } export function syncCurrentStep(journey: LearningJourney): LearningStep { diff --git a/backend/src/modules/sessions/sessions.service.ts b/backend/src/modules/sessions/sessions.service.ts index 0054d47..0dac6e5 100644 --- a/backend/src/modules/sessions/sessions.service.ts +++ b/backend/src/modules/sessions/sessions.service.ts @@ -253,6 +253,19 @@ export async function completeSession( where: { userId, sessionOrder: order, endedAt: null }, data: { endedAt: now }, }); + + // Repair stuck journeys: all sessions done but trainingCompletedAt never set. + if (completedOrders.length >= PROGRAM_SESSION_COUNT && !journey.trainingCompletedAt) { + await prisma.learningJourney.update({ + where: { userId }, + data: { + trainingCompletedAt: now, + lastActivityAt: now, + currentStep: 'training_completed', + }, + }); + } + return getSessionProgress(userId); } diff --git a/backend/tests/e2e-qa.test.ts b/backend/tests/e2e-qa.test.ts index d83b4d5..21e48bb 100644 --- a/backend/tests/e2e-qa.test.ts +++ b/backend/tests/e2e-qa.test.ts @@ -224,7 +224,8 @@ describe('full E2E learning journey QA', () => { const beforeFinal = await requestJson(app, '/journey', { headers: headers(token, ownerIp) }); assert.equal( (beforeFinal.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest, - false, + true, + 'post-test should unlock once the learner reaches session 11', ); await completeSession(token, ownerIp, PROGRAM_SESSION_COUNT, owner.user.id); diff --git a/index.html b/index.html index 33660cb..df76b57 100644 --- a/index.html +++ b/index.html @@ -2,17 +2,14 @@ - + + + + - - نویا @@ -20,7 +17,3 @@ - - - - diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..8d985b5 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000..5746ffe Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..a39c641 Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon-64x64.png b/public/favicon-64x64.png new file mode 100644 index 0000000..d232efa Binary files /dev/null and b/public/favicon-64x64.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..8194d56 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/fonts/iransansx/IRANSansX-Bold.woff2 b/public/fonts/iransansx/IRANSansX-Bold.woff2 new file mode 100644 index 0000000..e0f1095 Binary files /dev/null and b/public/fonts/iransansx/IRANSansX-Bold.woff2 differ diff --git a/public/fonts/iransansx/IRANSansX-Medium.woff2 b/public/fonts/iransansx/IRANSansX-Medium.woff2 new file mode 100644 index 0000000..bd38463 Binary files /dev/null and b/public/fonts/iransansx/IRANSansX-Medium.woff2 differ diff --git a/public/fonts/iransansx/IRANSansX-Regular.woff2 b/public/fonts/iransansx/IRANSansX-Regular.woff2 new file mode 100644 index 0000000..9861159 Binary files /dev/null and b/public/fonts/iransansx/IRANSansX-Regular.woff2 differ diff --git a/public/logo/logo.ico b/public/logo/logo.ico new file mode 100644 index 0000000..8194d56 Binary files /dev/null and b/public/logo/logo.ico differ diff --git a/public/logo/mainlogo.png b/public/logo/mainlogo.png new file mode 100644 index 0000000..ccba46c Binary files /dev/null and b/public/logo/mainlogo.png differ diff --git a/public/logo/mainlogo.svg b/public/logo/mainlogo.svg new file mode 100644 index 0000000..b35e318 --- /dev/null +++ b/public/logo/mainlogo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png new file mode 100644 index 0000000..0ccaf97 Binary files /dev/null and b/public/pwa-192x192.png differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png new file mode 100644 index 0000000..e0f1a9e Binary files /dev/null and b/public/pwa-512x512.png differ diff --git a/scripts/verify-coach-prompt.ts b/scripts/verify-coach-prompt.ts index 3aec2c0..e65b1d3 100644 --- a/scripts/verify-coach-prompt.ts +++ b/scripts/verify-coach-prompt.ts @@ -63,9 +63,13 @@ const checks = { thinContentRule: prompt.includes('CRITICAL THIN-CONTENT RULE'), noSubstitutePretest: prompt.includes('Do NOT retake, recreate, simulate, or substitute'), noRepeatPretestInChat: prompt.includes('Do NOT repeat the pre-test inside Chat'), - silentSkipPretest: prompt.includes('silent skip'), - omitPretestWhenSpeaking: prompt.includes('OMIT that phrase when speaking'), + silentSkipPretest: prompt.includes('silent skip') || prompt.includes('silent-skip'), + omitPretestWhenSpeaking: prompt.includes('ZERO words about it'), neverMentionPretestToUser: prompt.includes('NEVER mention «پیش‌آزمون»'), + noPretestNextSectionAnnounce: prompt.includes('Do NOT announce pre-test as the next section'), + noPretestAppDisclaimer: prompt.includes('به صورت جداگانه در اپلیکیشن انجام می‌شود'), + noPretestInOutline: !prompt.includes('SESSION OUTLINE') || !prompt.match(/SESSION OUTLINE[\s\S]*?- پیش‌آزمون/), + pretestStrippedFromIntroText: !prompt.includes('و پیش‌آزمون اجراء می‌شود'), assessmentResultsRule: prompt.includes('do NOT invent interpretation'), completionSignal: prompt.includes('SESSION COMPLETION SIGNAL'), completionMarker: prompt.includes('COACH_SESSION_COMPLETE'), diff --git a/src/App.tsx b/src/App.tsx index 21e2b9f..1a74d12 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'; import { AppHeader } from '@/components/AppHeader'; import { HomeRedirect } from '@/components/HomeRedirect'; import { useTheme } from '@/hooks/useTheme'; +import { PageSkeleton } from '@/components/skeletons'; const AccountPage = lazy(() => import('@/pages/Account/AccountPage')); const ChatPage = lazy(() => import('@/pages/Chat/ChatPage')); @@ -14,11 +15,7 @@ const LoginPage = lazy(() => import('@/pages/Auth/Login/LoginPage')); const SignupPage = lazy(() => import('@/pages/Auth/Signup/SignupPage')); const ProfileCompletionPage = lazy(() => import('@/pages/Profile/ProfileCompletionPage')); -const PageLoader = () => ( -
-
-
-); +const PageLoader = () => ; function AppRoutes() { return ( diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 92fd005..76200e7 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -3,16 +3,18 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { useAuth } from '@/hooks/useAuth'; import { APP_NAME } from '@/utils/constants'; +import { BrandLogo } from '@/components/BrandLogo'; import { ClipboardList, LogOut, Moon, Sun, User } from 'lucide-react'; import { MOTION_DURATION, MOTION_EASE, fastTransition } from '@/lib/motion'; import { useTheme } from '@/hooks/useTheme'; +import { cn } from '@/lib/utils'; /** * Minimal top shell: product mark + profile menu (Course Status, Logout). * Replaces the former bottom navigation. */ export function AppHeader() { - const { isAuthenticated, signOut, isGuest } = useAuth(); + const { isAuthenticated, signOut, isGuest, user } = useAuth(); const { toggleTheme, isDark } = useTheme(); const navigate = useNavigate(); const location = useLocation(); @@ -21,6 +23,9 @@ export function AppHeader() { const menuRef = useRef(null); const isAuthPage = location.pathname === '/login' || location.pathname === '/signup'; + const usernameLabel = isGuest + ? 'مهمان' + : user?.email?.trim() || 'کاربر'; useEffect(() => { setMenuOpen(false); @@ -35,8 +40,16 @@ export function AppHeader() { } }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setMenuOpen(false); + }; + document.addEventListener('mousedown', onPointerDown); - return () => document.removeEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; }, [menuOpen]); const handleSignOut = async () => { @@ -52,24 +65,31 @@ export function AppHeader() { return ( <> -
+
- {APP_NAME} +
setMenuOpen((open) => !open)} - whileHover={{ scale: 1.05 }} + whileHover={{ scale: 1.04 }} whileTap={{ scale: 0.96 }} transition={fastTransition} - className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-cyan-200 bg-white text-cyan-700 shadow-sm transition-colors hover:border-cyan-500 hover:text-cyan-800 dark:border-neon-cyan/40 dark:bg-background/70 dark:text-neon-cyan dark:shadow-none dark:hover:border-neon-cyan dark:hover:text-neon-cyan dark:hover:shadow-glow-sm" + className={cn( + 'inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors duration-200', + 'border-slate-200 bg-white text-sky-700 shadow-sm', + 'hover:border-sky-300 hover:bg-sky-50 hover:text-sky-800', + 'dark:border-slate-600/60 dark:bg-slate-800 dark:text-sky-300 dark:shadow-none', + 'dark:hover:border-slate-500 dark:hover:bg-slate-700 dark:hover:text-sky-200', + menuOpen && 'border-sky-400 ring-2 ring-sky-400/25 dark:border-sky-500 dark:ring-sky-500/20' + )} aria-label="منوی پروفایل" aria-expanded={menuOpen} aria-haspopup="menu" @@ -80,39 +100,50 @@ export function AppHeader() { {menuOpen && ( +
+

نام کاربری

+

+ {usernameLabel} +

+
setMenuOpen(false)} > - + وضعیت دوره
{showLogoutConfirm && ( -
+

خروج از حساب کاربری

-

آیا مطمئن هستید که می‌خواهید خارج شوید؟

+

آیا مطمئن هستید که می‌خواهید خارج شوید؟

)}
- - {successMessage && ( -

- {successMessage} -

- )}
); diff --git a/src/pages/Auth/Login/LoginPage.tsx b/src/pages/Auth/Login/LoginPage.tsx index df9828a..ef2673e 100644 --- a/src/pages/Auth/Login/LoginPage.tsx +++ b/src/pages/Auth/Login/LoginPage.tsx @@ -7,19 +7,20 @@ import { Label } from '@/components/ui/label'; import { useAuth } from '@/hooks/useAuth'; import { useTheme } from '@/hooks/useTheme'; import { useToast } from '@/components/ui/use-toast'; -import { getBackendErrorMessage } from '@/lib/backendError'; -import { Loader2, Mail, Lock, Moon, Sun, Sparkles } from 'lucide-react'; +import { getLoginErrorMessage } from '@/lib/backendError'; +import { Loader2, Mail, Lock, Moon, Sun, Eye, EyeOff } from 'lucide-react'; import { AnimationWrapper } from '@/components/motion'; -import { APP_NAME, APP_DESCRIPTION } from '@/utils/constants'; +import { BrandLogo } from '@/components/BrandLogo'; export default function LoginPage() { const navigate = useNavigate(); const location = useLocation(); - const { signIn, isAuthenticated, continueAsGuest } = useAuth(); + const { signIn, isAuthenticated } = useAuth(); const { toggleTheme, isDark } = useTheme(); const { toast } = useToast(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -39,10 +40,37 @@ export default function LoginPage() { const nextEmail = String(form.get('email') ?? email).trim(); const nextPassword = String(form.get('password') ?? password); - if (!nextEmail || !nextPassword) { + if (!nextEmail && !nextPassword) { toast({ title: 'خطا', - description: 'لطفا ایمیل و رمز عبور را وارد کنید', + description: 'لطفاً ایمیل و رمز عبور را وارد کنید.', + variant: 'destructive', + }); + return; + } + + if (!nextEmail) { + toast({ + title: 'خطا', + description: 'لطفاً ایمیل خود را وارد کنید.', + variant: 'destructive', + }); + return; + } + + if (!nextPassword) { + toast({ + title: 'خطا', + description: 'لطفاً رمز عبور را وارد کنید.', + variant: 'destructive', + }); + return; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(nextEmail)) { + toast({ + title: 'خطا', + description: 'فرمت ایمیل نامعتبر است. لطفاً یک ایمیل معتبر وارد کنید.', variant: 'destructive', }); return; @@ -58,8 +86,8 @@ export default function LoginPage() { } catch (error: unknown) { console.error('Login error:', error); toast({ - title: 'خطا', - description: getBackendErrorMessage(error, 'خطا در ورود. لطفاً دوباره تلاش کنید.'), + title: 'خطا در ورود', + description: getLoginErrorMessage(error), variant: 'destructive', }); } finally { @@ -67,23 +95,6 @@ export default function LoginPage() { } }; - const handleContinueWithoutLogin = async () => { - setIsLoading(true); - try { - await continueAsGuest(); - const fromState = (location.state as { from?: { pathname?: string } | string } | null)?.from; - const from = - typeof fromState === 'string' ? fromState : fromState?.pathname; - const safeFrom = - from && from !== '/login' && from !== '/signup' ? from : '/'; - navigate(safeFrom, { replace: true }); - } catch { - toast({ title: 'خطا', description: 'خطا در ایجاد حساب مهمان', variant: 'destructive' }); - } finally { - setIsLoading(false); - } - }; - return (
- -
حساب کاربری ندارید؟{' '} diff --git a/src/pages/Auth/Signup/SignupPage.tsx b/src/pages/Auth/Signup/SignupPage.tsx index 08018d5..c8b3019 100644 --- a/src/pages/Auth/Signup/SignupPage.tsx +++ b/src/pages/Auth/Signup/SignupPage.tsx @@ -8,8 +8,9 @@ import { useAuth } from '@/hooks/useAuth'; import { useTheme } from '@/hooks/useTheme'; import { useToast } from '@/components/ui/use-toast'; import { getBackendErrorMessage } from '@/lib/backendError'; -import { Loader2, UserPlus, Moon, Sun } from 'lucide-react'; +import { Loader2, Moon, Sun, Eye, EyeOff } from 'lucide-react'; import { APP_NAME, APP_DESCRIPTION } from '@/utils/constants'; +import { BrandLogo } from '@/components/BrandLogo'; export default function SignupPage() { const navigate = useNavigate(); @@ -19,6 +20,8 @@ export default function SignupPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); const handleSignup = async (e: React.FormEvent) => { @@ -83,15 +86,11 @@ export default function SignupPage() { className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-neon-blue/20 via-neon-cyan/5 to-transparent" aria-hidden /> - -
-
-
- -
-
+ +
+
- ثبت نام + ثبت نام ایجاد حساب کاربری در {APP_NAME} — {APP_DESCRIPTION} @@ -113,27 +112,53 @@ export default function SignupPage() {
- setPassword(e.target.value)} - disabled={isLoading} - autoComplete="new-password" - /> +
+ setPassword(e.target.value)} + disabled={isLoading} + className="pl-10" + autoComplete="new-password" + /> + +
- setConfirmPassword(e.target.value)} - disabled={isLoading} - autoComplete="new-password" - /> +
+ setConfirmPassword(e.target.value)} + disabled={isLoading} + className="pl-10" + autoComplete="new-password" + /> + +
+
+ )}
)} @@ -418,10 +580,8 @@ export default function ChatPage() { ref={messagesScrollRef} className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain" > - {loading ? ( -
- -
+ {loading && messages.length === 0 ? ( + ) : messages.length === 0 && !isAiThinking ? (
@@ -503,7 +663,7 @@ export default function ChatPage() { className={`shrink-0 text-[11px] font-medium ${ isPastSession ? 'text-muted-foreground/80' - : 'text-neon-cyan' + : 'text-sky-700 dark:text-sky-400' }`} > جلسه {msgSessionOrder} @@ -561,7 +721,7 @@ export default function ChatPage() { )}
-
+