Ship Novya PWA with backend for deployable production use.

Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ar.badiei
2026-09-03 21:01:52 +03:30
co-authored by Cursor
parent aa9236fb92
commit 370ac15471
193 changed files with 25997 additions and 1991 deletions
+100
View File
@@ -0,0 +1,100 @@
/**
* Prepare local dev: free backend port and ensure MySQL is reachable.
* Runs automatically via npm "predev" before `npm run dev`.
*/
import { execSync } from 'node:child_process';
import net from 'node:net';
import { platform } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const MYSQL_PORT = 3306;
const BACKEND_LISTEN_PORT = 3001;
const FRONTEND_PORT = 5173;
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const backendDir = join(rootDir, 'backend');
function log(msg) {
console.log(`[prepare-dev] ${msg}`);
}
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const socket = net.createConnection({ port, host }, () => {
socket.destroy();
resolve(true);
});
socket.on('error', () => resolve(false));
socket.setTimeout(1500, () => {
socket.destroy();
resolve(false);
});
});
}
function freePort(port) {
if (platform() === 'win32') {
try {
const out = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8' });
const pids = new Set();
for (const line of out.split('\n')) {
if (!line.includes('LISTENING')) continue;
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== '0') pids.add(pid);
}
for (const pid of pids) {
log(`Stopping process ${pid} on port ${port}`);
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
}
} catch {
// nothing listening
}
return;
}
try {
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: 'ignore', shell: true });
} catch {
// nothing listening
}
}
async function ensureMysql() {
if (await isPortOpen(MYSQL_PORT)) {
log(`MySQL is reachable on port ${MYSQL_PORT}`);
return;
}
log(`MySQL not reachable on port ${MYSQL_PORT}; starting Docker Compose...`);
try {
execSync('docker compose -f docker-compose.dev.yml up -d', {
cwd: backendDir,
stdio: 'inherit',
});
} catch {
console.error(
'[prepare-dev] ERROR: MySQL is not running. Start it with:\n' +
' cd backend && docker compose -f docker-compose.dev.yml up -d',
);
process.exit(1);
}
for (let i = 0; i < 30; i += 1) {
if (await isPortOpen(MYSQL_PORT)) {
log('MySQL is ready');
return;
}
await new Promise((r) => setTimeout(r, 1000));
}
console.error('[prepare-dev] ERROR: MySQL did not become ready in time.');
process.exit(1);
}
freePort(BACKEND_LISTEN_PORT);
freePort(FRONTEND_PORT);
await ensureMysql();
log(`Ready — backend will listen on port ${BACKEND_LISTEN_PORT}`);
+115
View File
@@ -0,0 +1,115 @@
/**
* Offline tests: AI Coach session completion schema + validation.
* Run: npx tsx --tsconfig tsconfig.json scripts/verify-coach-completion.ts
*/
import { getSessionByOrder } from '../src/data/program/sessions';
import { buildAiCoachSessionContext } from '../src/lib/aiCoach/sessionContext';
import {
COACH_SESSION_COMPLETE_CLOSE,
COACH_SESSION_COMPLETE_OPEN,
exampleCompletionSignal,
extractSessionExerciseLabels,
extractSessionHeadings,
parseCoachCompletionFromAssistantText,
validateCoachSessionCompletionSignal,
type CoachSessionCompletionSignal
} from '../src/lib/aiCoach/sessionCompletion';
import { buildCoachSystemPrompt } from '../src/lib/aiCoach/buildCoachSystemPrompt';
const USER_ID = 'verify-coach-user';
const session1 = getSessionByOrder(1);
if (!session1) throw new Error('Session 1 missing');
const ctx = buildAiCoachSessionContext(session1);
const prompt = buildCoachSystemPrompt(ctx);
const validSignal = exampleCompletionSignal(ctx);
const wrap = (signal: CoachSessionCompletionSignal, visible = 'جلسه به پایان رسید.') =>
`${visible}\n${COACH_SESSION_COMPLETE_OPEN}\n${JSON.stringify(signal)}\n${COACH_SESSION_COMPLETE_CLOSE}`;
let passed = 0;
let failed = 0;
const assert = (name: string, condition: boolean) => {
if (condition) {
passed += 1;
return;
}
failed += 1;
console.error(`FAIL: ${name}`);
};
// --- parse ---
const parsed = parseCoachCompletionFromAssistantText(wrap(validSignal));
assert('parse extracts signal', parsed.signal?.sessionComplete === true);
assert('parse strips marker from display', !parsed.displayContent.includes(COACH_SESSION_COMPLETE_OPEN));
assert('parse keeps visible Persian text', parsed.displayContent.includes('جلسه به پایان رسید'));
// --- validate valid ---
const ok = validateCoachSessionCompletionSignal(validSignal, ctx, {
userId: USER_ID,
userTurnCount: 3
});
assert('valid signal passes', ok.valid === true);
// --- reject: single user turn ---
const lowTurns = validateCoachSessionCompletionSignal(validSignal, ctx, {
userId: USER_ID,
userTurnCount: 1
});
assert('rejects single user turn', lowTurns.valid === false);
// --- reject: session id mismatch ---
const badId = validateCoachSessionCompletionSignal(
{ ...validSignal, sessionId: 'session-99' },
ctx,
{ userId: USER_ID, userTurnCount: 3 }
);
assert('rejects sessionId mismatch', badId.valid === false);
// --- reject: user not confirmed ---
const noConfirm = validateCoachSessionCompletionSignal(
{ ...validSignal, userConfirmed: false },
ctx,
{ userId: USER_ID, userTurnCount: 3 }
);
assert('rejects missing userConfirmed', noConfirm.valid === false);
// --- reject: missing exercise ---
const missingExercise = validateCoachSessionCompletionSignal(
{ ...validSignal, completedExercises: [] },
ctx,
{ userId: USER_ID, userTurnCount: 3 }
);
assert('rejects missing completed exercises', missingExercise.valid === false);
// --- reject: insufficient covered topics ---
const thinTopics = validateCoachSessionCompletionSignal(
{ ...validSignal, coveredTopics: ['نامرتبط'] },
ctx,
{ userId: USER_ID, userTurnCount: 3 }
);
assert('rejects insufficient coveredTopics', thinTopics.valid === false);
// --- prompt includes completion instructions ---
assert('prompt includes COACH_SESSION_COMPLETE marker', prompt.includes('COACH_SESSION_COMPLETE'));
assert('prompt includes sessionComplete field', prompt.includes('"sessionComplete":true'));
assert('prompt includes userConfirmed rule', prompt.includes('userConfirmed'));
// --- session 1 has pretest exercise in labels ---
const exerciseLabels = extractSessionExerciseLabels(ctx.blocks);
assert('session 1 has pretest exercise label', exerciseLabels.some((l) => l.includes('پیش‌آزمون')));
const headings = extractSessionHeadings(ctx.blocks);
assert('session 1 has headings', headings.length > 0);
console.log(JSON.stringify({ passed, failed, exerciseLabels, headingCount: headings.length }, null, 2));
if (failed > 0) {
console.error(`Verification failed: ${failed} check(s)`);
process.exit(1);
}
console.log('Coach completion schema + validation OK');
+187
View File
@@ -0,0 +1,187 @@
/**
* Dev check: Session 1 coach system prompt contains course context and teaching-style rules.
* Run: npx tsx --tsconfig tsconfig.json scripts/verify-coach-prompt.ts
*/
import { getSessionByOrder, PROGRAM_SESSION_COUNT } from '../src/data/program/sessions';
import {
buildCoachSystemPrompt,
buildCoachSessionStartCue,
COACH_SESSION_START_CUE
} from '../src/lib/aiCoach/buildCoachSystemPrompt';
const s = getSessionByOrder(1);
if (!s) throw new Error('Session 1 missing');
const s11 = getSessionByOrder(11);
if (!s11) throw new Error('Session 11 missing');
const ctx = {
sessionId: s.id,
order: s.order,
title: s.title,
description: s.description,
estimatedDurationMinutes: s.estimatedDurationMinutes,
objectivesHint: s.description,
blocks: s.blocks,
totalSessions: PROGRAM_SESSION_COUNT,
isFinalSession: s.order >= PROGRAM_SESSION_COUNT,
nextSessionUnlocksAt: null,
};
const prompt = buildCoachSystemPrompt(ctx);
const ctx11 = {
sessionId: s11.id,
order: s11.order,
title: s11.title,
description: s11.description,
estimatedDurationMinutes: s11.estimatedDurationMinutes,
objectivesHint: s11.description,
blocks: s11.blocks,
totalSessions: PROGRAM_SESSION_COUNT,
isFinalSession: true,
nextSessionUnlocksAt: null,
};
const prompt11 = buildCoachSystemPrompt(ctx11);
const startCue11 = buildCoachSessionStartCue(11);
const checks = {
// Existing course context & grounding
title: prompt.includes(s.title),
objective: prompt.includes(s.description),
exercise: prompt.includes('پیش‌آزمون'),
bansGenericHelp: prompt.includes('چه کمکی می‌تونم بکنم؟'),
hasOpeningRules: prompt.includes('SESSION OPENING'),
pretestNote: prompt.includes('NEVER MENTION PRE-TEST IN USER-FACING CHAT'),
groundingSection: prompt.includes('STRICT COURSE GROUNDING'),
assessmentBoundary: prompt.includes('ASSESSMENT BOUNDARY'),
noNewAssessment: prompt.includes('administer another psychological assessment'),
noInventQuestionnaire: prompt.includes('invent questionnaire items'),
forbiddenSymptomExamples: prompt.includes('در هفته گذشته چه میزان استرس'),
noInventRoles: prompt.includes('Do NOT invent role lists'),
noGuessGap: prompt.includes('Do not guess'),
noGenericPsychFill: prompt.includes('general psychological knowledge'),
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'),
neverMentionPretestToUser: prompt.includes('NEVER mention «پیش‌آزمون»'),
assessmentResultsRule: prompt.includes('do NOT invent interpretation'),
completionSignal: prompt.includes('SESSION COMPLETION SIGNAL'),
completionMarker: prompt.includes('COACH_SESSION_COMPLETE'),
// Teaching style — educational package introduction
educationalPackageIntro: prompt.includes('introduce the overall educational package'),
packageMultipleSessions: prompt.includes('multiple guided sessions'),
packageConversationLearning: prompt.includes('learning happens through conversation and interaction'),
// Teaching style — tone & language
informalColloquialTone: prompt.includes('informal, colloquial Persian'),
addressLearnerAsTo: prompt.includes('Address the learner with «تو»'),
teenFriendlyLanguage: prompt.includes('teenager'),
allLearnersTeenagers: prompt.includes('ALL learners are teenagers'),
teenAudienceSection: prompt.includes('TEEN AUDIENCE'),
noSpouseQuestion: prompt.includes('با همسر خود یک فعالیت نشاط‌آور'),
teenAdaptFriends: prompt.includes('friends, family, or classmates'),
simpleLanguageGuidance: prompt.includes('explain it simply'),
noChildishTone: prompt.includes('Do NOT be childish'),
// Teaching style — anti-over-questioning
antiOverQuestioning: prompt.includes('NOT an interviewer'),
forbiddenRepeatedQuestions: prompt.includes('نظرت چیست؟'),
noTopicChoice: prompt.includes('Do NOT ask the user to choose the next topic'),
noPermissionToContinue: prompt.includes('Do NOT ask permission to continue'),
// Teaching style — negative-answer handling
negativeAnswerHandling: prompt.includes('NEGATIVE / UNKNOWN ANSWERS'),
noInterrogateFurther: prompt.includes('Do NOT interrogate further'),
unknownAnswerExample: prompt.includes('نمی‌دانم'),
// Teaching style — section-end questions
sectionEndQuestion: prompt.includes('در مورد مطالبی که تا اینجا گفتیم، سؤالی دارید؟'),
sectionEndNaturalBoundaries: prompt.includes('natural section boundaries'),
// Session 1 — no previous session
session1NoPreviousSession: prompt.includes('NO previous educational session'),
noReviewPreviousSession: prompt.includes('مرور جلسه قبل'),
// Teaching style — package identity & 11 sessions
iranianIslamicPackage: prompt.includes('بسته آموزشی مثبت‌نگر ایرانی ـ اسلامی'),
programOverviewSection: prompt.includes('PROGRAM OVERVIEW'),
elevenSessionsListed: prompt.includes('1. توجیه و آشناسازی') && prompt.includes('11. مرور و جمع‌بندی مطالب'),
// Teaching style — session outline then teach one by one
sessionOutlineSection: prompt.includes('SESSION OUTLINE'),
listThenTeach: prompt.includes('list these at session start as a short roadmap'),
// Teaching style — no identity repetition
noIdentityRepetition: prompt.includes('NO IDENTITY REPETITION'),
noRepeatNovyaHelp: prompt.includes('اینجام تا کمک کنم'),
// Teaching style — stay on path / other model
stayOnEducationalPath: prompt.includes('STAY ON THE EDUCATIONAL PATH'),
otherModelRedirect: prompt.includes('another model/assistant'),
// Teaching style — package overview only in Session 1
packageOverviewSession1Only: prompt.includes('Session 1 ONLY'),
startCueSession1Package: COACH_SESSION_START_CUE.includes('list the ۱۱ session titles ONCE'),
// Teaching style — message formatting
messageFormatting: prompt.includes('MESSAGE FORMATTING'),
occasionalEmoji: prompt.includes('EMOJI / STICKER-LIKE EXPRESSIONS'),
emojiHardLimit: prompt.includes('0–1 emoji per message'),
numberedListFormatting: prompt.includes('numbered list'),
boldKeyPhrases: prompt.includes('double asterisks'),
headingOwnLine: prompt.includes('OWN separate line'),
headingNumberedFirstTime: prompt.includes('FIRST time you introduce a section title'),
plainTextHeadings: prompt.includes('NEVER use Markdown # / ## / ###'),
noVisibleHash: prompt.includes('NEVER show # ## ###'),
noVisibleAt: prompt.includes('NEVER show @ mentions'),
standardPersianPunctuation: prompt.includes('STANDARD PERSIAN PUNCTUATION'),
justifyFriendlyParagraphs: prompt.includes('justified'),
neverGenerateImages: prompt.includes('NEVER generate, attach, draw, or request images'),
formattingDoesNotChangeContent: prompt.includes('MUST NOT add, remove, or change educational content'),
formattingDoesNotChangeOrder: prompt.includes('MUST NOT change the teaching order'),
tablesOccasionalVariety: prompt.includes('TABLES (occasional variety)'),
// Teaching style — creative / lived elaboration
creativeElaboration: prompt.includes('CREATIVE ELABORATION'),
perfumeExample: prompt.includes('استفاده از عطر'),
scientificColor: prompt.includes('simple scientific'),
experientialThenQuote: prompt.includes('THEN present the course quote'),
// Session 11 final review (Session 11 prompt only)
session11ThreePartReview: prompt11.includes('THREE-PART GRADUAL REVIEW'),
session11NoDuplicatePhrases: prompt11.includes('NO DUPLICATE PHRASES'),
session11PriorSessionsReference: prompt11.includes('PRIOR SESSIONS REFERENCE'),
session11StartCueThreeParts: startCue11.includes('three parts'),
session11StartCueNoDuplicate: startCue11.includes('NEVER duplicate the session title'),
noRepetitionRule: prompt.includes('NO REPETITION (all sessions)'),
// Session start cue
startCue: COACH_SESSION_START_CUE.includes('[SESSION_START]'),
startCueEducationalPackage: COACH_SESSION_START_CUE.includes('introduce the overall educational package'),
startCueNoPreviousSession: COACH_SESSION_START_CUE.includes('Do NOT review a previous session'),
startCueGrounding: COACH_SESSION_START_CUE.includes('Do not invent missing details'),
startCueNoAssessment: COACH_SESSION_START_CUE.includes('Do NOT administer or invent any psychological assessment'),
startCueNoTopicChoice: COACH_SESSION_START_CUE.includes('Do not ask the user to choose topics'),
startCueNoOverQuestion: COACH_SESSION_START_CUE.includes('Do not over-question'),
startCueIranianIslamic: COACH_SESSION_START_CUE.includes('بسته آموزشی مثبت‌نگر ایرانی ـ اسلامی'),
startCueLivedExamples: COACH_SESSION_START_CUE.includes('lived examples'),
startCueStayOnPath: COACH_SESSION_START_CUE.includes('Stay on the educational path'),
startCueNeverPretest: COACH_SESSION_START_CUE.includes('NEVER mention پیش‌آزمون'),
startCueNoSpouse: COACH_SESSION_START_CUE.includes('NEVER ask about همسر'),
chars: prompt.length
};
console.log(JSON.stringify(checks, null, 2));
const failed = Object.entries(checks).filter(([k, v]) => k !== 'chars' && v !== true);
if (failed.length) {
console.error('Failed checks:', failed);
process.exit(1);
}
console.log('Session 1 coach prompt OK');
console.log('Session 11 coach prompt OK');
+207
View File
@@ -0,0 +1,207 @@
/**
* Offline verification: end-of-course flow (Session 11 → training done → post → finished).
* Run: npx tsx --tsconfig tsconfig.json scripts/verify-end-to-end-course-flow.ts
*
* Does NOT call AI. Uses programService + journey permission rules + storage helpers.
*/
import { PROGRAM_SESSION_COUNT } from '../src/data/program/sessions';
import {
completeSession,
getProgramProgress,
isProgramCompleted,
resetProgramProgress
} from '../src/services/programService';
import {
getLatestByPhase,
isPhaseCompleted,
saveAssessment,
type AssessmentSubmission
} from '../src/services/assessmentService';
import { PCQ24_QUESTIONS } from '../src/config/pcq24';
// Minimal localStorage for Node
const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
clear: () => store.clear(),
key: () => null,
get length() {
return store.size;
}
};
type LearningProgress = {
user_id: string;
current_step: string;
pre_test_completed_at: string | null;
training_started_at: string | null;
training_completed_at: string | null;
post_test_completed_at: string | null;
};
const journeyKey = (userId: string) => `learning-progress:${userId}`;
const readJourney = (userId: string): LearningProgress => {
const raw = localStorage.getItem(journeyKey(userId));
if (!raw) {
return {
user_id: userId,
current_step: 'registered',
pre_test_completed_at: null,
training_started_at: null,
training_completed_at: null,
post_test_completed_at: null
};
}
return JSON.parse(raw) as LearningProgress;
};
const writeJourney = (progress: LearningProgress) => {
localStorage.setItem(journeyKey(progress.user_id), JSON.stringify(progress));
};
/** Mirrors useLearningJourney permissions */
const permissionsOf = (progress: LearningProgress) => ({
canTakePreTest: !progress.pre_test_completed_at,
canOpenTraining: Boolean(progress.pre_test_completed_at),
canTakePostTest: Boolean(
progress.pre_test_completed_at &&
progress.training_completed_at &&
!progress.post_test_completed_at
),
isCompleted: Boolean(progress.post_test_completed_at)
});
/** Mirrors HomeRedirect */
const resolveRoot = (progress: LearningProgress): string => {
if (!progress.pre_test_completed_at) return '/assessment/pre';
if (!progress.training_completed_at) return '/chat';
if (!progress.post_test_completed_at) return '/assessment/post';
return '/finished';
};
/** Mirrors ChatPage Session 11 completion side-effects (without React) */
const applyFinalSessionCompletion = (userId: string, sessionOrder: number) => {
const updated = completeSession(userId, sessionOrder);
const isFinal =
sessionOrder >= PROGRAM_SESSION_COUNT || Boolean(updated.completedAt);
if (isFinal) {
const now = new Date().toISOString();
const base = readJourney(userId);
if (!base.training_completed_at) {
writeJourney({
...base,
training_started_at: base.training_started_at || now,
training_completed_at: now
});
}
}
return { updated, isFinal };
};
let passed = 0;
let failed = 0;
const assert = (name: string, condition: boolean) => {
if (condition) {
passed += 1;
console.log(`PASS: ${name}`);
return;
}
failed += 1;
console.error(`FAIL: ${name}`);
};
const USER = 'e2e-flow-user';
store.clear();
resetProgramProgress(USER);
writeJourney({
user_id: USER,
current_step: 'pre_test_done',
pre_test_completed_at: new Date().toISOString(),
training_started_at: new Date().toISOString(),
training_completed_at: null,
post_test_completed_at: null
});
// --- Root routing before training complete ---
assert('root → /chat when training incomplete', resolveRoot(readJourney(USER)) === '/chat');
assert(
'post locked before training_completed_at',
permissionsOf(readJourney(USER)).canTakePostTest === false
);
// --- Complete sessions 1..10 (sequential), then 11 ---
for (let order = 1; order <= 10; order++) {
completeSession(USER, order);
}
assert('after 10 sessions program not complete', isProgramCompleted(USER) === false);
assert(
'training still incomplete after 10',
readJourney(USER).training_completed_at === null
);
const final = applyFinalSessionCompletion(USER, 11);
assert('session 11 marks program completedAt', Boolean(final.updated.completedAt));
assert('session 11 isFinal', final.isFinal === true);
assert('training_completed_at set after session 11', Boolean(readJourney(USER).training_completed_at));
assert(
'post unlocked after training_completed_at',
permissionsOf(readJourney(USER)).canTakePostTest === true
);
assert('root → /assessment/post after training', resolveRoot(readJourney(USER)) === '/assessment/post');
assert(
'all 11 sessions listed completed',
getProgramProgress(USER).completedSessions.length === PROGRAM_SESSION_COUNT
);
// --- Refresh persistence (re-read storage) ---
const journeyAfterReload = readJourney(USER);
const programAfterReload = getProgramProgress(USER);
assert('persist training_completed_at', Boolean(journeyAfterReload.training_completed_at));
assert('persist program completedAt', Boolean(programAfterReload.completedAt));
assert(
'persist post unlock',
permissionsOf(journeyAfterReload).canTakePostTest === true
);
// --- Post-test submission (scoring storage) + journey mark ---
const answers = PCQ24_QUESTIONS.map((q) => ({
questionId: q.id,
selectedValue: '4'
}));
await saveAssessment({ userId: USER, phase: 'post', answers });
assert('post assessment stored', isPhaseCompleted(USER, 'post') === true);
const now = new Date().toISOString();
writeJourney({
...readJourney(USER),
current_step: 'completed',
post_test_completed_at: now
});
assert('root → /finished after post', resolveRoot(readJourney(USER)) === '/finished');
assert('permissions.isCompleted', permissionsOf(readJourney(USER)).isCompleted === true);
assert('canTakePostTest false after post', permissionsOf(readJourney(USER)).canTakePostTest === false);
// --- Duplicate post guard (existing storage + journey) ---
const beforeDup = getLatestByPhase(USER, 'post') as AssessmentSubmission;
assert('existing post submission present', beforeDup !== null);
assert(
'duplicate blocked by journey permission',
permissionsOf(readJourney(USER)).canTakePostTest === false
);
assert(
'duplicate blocked by isPhaseCompleted',
isPhaseCompleted(USER, 'post') === true
);
// --- Finished refresh ---
assert('finished still available after reload', resolveRoot(readJourney(USER)) === '/finished');
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);