Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions clientlibs/js/quickTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,17 @@ const groupsForSession = { classId: ['EPHEMERAL_USER_GROUP'] };
const includeStoredUserGroups = true; // true to merge with stored user groups, false for session-only groups
const alias = 'alias' + userId;
const hostUrl = URL.LOCAL;
const context = 'upgrade-internal';
const site = 'asdf';
const target = 'fssfs';
const context = 'assign-prog';
const site = 'fakesite';
const target = 'faketarget';
const status = MARKED_DECISION_POINT_STATUS.CONDITION_APPLIED;
const featureFlagKey = 'TEST_FEATURE_FLAG';

// reward testing variables ----- //
const experimentId = 'f9c3927c-b786-45f5-a96c-dd9262e3b4b6'; // needed for reward testing
const experimentId = '1a43d51e-b286-40a2-9dd7-a01b67797276'; // needed for reward testing
const rewardSite = site; // if using decision point for reward
const rewardTarget = target; // if using decision point for reward
const rewardValue = 'SUCCESS'; // or 'FAILURE' or use an UpgradeClient.BINARY_REWARD_VALUE enum
const rewardValue = 'FAILURE'; // or 'FAILURE' or use an UpgradeClient.BINARY_REWARD_VALUE enum
// ---------------------------- //

const options: UpGradeClientInterfaces.IConfigOptions = {
Expand Down Expand Up @@ -81,7 +81,7 @@ async function quickTest() {
await doInit(client);
await doGroupMembership(client);
await doWorkingGroupMembership(client);
await doAliases(client);
// await doAliases(client);
// await doAssign(client);
// await doAssignIgnoreCache(client);
// await doAssign(client);
Expand Down
11 changes: 8 additions & 3 deletions packages/backend/rest-client-vscode/MoocletAPI.http
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@
# 11. Repeat steps 8-10 as needed

############ env variables
@host = http://localhost:8000
@host = https://apps.qa-cli.net/mooclet-service

# Replace with your token, i.e.
@token = Token 7439dc95718b525e8ae267604178575da15820e9
@token = Token abc123
# @token =

@apiEndpoint = /engine/api/v1

############ request variables (change as needed)
@moocletId = 5
@moocletId = 196
@moocletName = newmooc4
@policyId = 17
@policyParametersId = 2
Expand Down Expand Up @@ -144,6 +144,11 @@ Content-type: application/json
"policy": {{policyId}}
}

########## query rewards by mooclet id:
GET {{host}}{{apiEndpoint}}/value?mooclet={{moocletId}}&variable__name={{outcomeVariableName}}
Authorization: {{token}}
Content-type: application/json

##### EDITS:

########### create policyparameters
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/api/controllers/ExperimentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import { SegmentInputValidator } from './validators/SegmentInputValidator';
import { ExperimentSegmentExclusion } from '../models/ExperimentSegmentExclusion';
import { IdValidator } from './validators/ExperimentUserValidator';
import { Segment } from '../models/Segment';
import { MoocletRewardsService } from '../services/MoocletRewardsService';
import { ExperimentRewardsSummary } from 'upgrade_types';

interface ExperimentPaginationInfo extends PaginationResponse {
nodes: Experiment[];
Expand Down Expand Up @@ -656,6 +658,7 @@ export class ExperimentController {
public experimentService: ExperimentService,
public experimentAssignmentService: ExperimentAssignmentService,
public moocletExperimentService: MoocletExperimentService,
public moocletRewardService: MoocletRewardsService,
public importExportService: ImportExportService
) {}

Expand Down Expand Up @@ -1933,4 +1936,18 @@ export class ExperimentController {

return lists;
}

/**
* Get Mooclet Rewards Feedback data
*/
@Get('/mooclet-rewards/:id')
public getMoocletRewards(
@Params({ validate: true }) { id }: IdValidator,
@Req() request: AppRequest
): Promise<ExperimentRewardsSummary> {
if (!env.mooclets?.enabled) {
throw new BadRequestError('Mooclet is not enabled in the environment');
}
return this.moocletRewardService.getRewardsSummaryForExperiment(id, request.logger);
}
}
24 changes: 24 additions & 0 deletions packages/backend/src/api/services/MoocletDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
MoocletVariableResponseDetails,
MoocletValueRequestBody,
MoocletValueResponseDetails,
MoocletRewardCountRequestBody,
MoocletPaginatedResponse,
} from '../../types/Mooclet';
import { UpgradeLogger } from '../../lib/logger/UpgradeLogger';
import { MoocletError } from '../errors/MoocletError';
Expand Down Expand Up @@ -232,6 +234,28 @@ export class MoocletDataService {
return response;
}

public async getRewardsForExperiment(
requestBody: MoocletRewardCountRequestBody,
logger: UpgradeLogger,
nextPageUrl?: string
): Promise<MoocletPaginatedResponse<MoocletValueResponseDetails>> {
// this endpoint serves a paginated response
// if there are more results "pages" mooclet api sends the exact url to use for "next" page
// else it is nul/undefined and we'll fetch from the beginning
const url =
nextPageUrl || `${this.apiUrl}/value?mooclet=${requestBody.moocletId}&variable__name=${requestBody.variableName}`;

const requestParams: MoocletProxyRequestParams = {
method: 'GET',
url,
apiToken: this.apiToken,
};

const response = await this.fetchExternalMoocletsData(requestParams, logger);

return response;
}

public async postNewVariable(
requestBody: MoocletVariableRequestBody,
logger: UpgradeLogger
Expand Down
103 changes: 101 additions & 2 deletions packages/backend/src/api/services/MoocletRewardsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ import { IndividualEnrollmentRepository } from '../repositories/IndividualEnroll
import { Service } from 'typedi';
import { InjectRepository } from '../../typeorm-typedi-extensions';
import { HttpError } from 'routing-controllers';
import { MoocletValueRequestBody } from '../../types/Mooclet';
import {
MoocletPaginatedResponse,
MoocletRewardCountRequestBody,
MoocletValueRequestBody,
MoocletValueResponseDetails,
} from '../../types/Mooclet';
import { RewardValidator } from '../controllers/validators/RewardValidator';
import { ExperimentRewardsByCondition, ExperimentRewardsSummary } from 'upgrade_types';
import { MoocletExperimentService } from './MoocletExperimentService';

export interface IRewardResponse {
message: string;
Expand All @@ -25,7 +32,8 @@ export class MoocletRewardsService {
private moocletExperimentRefRepository: MoocletExperimentRefRepository,
@InjectRepository()
private individualEnrollmentRepository: IndividualEnrollmentRepository,
private moocletDataService: MoocletDataService
private moocletDataService: MoocletDataService,
private moocletExperimentService: MoocletExperimentService
) {}

/**
Expand Down Expand Up @@ -204,6 +212,97 @@ export class MoocletRewardsService {
return map.moocletVersionId;
}

public async getRewardsSummaryForExperiment(
experimentId: string,
logger: UpgradeLogger
): Promise<ExperimentRewardsSummary> {
try {
const moocletExperimentRef = await this.moocletExperimentService.getMoocletExperimentRefByUpgradeExperimentId(
experimentId
);
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMoocletExperimentRefByUpgradeExperimentId can return undefined when no Mooclet ref exists for the experiment. The current implementation passes that value into fetchRewardsForExperiment and will throw at runtime when accessing moocletId/outcomeVariableName. Add an explicit not-found check and return a controlled HTTP error (e.g., 404/409) with a clear message when the experiment has no Mooclet configuration.

Suggested change
);
);
if (!moocletExperimentRef) {
logger.error({
message: 'No Mooclet configuration found for experiment when fetching rewards summary',
experimentId,
});
throw new HttpError(
404,
`No Mooclet configuration found for experiment with id ${experimentId}.`
);
}

Copilot uses AI. Check for mistakes.
const rewards: MoocletValueResponseDetails[] = [];
logger.info({
message: `Fetching Rewards data from mooclet server.`,
experimentId,
});
let response = await this.fetchRewardsForExperiment(moocletExperimentRef, logger);
if (Array.isArray(response.results)) {
rewards.push(...response.results);
}

while (response.next) {
logger.info({
message: `But wait there's more (Fetching more Rewards data from Mooclet server for experiment...)`,
totalFound: response.count,
totalFetched: response.results.length,
next: response.next,
});
response = await this.fetchRewardsForExperiment(moocletExperimentRef, logger, response.next);
if (Array.isArray(response.results)) {
rewards.push(...response.results);
}
}

return this.createExperimentRewardsSummary(moocletExperimentRef, rewards, logger);
} catch (error) {
logger.error({ message: 'Error fetching rewards summary for experiment', experimentId, error });
throw error;
}
}

public async fetchRewardsForExperiment(
moocletExperimentRef: MoocletExperimentRef,
logger: UpgradeLogger,
nextPageUrl?: string
): Promise<MoocletPaginatedResponse<MoocletValueResponseDetails>> {
const requestBody: MoocletRewardCountRequestBody = {
moocletId: moocletExperimentRef.moocletId,
variableName: moocletExperimentRef.outcomeVariableName,
};

return await this.moocletDataService.getRewardsForExperiment(requestBody, logger, nextPageUrl);
}

public async createExperimentRewardsSummary(
moocletExperimentRef: MoocletExperimentRef,
rewardsData: MoocletValueResponseDetails[],
logger: UpgradeLogger
): Promise<ExperimentRewardsSummary> {
const rewards: MoocletValueResponseDetails[] = rewardsData;

if (!rewardsData) {
logger.warn({
message: 'No rewards data returned from Mooclet API',
experimentId: moocletExperimentRef.experimentId,
});
return [];
}

const rewardsSummaries = moocletExperimentRef.versionConditionMaps.map(
({ experimentCondition, moocletVersionId }) => {
const versionRewards = rewards.filter((reward) => reward.version === moocletVersionId);
const successes = versionRewards.filter((reward) => reward.value === 1.0).length;
const failures = versionRewards.filter((reward) => reward.value === 0.0).length;
const total = successes + failures;
const percentSuccess = total > 0 ? (successes / total) * 100 : 0.0;
const successRate = percentSuccess.toFixed(1) + '%';

const rewardsForCondition: ExperimentRewardsByCondition = {
conditionCode: experimentCondition.conditionCode,
successes,
failures,
total,
successRate,
order: experimentCondition.order,
};
return rewardsForCondition;
}
);

const orderedRewardsSummary = rewardsSummaries.sort((a, b) => a.order - b.order);
return orderedRewardsSummary;
}

/**
* Throws a 409 data-conflict error for most unexpected cases
*/
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/types/Mooclet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export interface MoocletRequestBody {
policy: number;
}

export interface MoocletPaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}

export interface MoocletResponseDetails {
id: number;
name: string;
Expand Down Expand Up @@ -73,6 +80,11 @@ export interface MoocletValueRequestBody {
policy?: number;
}

export interface MoocletRewardCountRequestBody {
moocletId: number;
variableName: string;
}

export interface MoocletValueResponseDetails {
id: string;
variable: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { ExperimentAssignmentService } from '../../../src/api/services/Experimen
import ExperimentAssignmentServiceMock from './mocks/ExperimentAssignmentServiceMock';
import { MoocletExperimentService } from '../../../src/api/services/MoocletExperimentService';
import MoocletExperimentServiceMock from './mocks/MoocletExperimentServiceMock';
import { MoocletRewardsService } from '../../../src/api/services/MoocletRewardsService';
import MoocletRewardsServiceMock from './mocks/MoocletRewardsServiceMock';
import { ImportExportService } from '../../../src/api/services/ImportExportService';
import ImportExportServiceMock from './mocks/ImportExportServiceMock';
import { env } from './../../../src/env';
Expand All @@ -37,6 +39,7 @@ describe('Experiment Controller Testing', () => {
Container.set(ExperimentService, new ExperimentServiceMock());
Container.set(ExperimentAssignmentService, new ExperimentAssignmentServiceMock());
Container.set(MoocletExperimentService, new MoocletExperimentServiceMock());
Container.set(MoocletRewardsService, new MoocletRewardsServiceMock());
Container.set(ImportExportService, new ImportExportServiceMock());
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Service } from 'typedi';

@Service()
export default class MoocletRewardsServiceMock {
public async getRewardsSummaryForExperiment(experimentId: string, logger: any): Promise<any> {
return [
{
conditionCode: 'Control',
successes: 10,
failures: 5,
total: 15,
successRate: '66.7%',
order: 0,
},
{
conditionCode: 'Treatment',
successes: 8,
failures: 7,
total: 15,
successRate: '53.3%',
order: 1,
},
];
}
}
Loading