Polish Novya UI with brand colors, first-load skeletons, and session transition overlay.
Keep chat pinned to the latest message while AI thinks or streams, and align buttons/inputs with the logo teal/blue focus treatment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 <email>');
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Dev helper: reset password for a password-auth user.
|
||||
* Usage: npx tsx scripts/reset-user-password.ts <email> <newPassword>
|
||||
*/
|
||||
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 <email> <newPassword>');
|
||||
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();
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user