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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user