Remove inter-session waiting period for immediate sequential access

- Set SESSION_UNLOCK_INTERVAL_MS to 0 in backend and frontend.
- Allow immediate access to next eligible session upon completing previous sessions.
- Preserve all session prerequisites, assignment unlock logic, and participant progress.
- Update tests to verify immediate session unlock and assignment availability.
This commit is contained in:
ar.badiei
2026-10-14 19:42:54 +03:30
parent 8c37c15697
commit a3a9f2664b
5 changed files with 294 additions and 40 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
export const APP_NAME = 'Novya API';
export const APP_DESCRIPTION = 'Novya learning platform backend API';
/** Minimum wait after completing session N before session N+1 unlocks (sessions 3–10). */
export const SESSION_UNLOCK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 so next session unlocks immediately. */
export const SESSION_UNLOCK_INTERVAL_MS = 0;
export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 20;
+26 -34
View File
@@ -143,14 +143,7 @@ describe('assignments API', () => {
});
assert.equal((list.body.data?.assignments as unknown[]).length, 1);
// Backdate session 2 completion so session 3 unlocks (24h gate for order >= 3)
await prisma.sessionProgress.update({
where: {
userId_sessionOrder: { userId: owner.user.id, sessionOrder: 2 },
},
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
// Session 3 is unlocked immediately (no 24h waiting period)
// Incomplete homework must NOT block starting session 3
const chat3 = await requestJson(app, '/chat/sessions', {
method: 'POST',
@@ -363,12 +356,8 @@ describe('assignments API', () => {
await openChatAndComplete(token, 1);
await openChatAndComplete(token, 2);
// Unlock sessions 3–10 by backdating previous completions as we go
// Unlock sessions 3–10 sequentially without cooldown delays
for (let order = 3; order <= 10; order += 1) {
await prisma.sessionProgress.updateMany({
where: { userId: user.id, sessionOrder: order - 1 },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
await openChatAndComplete(token, order);
}
@@ -472,13 +461,13 @@ describe('assignments API', () => {
await openChatAndComplete(token, 2);
// completeSession helper only asserts 200 — fetch progress for cooldown fields
// With waiting period removed, no cooldown is set
const progress = await requestJson(app, '/sessions/progress', {
headers: authHeader(token),
});
assert.equal(progress.status, 200);
assert.ok(progress.body.data?.waitingForSessionOrder === 3);
assert.ok(progress.body.data?.nextSessionUnlocksAt);
assert.equal(progress.body.data?.waitingForSessionOrder, null);
assert.equal(progress.body.data?.nextSessionUnlocksAt, null);
// Re-complete to read unlockedAssignments payload from complete endpoint
const redo = await requestJson(app, '/sessions/2/complete', {
@@ -508,7 +497,7 @@ describe('assignments API', () => {
assert.equal(joy.lockReason, null);
assert.ok(joy.userAssignment?.id);
// Session 3 still locked by cooldown — must NOT block assignment 1
// Session 3 is unlocked immediately (no 24h wait)
const sessions = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
@@ -518,18 +507,19 @@ describe('assignments API', () => {
unlocksAt?: string | null;
}>;
const s3 = sessionList.find((s) => s.order === 3)!;
assert.equal(s3.unlocked, false);
assert.ok(s3.unlocksAt);
assert.equal(s3.unlocked, true);
assert.equal(s3.unlocksAt, null);
// Opening cooldown (simulate later unlock of session 3) must not change assignment-1 state
await prisma.sessionProgress.update({
where: { userId_sessionOrder: { userId: user.id, sessionOrder: 2 } },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
const sessionsAfter = await requestJson(app, '/sessions', {
// Prerequisites still hold: Session 4 is NOT unlocked yet
const s4 = sessionList.find((s) => s.order === 4)!;
assert.equal(s4.unlocked, false);
// Complete session 3 immediately without any time delay
await openChatAndComplete(token, 3);
const sessionsAfter3 = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
const s3After = (sessionsAfter.body.data as typeof sessionList).find((s) => s.order === 3)!;
const s3After = (sessionsAfter3.body.data as typeof sessionList).find((s) => s.order === 3)!;
assert.equal(s3After.unlocked, true);
catalog = await requestJson(app, '/assignments/catalog', {
@@ -539,18 +529,18 @@ describe('assignments API', () => {
const joyAfter = items.find((i) => i.definition.key === 'joy-activities')!;
assert.equal(joyAfter.unlocked, true);
assert.equal(joyAfter.userAssignment?.id, joy.userAssignment?.id);
// Assignment 2 (meaning) is now unlocked immediately on session 3 complete
const meaningAfter3 = items.find((i) => i.definition.key === 'meaning-purpose')!;
assert.equal(meaningAfter3.unlocked, true);
assert.equal(
items.filter((i) => i.unlocked).length,
1,
'unlocking session 3 must not unlock later assignments',
2,
'unlocking session 3 must not unlock later assignments (session 4+)',
);
// Mid assignment: تکلیف پنجم (displayOrder 5 / sessionOrder 6) after completing session 6
for (let order = 3; order <= 6; order += 1) {
await prisma.sessionProgress.updateMany({
where: { userId: user.id, sessionOrder: order - 1 },
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
});
for (let order = 4; order <= 6; order += 1) {
await openChatAndComplete(token, order);
}
@@ -568,7 +558,9 @@ describe('assignments API', () => {
headers: authHeader(token),
});
const s7 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 7)!;
assert.equal(s7.unlocked, false, 'session 7 may still be on cooldown');
assert.equal(s7.unlocked, true, 'session 7 unlocks immediately after session 6 completion');
const s8 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 8)!;
assert.equal(s8.unlocked, false, 'session 8 remains locked until session 7 is completed');
assert.equal(
items.find((i) => i.definition.sessionOrder === 7)?.unlocked,
false,
+263
View File
@@ -0,0 +1,263 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { prisma } from '../src/lib/prisma.js';
import { PCQ_QUESTION_IDS } from '../src/modules/assessments/scoring/index.js';
import { exampleCompletionSignal } from '../src/modules/ai/sessionCompletion.js';
import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.js';
import { getSessionByOrder, PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.js';
import { seedCourseSessions } from '../src/modules/sessions/seedCatalog.js';
import { seedAssignmentDefinitions } from '../src/modules/assignments/assignments.service.js';
import {
getNextLockedSessionUnlockTime,
getPreviousSessionUnlockTime,
isSessionUnlockedForProgress,
sessionUsesUnlockInterval,
} from '../src/modules/sessions/unlock.js';
import {
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
const app = createTestApp();
const createdUserIds: bigint[] = [];
function pcqAnswers(value: number) {
return PCQ_QUESTION_IDS.map((questionId) => ({
questionId,
selectedValue: String(value),
}));
}
async function registerUser(suffix: string) {
const email = `session-unlock-${suffix}-${Date.now()}@example.com`;
const { status, body } = await requestJson(app, '/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password: 'password12' }),
});
assert.equal(status, 201);
const token = body.data?.sessionToken as string;
const externalId = (body.data?.user as { id: string }).id;
const user = await prisma.user.findUnique({ where: { externalId } });
assert.ok(user);
createdUserIds.push(user.id);
return { token, user };
}
import { ChatRole } from '@prisma/client';
async function completePreTest(token: string) {
const res = await requestJson(app, '/assessments/attempts', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }),
});
assert.equal(res.status, 201);
}
async function openChatAndComplete(token: string, order: number) {
const chat = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: order }),
});
assert.equal(chat.status, 201);
const chatId = (chat.body.data as { id: string }).id;
await prisma.chatMessage.createMany({
data: [
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `سلام جلسه ${order}` },
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `ادامه جلسه ${order}` },
],
});
const session = getSessionByOrder(order)!;
const complete = await requestJson(app, `/sessions/${order}/complete`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
...exampleCompletionSignal(buildAiCoachSessionContext(session)),
chatSessionId: chatId,
}),
});
assert.equal(complete.status, 200);
return { chatId };
}
describe('session unlock logic and waiting period removal', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
if (dbAvailable) {
await seedCourseSessions();
await seedAssignmentDefinitions();
}
});
after(async () => {
if (dbAvailable && createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
});
it('unit: unlock interval functions confirm 0 waiting period', () => {
// 1. sessionUsesUnlockInterval returns false for all session orders
for (let order = 1; order <= PROGRAM_SESSION_COUNT; order++) {
assert.equal(
sessionUsesUnlockInterval(order),
false,
`session ${order} should not use unlock interval`,
);
}
const completionTimes = new Map<number, Date>();
completionTimes.set(1, new Date());
completionTimes.set(2, new Date());
// 2. getPreviousSessionUnlockTime returns null (no waiting delay)
assert.equal(getPreviousSessionUnlockTime(3, completionTimes), null);
// 3. isSessionUnlockedForProgress unlocks session 3 immediately when 1 and 2 are complete
assert.equal(
isSessionUnlockedForProgress(3, [1, 2], true, false, completionTimes),
true,
'session 3 should unlock immediately after sessions 1 and 2 are complete',
);
// 4. Prerequisites still block: session 4 is NOT unlocked when only 1 and 2 are complete
assert.equal(
isSessionUnlockedForProgress(4, [1, 2], true, false, completionTimes),
false,
'session 4 must remain locked when session 3 is not complete',
);
// 5. canOpenTraining prerequisite still blocks all sessions
assert.equal(
isSessionUnlockedForProgress(1, [], false, false, completionTimes),
false,
'session 1 must be locked if canOpenTraining is false',
);
// 6. getNextLockedSessionUnlockTime returns null when next eligible session has no cooldown
assert.equal(
getNextLockedSessionUnlockTime([1, 2], completionTimes),
null,
'next eligible session should have no locked cooldown delay',
);
});
it('integration: completes sessions sequentially without delays; preserves assignments and prerequisites', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const participantA = await registerUser('part-a');
const participantB = await registerUser('part-b');
// Participant A takes pre-test
await completePreTest(participantA.token);
// Participant B has NOT taken pre-test — training is locked
const bSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantB.token),
});
const bList = bSessions.body.data as Array<{ order: number; unlocked: boolean }>;
assert.ok(bList.every((s) => !s.unlocked), 'all sessions locked for participant B without pre-test');
// Participant A: session 1 is unlocked initially, session 2-11 are locked
let aSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantA.token),
});
let aList = aSessions.body.data as Array<{ order: number; unlocked: boolean; completed: boolean; unlocksAt: string | null }>;
assert.equal(aList[0]?.unlocked, true);
assert.equal(aList[1]?.unlocked, false);
assert.equal(aList[2]?.unlocked, false);
// Complete session 1
await openChatAndComplete(participantA.token, 1);
// Session 2 is now unlocked immediately
aSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantA.token),
});
aList = aSessions.body.data as typeof aList;
assert.equal(aList[0]?.completed, true);
assert.equal(aList[1]?.unlocked, true);
assert.equal(aList[2]?.unlocked, false);
// Complete session 2
await openChatAndComplete(participantA.token, 2);
// Progress endpoint shows NO cooldown waiting
const aProgress = await requestJson(app, '/sessions/progress', {
headers: authHeader(participantA.token),
});
assert.equal(aProgress.status, 200);
assert.equal(aProgress.body.data?.waitingForSessionOrder, null);
assert.equal(aProgress.body.data?.nextSessionUnlocksAt, null);
// Session 3 is unlocked IMMEDIATELY (no 24h waiting period!)
aSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantA.token),
});
aList = aSessions.body.data as typeof aList;
const s3 = aList.find((s) => s.order === 3)!;
assert.equal(s3.unlocked, true, 'session 3 must be unlocked immediately');
assert.equal(s3.unlocksAt, null, 'session 3 unlocksAt must be null');
// Completing session 2 does NOT unlock session 4 or the rest of the program
const s4 = aList.find((s) => s.order === 4)!;
assert.equal(s4.unlocked, false, 'session 4 must remain locked');
const s11 = aList.find((s) => s.order === 11)!;
assert.equal(s11.unlocked, false, 'session 11 must remain locked');
// Associated assignment for session 2 (joy-activities) is unlocked immediately
const aCatalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(participantA.token),
});
const catalogItems = aCatalog.body.data?.catalog as Array<{
unlocked: boolean;
definition: { key: string; sessionOrder: number };
}>;
const joyAssignment = catalogItems.find((i) => i.definition.key === 'joy-activities')!;
assert.equal(joyAssignment.unlocked, true, 'joy assignment unlocked after session 2 complete');
// Assignment for session 3 (meaning-purpose) is STILL locked because session 3 is not yet completed
const meaningAssignment = catalogItems.find((i) => i.definition.key === 'meaning-purpose')!;
assert.equal(meaningAssignment.unlocked, false, 'meaning assignment remains locked until session 3 is completed');
// Participant B cannot access participant A's assignments
const bAssignments = await requestJson(app, '/assignments?filter=all', {
headers: authHeader(participantB.token),
});
assert.equal(bAssignments.status, 200);
assert.equal((bAssignments.body.data?.assignments as unknown[]).length, 0, 'participant B sees no assignments');
// Participant A immediately accesses and completes session 3 without any delay
await openChatAndComplete(participantA.token, 3);
// Associated assignment for session 3 is now unlocked immediately
const aCatalogAfter3 = await requestJson(app, '/assignments/catalog', {
headers: authHeader(participantA.token),
});
const itemsAfter3 = aCatalogAfter3.body.data?.catalog as typeof catalogItems;
const meaningAfter3 = itemsAfter3.find((i) => i.definition.key === 'meaning-purpose')!;
assert.equal(meaningAfter3.unlocked, true, 'meaning assignment unlocks immediately after session 3 completion');
// Session 4 is now unlocked immediately
aSessions = await requestJson(app, '/sessions', {
headers: authHeader(participantA.token),
});
aList = aSessions.body.data as typeof aList;
const s4After3 = aList.find((s) => s.order === 4)!;
assert.equal(s4After3.unlocked, true, 'session 4 unlocks immediately after session 3 completion');
// Completed sessions 1, 2, 3 remain completed
assert.equal(aList.find((s) => s.order === 1)!.completed, true);
assert.equal(aList.find((s) => s.order === 2)!.completed, true);
assert.equal(aList.find((s) => s.order === 3)!.completed, true);
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
/** Minimum wait after completing session N before session N+1 unlocks (sessions 3–10). */
export const SESSION_UNLOCK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 so next session unlocks immediately. */
export const SESSION_UNLOCK_INTERVAL_MS = 0;
export type SessionCompletionTimes = Record<number, string>;
+1 -2
View File
@@ -334,8 +334,7 @@ export async function submitHomeworkReport(
/**
* Check if a specific session is unlocked for a user.
* Session N unlocks when sessions 1…N-1 are complete and at least 1 hour has passed
* since session N-1 was completed.
* Session N unlocks immediately when previous sessions 1…N-1 are complete.
*/
export function isSessionUnlocked(userId: string, sessionOrder: number): boolean {
const cachedSessions = getSessions(userId);