diff --git a/packages/backend/src/api/repositories/RepeatedEnrollmentRepository.ts b/packages/backend/src/api/repositories/RepeatedEnrollmentRepository.ts index afde9a0e1..e5b7d19c8 100644 --- a/packages/backend/src/api/repositories/RepeatedEnrollmentRepository.ts +++ b/packages/backend/src/api/repositories/RepeatedEnrollmentRepository.ts @@ -6,23 +6,23 @@ import repositoryError from './utils/repositoryError'; export interface RepeatedEnrollmentDataCount { userId: string; - decisionPointId: string; + experimentId: string; count: number; } @EntityRepository(RepeatedEnrollment) export class RepeatedEnrollmentRepository extends Repository { public async getRepeatedEnrollmentCount( userId: string, - decisionPointsIds: string[], + experimentIds: string[], logger: UpgradeLogger ): Promise { const result = await this.createQueryBuilder('repeatedEnrollment') - .select(['ie.userId as "userId"', 'ie.partitionId as "decisionPointId"']) + .select(['ie.userId as "userId"', 'ie.experimentId as "experimentId"']) .addSelect('COUNT(*) as count') .leftJoin('repeatedEnrollment.individualEnrollment', 'ie') .where('ie.userId = :userId', { userId }) - .andWhere('ie.partitionId IN (:...decisionPointsIds)', { decisionPointsIds }) - .groupBy('ie.userId , ie.partitionId , ie.id') + .andWhere('ie.experimentId IN (:...experimentIds)', { experimentIds }) + .groupBy('ie.userId, ie.experimentId') .getRawMany() .catch((errorMsg: any) => { const errorMsgString = repositoryError( diff --git a/packages/backend/src/api/services/ExperimentAssignmentService.ts b/packages/backend/src/api/services/ExperimentAssignmentService.ts index a32bb3b48..b15f2a570 100644 --- a/packages/backend/src/api/services/ExperimentAssignmentService.ts +++ b/packages/backend/src/api/services/ExperimentAssignmentService.ts @@ -432,13 +432,9 @@ export class ExperimentAssignmentService { (experiment) => experiment.assignmentUnit === ASSIGNMENT_UNIT.WITHIN_SUBJECTS ); - const allWithinSubjectDecisionPoints = filteredWithinSubjectExperiments - .map((experiment) => this.getActiveDecisionPoints(experiment)) - .flat(); - repeatedEnrollmentCounts = await this.repeatedEnrollmentRepository.getRepeatedEnrollmentCount( userId, - allWithinSubjectDecisionPoints.map((dp) => dp.id), + filteredWithinSubjectExperiments.map((experiment) => experiment.id), logger ); } @@ -454,7 +450,7 @@ export class ExperimentAssignmentService { conditionPayloads, type, factors, - repeatedEnrollmentCounts || [], + repeatedEnrollmentCounts, logger ); return [...accumulator, ...decisionPoints]; @@ -894,7 +890,7 @@ export class ExperimentAssignmentService { conditionPayloads: ConditionPayloadDTO[], type: EXPERIMENT_TYPE, factors: FactorDTO[], - repeatedEnrollmentCounts: { userId: string; decisionPointId: string; count: number }[], + repeatedEnrollmentCounts: RepeatedEnrollmentDataCount[], logger: UpgradeLogger ): IExperimentAssignmentv5[] { return experiment.partitions @@ -929,9 +925,8 @@ export class ExperimentAssignmentService { if (experiment.assignmentUnit === ASSIGNMENT_UNIT.WITHIN_SUBJECTS) { const count = - repeatedEnrollmentCounts.find( - (repeatedEnrollment) => repeatedEnrollment.decisionPointId === decisionPoint.id - )?.count || 0; + repeatedEnrollmentCounts?.find((repeatedEnrollment) => repeatedEnrollment.experimentId === experiment.id) + ?.count || 0; return withInSubjectType(experiment, conditionPayloads, decisionPoint, factors, userId, count); } else { const experimentId = experiment.id; diff --git a/packages/backend/test/unit/services/ExperimentAssignmentService.test.ts b/packages/backend/test/unit/services/ExperimentAssignmentService.test.ts index 28588d8d2..7f6781b6e 100644 --- a/packages/backend/test/unit/services/ExperimentAssignmentService.test.ts +++ b/packages/backend/test/unit/services/ExperimentAssignmentService.test.ts @@ -409,6 +409,47 @@ describe('Experiment Assignment Service Test', () => { expect(result[0].assignedCondition).toMatchObject(cond); }); + it('should use a single shared rotation counter across all decision points in a within-subject experiment', async () => { + const context = 'context'; + const userDoc = { id: 'user123', group: { schoolId: ['school1'] }, workingGroup: {} }; + const exp = structuredClone(simpleWithinSubjectOrderedRoundRobinExperiment); + + // Add a second decision point to the experiment + const secondPartition = { + ...exp.partitions[0], + id: 'dp-2-id', + twoCharacterId: 'W2', + site: 'CurriculumSequence', + target: 'W2', + }; + exp.partitions = [...exp.partitions, secondPartition]; + + // Simulate the user has already been through the rotation once (count = 1) + // For ORDERED_ROUND_ROBIN with 2 conditions and count=1, the second condition should be first + const repeatedEnrollmentCount = 1; + testedModule.repeatedEnrollmentRepository = { + getRepeatedEnrollmentCount: sandbox + .stub() + .resolves([{ userId: userDoc.id, experimentId: exp.id, count: repeatedEnrollmentCount }]), + }; + + testedModule.experimentService.getCachedValidExperiments = sandbox.stub().resolves([exp]); + testedModule.experimentUserService = { getOriginalUserDoc: sandbox.stub().resolves(userDoc) }; + + const result = await testedModule.getAllExperimentConditions(userDoc, context, loggerMock); + + // Both decision points should be returned + expect(result.length).toEqual(2); + + // Both DPs must have the same assigned condition order — they share one rotation counter + expect(result[0].assignedCondition[0].conditionCode).toEqual(result[1].assignedCondition[0].conditionCode); + expect(result[0].assignedCondition[1].conditionCode).toEqual(result[1].assignedCondition[1].conditionCode); + + // With count=1, the rotation should have advanced past position 0 — condition at index 1 should now be first + expect(result[0].assignedCondition[0].conditionCode).toEqual(exp.conditions[1].conditionCode); + expect(result[0].assignedCondition[1].conditionCode).toEqual(exp.conditions[0].conditionCode); + }); + it('should return the assigned condition for a simple group experiment', async () => { const context = 'context'; const userDoc = { id: 'user123', group: { 'add-group1': ['school1'] }, workingGroup: { 'add-group1': 'school1' } };