From c7754510c7ef34eee712f024be46ca19dfae7e32 Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:20:21 -0400 Subject: [PATCH 01/11] update env toggle for staging and prod (#2454) --- .../projects/upgrade/src/environments/environment.prod.ts | 4 ++-- .../projects/upgrade/src/environments/environment.staging.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/projects/upgrade/src/environments/environment.prod.ts b/frontend/projects/upgrade/src/environments/environment.prod.ts index 9ef53ea1cb..cc272f7966 100755 --- a/frontend/projects/upgrade/src/environments/environment.prod.ts +++ b/frontend/projects/upgrade/src/environments/environment.prod.ts @@ -13,10 +13,10 @@ export const environment: Environment = { pollingEnabled: true, pollingInterval: 10 * 1000, pollingLimit: 600, - segmentsRefreshToggle: false, + segmentsRefreshToggle: true, withinSubjectExperimentSupportToggle: false, errorLogsToggle: false, - metricAnalyticsExperimentDisplayToggle: false, + metricAnalyticsExperimentDisplayToggle: true, moocletToggle: false, api: { getAllExperiments: '/experiments/paginated', diff --git a/frontend/projects/upgrade/src/environments/environment.staging.ts b/frontend/projects/upgrade/src/environments/environment.staging.ts index e798b7ce30..564239c643 100644 --- a/frontend/projects/upgrade/src/environments/environment.staging.ts +++ b/frontend/projects/upgrade/src/environments/environment.staging.ts @@ -13,10 +13,10 @@ export const environment: Environment = { pollingEnabled: true, pollingInterval: 10 * 1000, pollingLimit: 600, - segmentsRefreshToggle: false, + segmentsRefreshToggle: true, withinSubjectExperimentSupportToggle: false, errorLogsToggle: false, - metricAnalyticsExperimentDisplayToggle: false, + metricAnalyticsExperimentDisplayToggle: true, moocletToggle: false, api: { getAllExperiments: '/experiments/paginated', From cc97ca03aea372275d05932b558e29b3d88ef108 Mon Sep 17 00:00:00 2001 From: Ben Blanchard Date: Wed, 30 Apr 2025 18:56:22 -0400 Subject: [PATCH 02/11] increase max subsegment depth to 8 (#2462) --- .../Upgrade/src/api/services/ExperimentAssignmentService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/packages/Upgrade/src/api/services/ExperimentAssignmentService.ts b/backend/packages/Upgrade/src/api/services/ExperimentAssignmentService.ts index 5aaaa75af1..865b56ff23 100644 --- a/backend/packages/Upgrade/src/api/services/ExperimentAssignmentService.ts +++ b/backend/packages/Upgrade/src/api/services/ExperimentAssignmentService.ts @@ -1827,7 +1827,7 @@ export class ExperimentAssignmentService { const segmentsToFetchArray = Object.keys(segmentObj).map((expId) => segmentObj[expId].segmentIdsQueue); const segmentsToFetch = segmentsToFetchArray.flat(); - if (depth === 5 || segmentsToFetch.length === 0) { + if (depth === 9 || segmentsToFetch.length === 0) { return [segmentObj, segmentDetails]; } @@ -1850,7 +1850,7 @@ export class ExperimentAssignmentService { newExcludedSegments.push(...foundSegment.subSegments.map((subSegment) => subSegment.id)); }); - if (depth < 4) { + if (depth < 8) { exp.segmentIdsQueue = [...newIncludedSegments, ...newExcludedSegments]; exp.currentIncludedSegmentIds = [...newIncludedSegments]; exp.currentExcludedSegmentIds = [...newExcludedSegments]; From fc3d7a4c41cd3a02666885c897b3d97f341dc130 Mon Sep 17 00:00:00 2001 From: Ben Blanchard Date: Fri, 2 May 2025 16:06:00 -0400 Subject: [PATCH 03/11] remove joins from findOneSegmentByContextAndType (#2467) --- .../packages/Upgrade/src/api/repositories/SegmentRepository.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/packages/Upgrade/src/api/repositories/SegmentRepository.ts b/backend/packages/Upgrade/src/api/repositories/SegmentRepository.ts index 01e3eaccf8..3638bbe42e 100644 --- a/backend/packages/Upgrade/src/api/repositories/SegmentRepository.ts +++ b/backend/packages/Upgrade/src/api/repositories/SegmentRepository.ts @@ -62,9 +62,6 @@ export class SegmentRepository extends Repository { */ public async findOneSegmentByContextAndType(context: string, type: SEGMENT_TYPE): Promise { return await this.createQueryBuilder('segment') - .leftJoinAndSelect('segment.individualForSegment', 'individualForSegment') - .leftJoinAndSelect('segment.groupForSegment', 'groupForSegment') - .leftJoinAndSelect('segment.subSegments', 'subSegments') .where('segment.context=:context', { context }) .andWhere('segment.type=:type', { type }) .getOne() From e70ce09fa7d73aaff5ac12547b0ce06cb9f3543c Mon Sep 17 00:00:00 2001 From: Ben Blanchard Date: Tue, 6 May 2025 15:19:02 -0400 Subject: [PATCH 04/11] skip duplicate check for private segments on upsert (#2472) --- backend/packages/Upgrade/src/api/services/SegmentService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/packages/Upgrade/src/api/services/SegmentService.ts b/backend/packages/Upgrade/src/api/services/SegmentService.ts index cdffe59153..e17773b547 100644 --- a/backend/packages/Upgrade/src/api/services/SegmentService.ts +++ b/backend/packages/Upgrade/src/api/services/SegmentService.ts @@ -384,7 +384,9 @@ export class SegmentService { } public async upsertSegment(segment: SegmentInputValidator, logger: UpgradeLogger): Promise { - await this.checkIsDuplicateSegmentName(segment.name, segment.context, segment.id, logger); + if (segment.type !== SEGMENT_TYPE.PRIVATE) { + await this.checkIsDuplicateSegmentName(segment.name, segment.context, segment.id, logger); + } logger.info({ message: `Upsert segment => ${JSON.stringify(segment, undefined, 2)}` }); return this.addSegmentDataInDB(segment, logger); } From ded48f28efc000818529efb85de70a77d2876649 Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Wed, 7 May 2025 17:13:48 -0400 Subject: [PATCH 05/11] put changelog.md for release notes in repo (#2463) --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..8810a711fc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +UpGrade 6.1 Release Notes + +Segments Refresh: + +- Redesigned UI for Segments pages / modals (UX design kudos to @zackcl) +- Global exclusion segments now separated by app-context (was previously one global list) +- Updated list management for segments with import/export functionality +- Improved search and pagination + +Note that the segment data structure has been updated also: + +- Backwards compatible, old and new segment structures will both work. +- Newly added segments will see an update UI for working with lists. +- 'Old' segments will continue to be supported, but with the old UI in the details view. +- It is recommended to recreate 'old' style lists to the newer style when convenient. + +Other updates: + +Metrics display in data tab of experiments has been turned back on in the UI +Performance Optimizations - Improved database queries for metrics and experiment conditions +Error Handling Improvements - Better HTTP error codes with consistent handling across endpoints +Mooclet Integration Backend - Added foundational support for Mooclet experiments (not user-visible in this release) + +Angular 19 / Node 22 Support +Java Client support for Java 17 (specifically handling PATCH requests) +Support for ECS Infrastructure, CORS whitelisting + +Important note for devs running upgrade instance locally: +- local UI configuration no longer uses the hardcoded `environment.ts` by default in local +- you will need to create an `environment.local.ts` file, which is properly gitignored \ No newline at end of file From 268e0c3f1bb84e46229625e512347d8ca5d45200 Mon Sep 17 00:00:00 2001 From: Ben Blanchard Date: Wed, 7 May 2025 17:21:39 -0400 Subject: [PATCH 06/11] remove sorting by status from global exclude list (#2476) --- .../segment-global-section-card-table.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-global-section-card/segment-global-section-card-table/segment-global-section-card-table.component.html b/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-global-section-card/segment-global-section-card-table/segment-global-section-card-table.component.html index d89c1acd25..dc93e3659f 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-global-section-card/segment-global-section-card-table/segment-global-section-card-table.component.html +++ b/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-global-section-card/segment-global-section-card-table/segment-global-section-card-table.component.html @@ -37,7 +37,7 @@ - + {{ SEGMENT_TRANSLATION_KEYS.STATUS | translate }} From 14a062f7403e72f1474faae4d6325affb8b0b8da Mon Sep 17 00:00:00 2001 From: Ben Blanchard Date: Thu, 29 May 2025 13:51:28 -0400 Subject: [PATCH 07/11] Exclude user with no working group data if there are global group excludes (#2502) --- .../services/ExperimentAssignmentService.ts | 3 +- .../ExperimentAssignmentService.test.ts | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/backend/packages/Upgrade/src/api/services/ExperimentAssignmentService.ts b/backend/packages/Upgrade/src/api/services/ExperimentAssignmentService.ts index 865b56ff23..9be045790d 100644 --- a/backend/packages/Upgrade/src/api/services/ExperimentAssignmentService.ts +++ b/backend/packages/Upgrade/src/api/services/ExperimentAssignmentService.ts @@ -867,7 +867,8 @@ export class ExperimentAssignmentService { //users and groups excluded from GlobalExclude segment const userExcluded = excludedUsers.find((userId) => userId === experimentUser.id); - const groupExcluded = userGroup.length > 0 ? excludedGroups.filter((group) => userGroup.includes(group)) : []; + const groupExcluded = + userGroup.length > 0 ? excludedGroups.filter((group) => userGroup.includes(group)) : excludedGroups; return [userExcluded !== undefined, groupExcluded.length > 0]; } diff --git a/backend/packages/Upgrade/test/unit/services/ExperimentAssignmentService.test.ts b/backend/packages/Upgrade/test/unit/services/ExperimentAssignmentService.test.ts index 796859bcd1..b77d6bca1b 100644 --- a/backend/packages/Upgrade/test/unit/services/ExperimentAssignmentService.test.ts +++ b/backend/packages/Upgrade/test/unit/services/ExperimentAssignmentService.test.ts @@ -430,6 +430,46 @@ describe('Experiment Assignment Service Test', () => { expect(exclusionResult).toEqual([true, false]); }); + it('[checkUserOrGroupIsGloballyExcluded] should return true for users with no working group if there is a global group exclusion', async () => { + const userDoc = { id: 'user6', group: {}, workingGroup: {} }; + // stub the global exclusion segment with group `anygroup` in groupForSegment + testedModule.segmentService.getSegmentByIds.withArgs(['77777777-7777-7777-7777-777777777777']).resolves([ + { + id: '77777777-7777-7777-7777-777777777777', + name: 'Global Exclude', + description: 'Globally excluded Users, Groups and Segments', + context: 'context', + type: 'global_exclude', + individualForSegment: [], + groupForSegment: [{ groupId: 'anygroup', type: 'teacher' }], + subSegments: [], + }, + ]); + + const exclusionResult = await testedModule.checkUserOrGroupIsGloballyExcluded(userDoc, 'context'); + expect(exclusionResult).toEqual([false, true]); + }); + + it('[checkUserOrGroupIsGloballyExcluded] should return false for users with a non-excluded working group if there is a global group exclusion', async () => { + const userDoc = { id: 'user7', group: { schoolId: ['school1'] }, workingGroup: { schoolId: 'school1' } }; + // stub the global exclusion segment with group `anygroup` in groupForSegment + testedModule.segmentService.getSegmentByIds.withArgs(['77777777-7777-7777-7777-777777777777']).resolves([ + { + id: '77777777-7777-7777-7777-777777777777', + name: 'Global Exclude', + description: 'Globally excluded Users, Groups and Segments', + context: 'context', + type: 'global_exclude', + individualForSegment: [], + groupForSegment: [{ groupId: 'anygroup', type: 'teacher' }], + subSegments: [], + }, + ]); + + const exclusionResult = await testedModule.checkUserOrGroupIsGloballyExcluded(userDoc, 'context'); + expect(exclusionResult).toEqual([false, false]); + }); + it('[getAssignmentsAndExclusionsForUser] should return empty enrollment/exclusion user and group documents', async () => { const experimentUser = { id: 'user123', From dd4c2f6a7f451aacb59671a7edd9039df343725e Mon Sep 17 00:00:00 2001 From: Zack Lee <90279765+zackcl@users.noreply.github.com> Date: Fri, 13 Jun 2025 22:41:07 +0900 Subject: [PATCH 08/11] Auto-remove leading/trailing spaces from user-entered payloads (#2534) --- .../payloads-table.component.html | 1 + .../payloads-table.component.ts | 2 +- .../conditions-table.component.html | 1 + ...factorial-experiment-design.component.html | 1 + .../shared/directives/trim-input.directive.ts | 37 +++++++++++++++++++ .../upgrade/src/app/shared/shared.module.ts | 3 ++ 6 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 frontend/projects/upgrade/src/app/shared/directives/trim-input.directive.ts diff --git a/frontend/projects/upgrade/src/app/features/dashboard/home/components/experiment-design/payloads-table/payloads-table.component.html b/frontend/projects/upgrade/src/app/features/dashboard/home/components/experiment-design/payloads-table/payloads-table.component.html index 73af470757..a43b3f9611 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/home/components/experiment-design/payloads-table/payloads-table.component.html +++ b/frontend/projects/upgrade/src/app/features/dashboard/home/components/experiment-design/payloads-table/payloads-table.component.html @@ -62,6 +62,7 @@ required [matAutocomplete]="payloadRowAutoCompleteConditionCodes" (keyup)="handleFilterContextMetaDataConditions(rowData.payload, $event)" + appTrimInput /> diff --git a/frontend/projects/upgrade/src/app/features/dashboard/home/components/factorial-experiment-design/factorial-experiment-design.component.html b/frontend/projects/upgrade/src/app/features/dashboard/home/components/factorial-experiment-design/factorial-experiment-design.component.html index 8d08a6cca6..792cdb7a56 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/home/components/factorial-experiment-design/factorial-experiment-design.component.html +++ b/frontend/projects/upgrade/src/app/features/dashboard/home/components/factorial-experiment-design/factorial-experiment-design.component.html @@ -539,6 +539,7 @@ formControlName="value" [value]="payload?.value" autocomplete="off" + appTrimInput /> diff --git a/frontend/projects/upgrade/src/app/shared/directives/trim-input.directive.ts b/frontend/projects/upgrade/src/app/shared/directives/trim-input.directive.ts new file mode 100644 index 0000000000..c0ee3b8d99 --- /dev/null +++ b/frontend/projects/upgrade/src/app/shared/directives/trim-input.directive.ts @@ -0,0 +1,37 @@ +import { Directive, ElementRef, HostListener } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +/** + * TrimInputDirective automatically trims leading and trailing whitespace + * from form control values when the user finishes editing (on blur). + * + * Usage: + * + * + * This directive will: + * - Trim whitespace on blur (when user finishes editing) + * - Update both the DOM element and the form control + * - Work with any input that has a form control + */ +@Directive({ + selector: '[appTrimInput]', + standalone: false, +}) +export class TrimInputDirective { + constructor(private el: ElementRef, private control: NgControl) {} + + @HostListener('blur') onBlur() { + const value = this.el.nativeElement.value; + if (typeof value === 'string') { + const trimmedValue = value.trim(); + + // Only update if the value actually changed after trimming + if (trimmedValue !== value) { + this.el.nativeElement.value = trimmedValue; + if (this.control?.control) { + this.control.control.setValue(trimmedValue, { emitEvent: false }); + } + } + } + } +} diff --git a/frontend/projects/upgrade/src/app/shared/shared.module.ts b/frontend/projects/upgrade/src/app/shared/shared.module.ts index ae72779a8f..4fc23947d9 100755 --- a/frontend/projects/upgrade/src/app/shared/shared.module.ts +++ b/frontend/projects/upgrade/src/app/shared/shared.module.ts @@ -34,6 +34,7 @@ import { TruncatePipe } from './pipes/truncate.pipe'; import { ExperimentStatePipe } from './pipes/experiment-state.pipe'; import { FormatDatePipe } from './pipes/format-date.pipe'; import { ScrollDirective } from './directives/scroll.directive'; +import { TrimInputDirective } from './directives/trim-input.directive'; import { OperationPipe } from './pipes/operation.pipe'; import { SegmentStatusPipe } from './pipes/segment-status.pipe'; import { QueryResultComponent } from './components/query-result/query-result.component'; @@ -76,6 +77,7 @@ import { MatConfirmDialogComponent } from './components/mat-confirm-dialog/mat-c TruncatePipe, ExperimentStatePipe, ScrollDirective, + TrimInputDirective, FormatDatePipe, OperationPipe, QueryResultComponent, @@ -118,6 +120,7 @@ import { MatConfirmDialogComponent } from './components/mat-confirm-dialog/mat-c ExperimentStatePipe, FormatDatePipe, ScrollDirective, + TrimInputDirective, OperationPipe, QueryResultComponent, DeleteComponent, From 4690f47abec0ac17f53a9c7dd139419d7ebbef8a Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:26:22 -0400 Subject: [PATCH 09/11] Feature/ff ephemeral user options (#2556) * ephemeral ff user groups and remove exposure id FK constraint * ephemeral ff user groups and remove exposure id FK constraint * remove comment from ff-exposure model * use same language in usercheck middleware docs as swagger --- .../ExperimentClientController.v6.ts | 84 ++- .../validators/FeatureFlagRequestValidator.ts | 63 +++ .../api/middlewares/UserCheckMiddleware.ts | 162 +++++- .../src/api/models/FeatureFlagExposure.ts | 5 - .../src/api/services/FeatureFlagService.ts | 2 +- ...57069-dropExperimentUserFKFromExposures.ts | 19 + .../middlewares/UserCheckMiddleware.test.ts | 488 ++++++++++++++++++ clientlibs/js/package.json | 2 +- clientlibs/js/quickTest.ts | 51 +- .../js/src/ApiService/ApiService.spec.ts | 72 +++ clientlibs/js/src/ApiService/ApiService.ts | 32 +- clientlibs/js/src/DataService/DataService.ts | 2 +- .../src/UpGradeClient/UpGradeClient.types.ts | 7 - .../src/UpGradeClient/UpgradeClient.spec.ts | 80 +++ .../js/src/UpGradeClient/UpgradeClient.ts | 107 +++- clientlibs/js/src/types/Interfaces.ts | 16 + clientlibs/js/src/types/requests.ts | 12 +- 17 files changed, 1159 insertions(+), 45 deletions(-) create mode 100644 backend/packages/Upgrade/src/api/controllers/validators/FeatureFlagRequestValidator.ts create mode 100644 backend/packages/Upgrade/src/database/migrations/1750860557069-dropExperimentUserFKFromExposures.ts create mode 100644 backend/packages/Upgrade/test/unit/middlewares/UserCheckMiddleware.test.ts diff --git a/backend/packages/Upgrade/src/api/controllers/ExperimentClientController.v6.ts b/backend/packages/Upgrade/src/api/controllers/ExperimentClientController.v6.ts index 107b7db323..39a29ca970 100644 --- a/backend/packages/Upgrade/src/api/controllers/ExperimentClientController.v6.ts +++ b/backend/packages/Upgrade/src/api/controllers/ExperimentClientController.v6.ts @@ -12,6 +12,7 @@ import { import { ExperimentService } from '../services/ExperimentService'; import { ExperimentAssignmentService } from '../services/ExperimentAssignmentService'; import { ExperimentAssignmentValidatorv6 } from './validators/ExperimentAssignmentValidator'; +import { FeatureFlagRequestValidator } from './validators/FeatureFlagRequestValidator'; import { ExperimentUser } from '../models/ExperimentUser'; import { ExperimentUserService } from '../services/ExperimentUserService'; import { UpdateWorkingGroupValidatorv6 } from './validators/UpdateWorkingGroupValidator'; @@ -665,20 +666,88 @@ export class ExperimentClientController { * @swagger * /v6/featureflag: * post: - * description: Get all feature flags using SDK + * description: | + * Get feature flags that have been assigned to the user. + * + * This endpoint supports three different modes of operation based on the optional parameters: + * + * **Stored-user Mode** (Standard stored user lookup): + * - Omit both `groupsForSession` and `includeStoredUserGroups` parameters + * - Uses only stored user groups from the database + * - User must already have been initialized, will 404 if user does not exist + * + * **Ephemeral Mode** (Session-only groups): + * - Set `includeStoredUserGroups` to `false` and provide `groupsForSession` + * - Uses only the groups provided in the session, ignoring any stored user groups. + * - Does not require the user to be initialized (it will bypass stored user lookup) + * - Useful when complete group information is always provided at runtime. + * + * **Merged Mode** (Stored + Session groups): + * - Set `includeStoredUserGroups` to `true` and provide `groupsForSession` + * - User must already have been initialized, will 404 if user does not exist. + * - Session groups are merged with stored groups if they don't already exist for stored user. + * - Session groups are never persisted. + * - Useful for adding context-specific ephemeral groups to an existing user. + * * consumes: * - application/json * parameters: + * - in: header + * name: User-Id + * required: true + * schema: + * type: string + * example: user123 + * description: The unique identifier for the user * - in: body * name: user * required: true * schema: * type: object + * required: + * - context * properties: * context: * type: string - * example: add - * description: User Document + * example: "test-context" + * description: The context for feature flag evaluation + * groupsForSession: + * type: object + * additionalProperties: + * type: array + * items: + * type: string + * example: + * schoolId: ["temporary-school-id"] + * description: Optional groups to provide for the session (not persisted) + * includeStoredUserGroups: + * type: boolean + * description: Whether to include stored user groups in evaluation + * example: false + * description: Feature flag request parameters + * examples: + * normal_mode: + * summary: Normal Mode - Standard stored user lookup + * description: Uses only stored user groups from the database + * value: + * context: "test-context" + * ephemeral_mode: + * summary: Ephemeral Mode - Session-only groups + * description: Uses only session groups, ignoring stored user groups + * value: + * context: "test-context" + * groupsForSession: + * schoolId: ["demo-school"] + * classId: ["demo-class-advanced"] + * includeStoredUserGroups: false + * merged_mode: + * summary: Merged Mode - Stored + Session groups + * description: Combines stored user groups (if exists) with session groups + * value: + * context: "test-context" + * groupsForSession: + * classId: ["temp-class-123", "special-session"] + * includeStoredUserGroups: true * produces: * - application/json * tags: @@ -686,6 +755,11 @@ export class ExperimentClientController { * responses: * '200': * description: Feature flags list + * schema: + * type: array + * items: + * type: string + * example: ["NEW_FEATURE", "TEST_FLAG"] * '400': * description: BadRequestError - InvalidParameterValue * '401': @@ -699,10 +773,10 @@ export class ExperimentClientController { public async getAllFlags( @Req() request: AppRequest, @Body({ validate: true }) - experiment: ExperimentAssignmentValidatorv6 + featureFlagRequest: FeatureFlagRequestValidator ): Promise { const experimentUserDoc = request.userDoc; - return this.featureFlagService.getKeys(experimentUserDoc, experiment.context, request.logger); + return this.featureFlagService.getKeys(experimentUserDoc, featureFlagRequest.context, request.logger); } /** diff --git a/backend/packages/Upgrade/src/api/controllers/validators/FeatureFlagRequestValidator.ts b/backend/packages/Upgrade/src/api/controllers/validators/FeatureFlagRequestValidator.ts new file mode 100644 index 0000000000..1b848fe562 --- /dev/null +++ b/backend/packages/Upgrade/src/api/controllers/validators/FeatureFlagRequestValidator.ts @@ -0,0 +1,63 @@ +import { + IsNotEmpty, + IsOptional, + IsString, + IsBoolean, + IsObject, + registerDecorator, + ValidationOptions, + ValidationArguments, +} from 'class-validator'; + +export type IGetAllFeatureFlagsRequestBody = + | { + context: string; + } + | { + context: string; + groupsForSession: Record; + includeStoredUserGroups: boolean; + }; + +// Custom validation decorator to ensure both session properties are provided together +const BothSessionPropertiesRequired = (validationOptions?: ValidationOptions) => { + const registerBothSessionPropertiesRequired = (object: object, propertyName: string) => { + registerDecorator({ + name: 'bothSessionPropertiesRequired', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const obj = args.object as any; + const hasProvideGroups = obj.groupsForSession !== undefined; + const hasIncludeStored = obj.includeStoredUserGroups !== undefined; + + // Both must be provided together, or neither + return (hasProvideGroups && hasIncludeStored) || (!hasProvideGroups && !hasIncludeStored); + }, + defaultMessage() { + return 'Both groupsForSession and includeStoredUserGroups must be provided together, or neither should be provided'; + }, + }, + }); + }; + + return registerBothSessionPropertiesRequired; +}; + +export class FeatureFlagRequestValidator { + @IsNotEmpty() + @IsString() + public context: string; + + @IsOptional() + @IsObject() + @BothSessionPropertiesRequired() + public groupsForSession?: Record; + + @IsOptional() + @IsBoolean() + @BothSessionPropertiesRequired() + public includeStoredUserGroups?: boolean; +} diff --git a/backend/packages/Upgrade/src/api/middlewares/UserCheckMiddleware.ts b/backend/packages/Upgrade/src/api/middlewares/UserCheckMiddleware.ts index 728074d24e..b848f91c25 100644 --- a/backend/packages/Upgrade/src/api/middlewares/UserCheckMiddleware.ts +++ b/backend/packages/Upgrade/src/api/middlewares/UserCheckMiddleware.ts @@ -4,6 +4,7 @@ import { SERVER_ERROR } from 'upgrade_types'; import { AppRequest } from '../../types'; import { Service } from 'typedi'; import { ExperimentUserService } from '../services/ExperimentUserService'; +import { RequestedExperimentUser } from '../controllers/validators/ExperimentUserValidator'; @Service() export class UserCheckMiddleware { @@ -22,7 +23,15 @@ export class UserCheckMiddleware { req.logger.child({ user_id }); req.logger.debug({ message: 'User Id is:', user_id }); } - const experimentUserDoc = await this.experimentUserService.getUserDoc(user_id, req.logger); + + let experimentUserDoc: RequestedExperimentUser; + + if (req.url.endsWith('/v6/featureflag')) { + experimentUserDoc = await this.handleProvidedGroupsForSession(req, user_id); + } else { + experimentUserDoc = await this.experimentUserService.getUserDoc(user_id, req.logger); + } + if (!req.url.endsWith('/init') && !experimentUserDoc) { const error = new Error(`User not found: ${user_id}`); (error as any).type = SERVER_ERROR.EXPERIMENT_USER_NOT_DEFINED; @@ -30,6 +39,7 @@ export class UserCheckMiddleware { req.logger.error(error); return next(error); } + req.userDoc = experimentUserDoc; // Continue to the next middleware/controller return next(); @@ -38,4 +48,154 @@ export class UserCheckMiddleware { return next(error); } } + + /** + * Handles potential ephemeral session groups provided in the request. + * + * This method processes different scenarios based on the combination of + * `groupsForSession` and `includeStoredUserGroups` parameters: + * + * Note: provided session groups are never persisted + * + * **Stored-user Mode** (Standard stored user lookup): + * - Omit both `groupsForSession` and `includeStoredUserGroups` parameters + * - Uses only stored user groups from the database + * - User must already have been initialized, will 404 if user does not exist + * + * **Ephemeral Mode** (Session-only groups): + * - Set `includeStoredUserGroups` to `false` and provide `groupsForSession` + * - Uses only the groups provided in the session, ignoring any stored user groups. + * - Does not require the user to be initialized (it will bypass stored user lookup) + * - Useful when complete group information is always provided at runtime. + * + * **Merged Mode** (Stored + Session groups): + * - Set `includeStoredUserGroups` to `true` and provide `groupsForSession` + * - User must already have been initialized, will 404 if user does not exist. + * - Session groups are merged with stored groups if they don't already exist for stored user. + * - Session groups are never persisted. + * - Useful for adding context-specific ephemeral groups to an existing user. + * + * @param req - The application request containing session group parameters + * @param user_id - The user identifier for group lookup + * @returns Promise resolving to RequestedExperimentUser with appropriate group configuration + * + * @example + * ```typescript + * // Scenario 1: Standard lookup (no session params) + * { + * context: "storedUserDependentContext", + * } + * + * // Scenario 2: Ignore stored user and use session groups only + * { + * context: "independentContext", + * groupsForSession: { "classId": ["testClass"], "schoolId": ["instructor"] }, + * includeStoredUserGroups: false + * } + * + * // Scenario 3: Merge session and stored groups + * { + * context: "storedUserMergedContext", + * groupsForSession: { "classId": ["testClass"], "schoolId": ["demoSchool"] }, + * includeStoredUserGroups: true + * } + * + * ``` + */ + private async handleProvidedGroupsForSession(req: AppRequest, user_id: string): Promise { + // Note: validation of request will have occurred before this middleware + + // Scenario 1: Session-only groups (ephemeral user mode) + // explicitly check if includeStoredUserGroups is exactly false and not just undefined + if (req.body.groupsForSession && req.body.includeStoredUserGroups === false) { + const experimentUserDoc = this.createSessionUser(user_id, req.body.groupsForSession); + + req.logger.debug({ + message: 'Created ephemeral user with session groups', + experimentUserDoc, + }); + + return experimentUserDoc; + } + + // Load stored user document, required for scenarios 2 and 3 + const experimentUserDoc = await this.experimentUserService.getUserDoc(user_id, req.logger); + + if (!experimentUserDoc) { + return null; // User not found, will be handled in the main middleware + } + + if (req.body.groupsForSession && req.body.includeStoredUserGroups) { + // Scenario 2: Merged groups (Merged stored/ephemeral groups mode) + experimentUserDoc.group = this.mergeGroupsWithUniqueValues(experimentUserDoc.group, req.body.groupsForSession); + + req.logger.debug({ + message: 'Merged session groups with stored user groups', + experimentUserDoc, + }); + } else { + // Scenario 3: Standard behavior (user-lookup from user-id) + req.logger.debug({ + message: 'Using standard user lookup without session group modifications', + experimentUserDoc, + }); + } + + return experimentUserDoc; + } + + /** + * Creates a RequestedExperimentUser object for ephemeral user scenarios. + * + * @param user_id - The user ID for the experiment user + * @param groupsForSession - The groups provided in the session + * @returns RequestedExperimentUser object with session groups + */ + private createSessionUser(user_id: string, groupsForSession: Record): RequestedExperimentUser { + const sessionUser = new RequestedExperimentUser(); + sessionUser.id = user_id; + sessionUser.requestedUserId = user_id; + sessionUser.group = groupsForSession; + sessionUser.workingGroup = undefined; + return sessionUser; + } + + /** + * Merges two group objects with unique values per key. + * + * This utility method combines existing user groups with incoming session groups, + * ensuring no duplicate values exist within each group key's array. + * + * @param existing - The existing user groups (may be undefined) + * @param incoming - The incoming session groups to merge + * @returns A new object with merged groups containing unique values + * + * @example + * ```typescript + * const existing = { "classId": ["123", "xyz"], "schoolId": ["qwerty"] }; + * const incoming = { "classId": ["abc", "123"], "instructorId": ["dale123"] }; + * const merged = mergeGroupsWithUniqueValues(existing, incoming); + * // Result: { + * // "classId": ["123", "xyz", "abc"], + * // "schoolId": ["qwerty"], + * // "instructorId": ["dale123"] + * // } + * ``` + */ + private mergeGroupsWithUniqueValues( + existing: Record | undefined, + incoming: Record + ): Record { + const result: Record = { ...existing }; + + for (const [key, values] of Object.entries(incoming)) { + if (result[key]) { + result[key] = [...new Set([...result[key], ...values])]; + } else { + result[key] = [...values]; + } + } + + return result; + } } diff --git a/backend/packages/Upgrade/src/api/models/FeatureFlagExposure.ts b/backend/packages/Upgrade/src/api/models/FeatureFlagExposure.ts index 3632563742..ea31e8424a 100644 --- a/backend/packages/Upgrade/src/api/models/FeatureFlagExposure.ts +++ b/backend/packages/Upgrade/src/api/models/FeatureFlagExposure.ts @@ -1,7 +1,6 @@ import { Entity, Index, ManyToOne, PrimaryColumn } from 'typeorm'; import { BaseModel } from './base/BaseModel'; import { FeatureFlag } from './FeatureFlag'; -import { ExperimentUser } from './ExperimentUser'; @Entity() export class FeatureFlagExposure extends BaseModel { @@ -9,14 +8,10 @@ export class FeatureFlagExposure extends BaseModel { @PrimaryColumn() featureFlagId: string; - // Define primary column for the foreign key @PrimaryColumn() experimentUserId: string; @Index() @ManyToOne(() => FeatureFlag, { onDelete: 'CASCADE' }) public featureFlag: FeatureFlag; - @Index() - @ManyToOne(() => ExperimentUser, { onDelete: 'CASCADE' }) - public experimentUser: ExperimentUser; } diff --git a/backend/packages/Upgrade/src/api/services/FeatureFlagService.ts b/backend/packages/Upgrade/src/api/services/FeatureFlagService.ts index aa01baef77..188a6aff35 100644 --- a/backend/packages/Upgrade/src/api/services/FeatureFlagService.ts +++ b/backend/packages/Upgrade/src/api/services/FeatureFlagService.ts @@ -116,7 +116,7 @@ export class FeatureFlagService { const exposureRepo = transactionalEntityManager.getRepository(FeatureFlagExposure); const exposuresToSave = includedFeatureFlags.map((flag) => ({ featureFlag: flag, - experimentUser: experimentUserDoc, + experimentUserId: experimentUserDoc.id, })); if (exposuresToSave.length > 0) { // fire and forget, we don't need to wait on this, return flag asap to user diff --git a/backend/packages/Upgrade/src/database/migrations/1750860557069-dropExperimentUserFKFromExposures.ts b/backend/packages/Upgrade/src/database/migrations/1750860557069-dropExperimentUserFKFromExposures.ts new file mode 100644 index 0000000000..17aef51857 --- /dev/null +++ b/backend/packages/Upgrade/src/database/migrations/1750860557069-dropExperimentUserFKFromExposures.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropExperimentUserFKFromExposures1750860557069 implements MigrationInterface { + name = 'DropExperimentUserFKFromExposures1750860557069'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "feature_flag_exposure" DROP CONSTRAINT "FK_6cefc76de0ca7c9a38faae5a8c4"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6cefc76de0ca7c9a38faae5a8c"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_6cefc76de0ca7c9a38faae5a8c" ON "feature_flag_exposure" ("experimentUserId") ` + ); + await queryRunner.query( + `ALTER TABLE "feature_flag_exposure" ADD CONSTRAINT "FK_6cefc76de0ca7c9a38faae5a8c4" FOREIGN KEY ("experimentUserId") REFERENCES "experiment_user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } +} diff --git a/backend/packages/Upgrade/test/unit/middlewares/UserCheckMiddleware.test.ts b/backend/packages/Upgrade/test/unit/middlewares/UserCheckMiddleware.test.ts new file mode 100644 index 0000000000..1192cb2107 --- /dev/null +++ b/backend/packages/Upgrade/test/unit/middlewares/UserCheckMiddleware.test.ts @@ -0,0 +1,488 @@ +import { NextFunction } from 'express'; +import { UserCheckMiddleware } from '../../../src/api/middlewares/UserCheckMiddleware'; +import { UpgradeLogger } from '../../../src/lib/logger/UpgradeLogger'; +import { RequestedExperimentUser } from '../../../src/api/controllers/validators/ExperimentUserValidator'; +import { AppRequest } from '../../../src/types'; +import { SERVER_ERROR } from 'upgrade_types'; + +// Mock services +class SettingServiceMock { + // Placeholder for settings service mock + public getSetting(): any { + return {}; + } +} + +class ExperimentUserServiceMock { + private readonly mockUsers: Map = new Map(); + + public setMockUser(userId: string, user: RequestedExperimentUser): void { + this.mockUsers.set(userId, user); + } + + public clearMockUsers(): void { + this.mockUsers.clear(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async getUserDoc(userId: string, _logger: UpgradeLogger): Promise { + return this.mockUsers.get(userId) || null; + } +} + +describe('UserCheckMiddleware Tests', () => { + let middleware: UserCheckMiddleware; + let mockSettingService: SettingServiceMock; + let mockExperimentUserService: ExperimentUserServiceMock; + let mockRequest: Partial; + let mockResponse: any; + let nextFunction: NextFunction; + let mockLogger: UpgradeLogger; + + beforeEach(() => { + mockSettingService = new SettingServiceMock(); + mockExperimentUserService = new ExperimentUserServiceMock(); + middleware = new UserCheckMiddleware(mockSettingService as any, mockExperimentUserService as any); + + mockLogger = new UpgradeLogger(); + jest.spyOn(mockLogger, 'child').mockReturnValue(mockLogger as any); + jest.spyOn(mockLogger, 'debug').mockImplementation(); + jest.spyOn(mockLogger, 'info').mockImplementation(); + jest.spyOn(mockLogger, 'error').mockImplementation(); + + mockRequest = { + get: jest.fn(), + logger: mockLogger, + url: '/api/v6/test', + body: {}, + }; + + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + nextFunction = jest.fn(); + + // Clear mock users before each test + mockExperimentUserService.clearMockUsers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Basic middleware functionality', () => { + test('should call next() when User-Id header is present and user exists', async () => { + const userId = 'test-user-123'; + const mockUser = new RequestedExperimentUser(); + mockUser.id = userId; + mockUser.requestedUserId = userId; + mockUser.group = { classId: ['class1'] }; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockExperimentUserService.setMockUser(userId, mockUser); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc).toEqual(mockUser); + expect(mockLogger.child).toHaveBeenCalledWith({ user_id: userId }); + }); + + test('should return error when User-Id header is missing', async () => { + (mockRequest.get as jest.Mock).mockReturnValue(undefined); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User-Id header not found.', + type: SERVER_ERROR.MISSING_HEADER_USER_ID, + httpCode: 400, + }) + ); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + test('should return error when user not found for non-init endpoints', async () => { + const userId = 'non-existent-user'; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.url = '/api/v6/assign'; // Non-init endpoint + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: `User not found: ${userId}`, + type: SERVER_ERROR.EXPERIMENT_USER_NOT_DEFINED, + httpCode: 404, + }) + ); + }); + + test('should allow non-existent user for init endpoint', async () => { + const userId = 'new-user-123'; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.url = '/api/v6/init'; + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc).toBeNull(); + }); + + test('should handle exceptions and call next with error', async () => { + const userId = 'test-user'; + const error = new Error('Database connection failed'); + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + jest.spyOn(mockExperimentUserService, 'getUserDoc').mockRejectedValue(error); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(error); + expect(mockLogger.error).toHaveBeenCalledWith(error); + }); + }); + + describe('Feature flag endpoint handling', () => { + beforeEach(() => { + mockRequest.url = '/api/v6/featureflag'; + }); + + test('should handle session-only groups (ephemeral user mode)', async () => { + const userId = 'ephemeral-user'; + const sessionGroups = { classId: ['session-class'], schoolId: ['session-school'] }; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + groupsForSession: sessionGroups, + includeStoredUserGroups: false, + }; + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc.id).toBe(userId); + expect(mockRequest.userDoc.requestedUserId).toBe(userId); + expect(mockRequest.userDoc.group).toEqual(sessionGroups); + expect(mockRequest.userDoc.workingGroup).toBeUndefined(); + expect(mockLogger.debug).toHaveBeenCalledWith({ + message: 'Created ephemeral user with session groups', + experimentUserDoc: expect.any(Object), + }); + }); + + test('should merge session groups with stored user groups', async () => { + const userId = 'existing-user'; + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + storedUser.group = { classId: ['stored-class'], instructorId: ['stored-instructor'] }; + + const sessionGroups = { classId: ['session-class'], schoolId: ['session-school'] }; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + groupsForSession: sessionGroups, + includeStoredUserGroups: true, + }; + + mockExperimentUserService.setMockUser(userId, storedUser); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc.group).toEqual({ + classId: ['stored-class', 'session-class'], // Merged and deduplicated + instructorId: ['stored-instructor'], + schoolId: ['session-school'], + }); + expect(mockLogger.debug).toHaveBeenCalledWith({ + message: 'Merged session groups with stored user groups', + experimentUserDoc: expect.any(Object), + }); + }); + + test('should use session groups when stored user has no groups', async () => { + const userId = 'user-no-groups'; + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + storedUser.group = undefined; // No stored groups + + const sessionGroups = { classId: ['session-class'] }; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + groupsForSession: sessionGroups, + includeStoredUserGroups: true, + }; + + mockExperimentUserService.setMockUser(userId, storedUser); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc.group).toEqual(sessionGroups); + expect(mockLogger.debug).toHaveBeenCalledWith({ + message: 'Merged session groups with stored user groups', + experimentUserDoc: expect.any(Object), + }); + }); + + test('should use standard user lookup without session modifications', async () => { + const userId = 'standard-user'; + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + storedUser.group = { classId: ['stored-class'] }; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = {}; // No session groups provided + + mockExperimentUserService.setMockUser(userId, storedUser); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc).toEqual(storedUser); + expect(mockLogger.debug).toHaveBeenCalledWith({ + message: 'Using standard user lookup without session group modifications', + experimentUserDoc: storedUser, + }); + }); + + test('should return error when user not found with includeStoredUserGroups=true', async () => { + const userId = 'non-existent-user'; + mockRequest.url = '/api/v6/featureflag'; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + groupsForSession: { classId: ['session-class'] }, + includeStoredUserGroups: true, + }; + + // No user set in mock service + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: `User not found: ${userId}`, + type: SERVER_ERROR.EXPERIMENT_USER_NOT_DEFINED, + httpCode: 404, + }) + ); + }); + }); + + describe('Group merging functionality', () => { + test('should merge groups with unique values', () => { + const existing = { + classId: ['class1', 'class2'], + instructorId: ['instructor1'], + }; + const incoming = { + classId: ['class2', 'class3'], // class2 is duplicate + schoolId: ['school1'], + }; + + // Access private method via reflection for testing + const result = (middleware as any).mergeGroupsWithUniqueValues(existing, incoming); + + expect(result).toEqual({ + classId: ['class1', 'class2', 'class3'], + instructorId: ['instructor1'], + schoolId: ['school1'], + }); + }); + + test('should handle undefined existing groups', () => { + const incoming = { + classId: ['class1'], + schoolId: ['school1'], + }; + + const result = (middleware as any).mergeGroupsWithUniqueValues(undefined, incoming); + + expect(result).toEqual(incoming); + }); + + test('should handle empty incoming groups', () => { + const existing = { + classId: ['class1'], + }; + + const result = (middleware as any).mergeGroupsWithUniqueValues(existing, {}); + + expect(result).toEqual(existing); + }); + }); + + describe('Session user creation', () => { + test('should create session user with provided groups', () => { + const sessionGroups = { + classId: ['class1', 'class2'], + schoolId: ['school1'], + }; + + const result = (middleware as any).createSessionUser('session-user', sessionGroups); + + expect(result).toMatchObject({ + id: 'session-user', + requestedUserId: 'session-user', + group: sessionGroups, + workingGroup: undefined, + }); + expect(result).toBeInstanceOf(RequestedExperimentUser); + }); + }); + + describe('Edge cases and error scenarios', () => { + test('should handle null User-Id header', async () => { + (mockRequest.get as jest.Mock).mockReturnValue(null); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User-Id header not found.', + type: SERVER_ERROR.MISSING_HEADER_USER_ID, + }) + ); + }); + + test('should handle empty string User-Id header', async () => { + (mockRequest.get as jest.Mock).mockReturnValue(''); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User-Id header not found.', + type: SERVER_ERROR.MISSING_HEADER_USER_ID, + }) + ); + }); + + test('should handle feature flag endpoint with includeStoredUserGroups=false but no groupsForSession', async () => { + const userId = 'test-user'; + mockRequest.url = '/api/v6/featureflag'; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + includeStoredUserGroups: false, + // No groupsForSession provided - this should fallback to standard lookup + }; + + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + mockExperimentUserService.setMockUser(userId, storedUser); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc).toEqual(storedUser); + expect(mockLogger.debug).toHaveBeenCalledWith({ + message: 'Using standard user lookup without session group modifications', + experimentUserDoc: storedUser, + }); + }); + + test('should handle complex group merging with multiple overlapping keys', async () => { + const userId = 'complex-user'; + mockRequest.url = '/api/v6/featureflag'; + + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + storedUser.group = { + classId: ['stored1', 'stored2'], + schoolId: ['school-stored'], + instructorId: ['instructor-stored'], + }; + + const sessionGroups = { + classId: ['stored1', 'session1'], // overlap with stored1 + schoolId: ['school-session'], // different from stored + newGroupType: ['new-value'], + }; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + groupsForSession: sessionGroups, + includeStoredUserGroups: true, + }; + + mockExperimentUserService.setMockUser(userId, storedUser); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(mockRequest.userDoc.group).toEqual({ + classId: ['stored1', 'stored2', 'session1'], + schoolId: ['school-stored', 'school-session'], + instructorId: ['instructor-stored'], + newGroupType: ['new-value'], + }); + }); + }); + + describe('URL pattern matching', () => { + test('should handle different feature flag URL patterns', async () => { + const userId = 'test-user'; + const sessionGroups = { classId: ['test'] }; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + groupsForSession: sessionGroups, + includeStoredUserGroups: false, + }; + + // Test various URL patterns that should trigger feature flag handling + const featureFlagUrls = ['/api/v6/featureflag', '/some/path/v6/featureflag', '/v6/featureflag']; + + for (const url of featureFlagUrls) { + mockRequest.url = url; + jest.clearAllMocks(); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc.group).toEqual(sessionGroups); + } + }); + + test('should use standard flow for non-feature flag URLs', async () => { + const userId = 'test-user'; + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + groupsForSession: { classId: ['should-be-ignored'] }, + includeStoredUserGroups: false, + }; + + mockExperimentUserService.setMockUser(userId, storedUser); + + const standardUrls = [ + '/api/v6/assign', + '/api/v6/mark', + '/api/v6/init', + '/api/v5/featureflag', // Different version + ]; + + for (const url of standardUrls) { + mockRequest.url = url; + jest.clearAllMocks(); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc).toEqual(storedUser); + } + }); + }); +}); diff --git a/clientlibs/js/package.json b/clientlibs/js/package.json index 70b1ea9dd1..47cefc0967 100644 --- a/clientlibs/js/package.json +++ b/clientlibs/js/package.json @@ -1,6 +1,6 @@ { "name": "upgrade_client_lib", - "version": "6.1.0", + "version": "6.1.1", "description": "Client library to communicate with the Upgrade server", "files": [ "dist/*" diff --git a/clientlibs/js/quickTest.ts b/clientlibs/js/quickTest.ts index a368978257..ae7f3615c4 100644 --- a/clientlibs/js/quickTest.ts +++ b/clientlibs/js/quickTest.ts @@ -1,6 +1,6 @@ // to run: npx ts-node clientlibs/js/quickTest.ts -import UpgradeClient, { AssignedCondition, MARKED_DECISION_POINT_STATUS, UpGradeClientInterfaces } from './dist/node'; +import UpgradeClient, { MARKED_DECISION_POINT_STATUS, UpGradeClientInterfaces } from './dist/node'; const URL = { LOCAL: 'http://localhost:3030', @@ -10,15 +10,29 @@ const URL = { ECS_STAGING: 'https://apps.qa-cli.com/upgrade-service', }; -const userId = 'quicktest_user_' + Date.now().toString(); -const group = 'test_class_group'; +const userId = 'qwerty6'; +const useEphemeralGroups = false; +const group = { classId: ['STORED_USER_GROUP'] }; +const workingGroup = 'STORED_USER_GROUP'; +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 = 'assign-prog'; +const context = 'mathstream'; const site = 'SelectSection'; const target = 'absolute_value_plot_equality'; const status = MARKED_DECISION_POINT_STATUS.CONDITION_APPLIED; const featureFlagKey = 'TEST_FEATURE_FLAG'; + +const options: UpGradeClientInterfaces.IConfigOptions = { + featureFlagUserGroupsForSession: useEphemeralGroups + ? { + groupsForSession, + includeStoredUserGroups, + } + : null, +}; + const logRequest = [ { userId, @@ -55,17 +69,18 @@ quickTest(); /** main test *******************************************************************************/ async function quickTest() { - const client = new UpgradeClient(userId, hostUrl, context); + const client = new UpgradeClient(userId, hostUrl, context, options); await doInit(client); await doGroupMembership(client); await doWorkingGroupMembership(client); await doAliases(client); await doAssign(client); const condition = await doGetDecisionPointAssignment(client); + doSetFeatureFlagUserGroupsForSession(client, options); await doFeatureFlags(client); - await doHasFeatureFlag(client); - await doMark(client, condition); - await doLog(client); + // await doHasFeatureFlag(client); + // await doMark(client, condition); + // await doLog(client); } /** test functions *******************************************************************************/ @@ -80,9 +95,7 @@ async function doInit(client: UpgradeClient) { } async function doGroupMembership(client: UpgradeClient) { - const groupRequest: UpGradeClientInterfaces.IExperimentUserGroup = { - schoolId: [group], - }; + const groupRequest: UpGradeClientInterfaces.IExperimentUserGroup = group; try { const response = await client.setGroupMembership(groupRequest); @@ -93,9 +106,7 @@ async function doGroupMembership(client: UpgradeClient) { } async function doWorkingGroupMembership(client: UpgradeClient) { - const workingGroupRequest: UpGradeClientInterfaces.IExperimentUserWorkingGroup = { - workingGroup: group, - }; + const workingGroupRequest: UpGradeClientInterfaces.IExperimentUserWorkingGroup = { workingGroup }; try { const response = await client.setWorkingGroup(workingGroupRequest); console.log('\n[Working Group response]:', JSON.stringify(response)); @@ -146,6 +157,14 @@ async function doGetDecisionPointAssignment(client: UpgradeClient): Promise { @@ -250,6 +251,77 @@ describe('ApiService', () => { }); }); + describe('#setFeatureFlagUserGroupsForSession', () => { + it('should update internal groupsForSession and includeStoredUserGroups properties', () => { + const mockGroupsForSession = { + school: ['testSchool1', 'testSchool2'], + class: ['testClass1'], + }; + const mockIncludeStoredUserGroups = true; + + apiService.setFeatureFlagUserGroupsForSession(mockGroupsForSession, mockIncludeStoredUserGroups); + + // Verify internal state was updated by checking if the values are used in subsequent requests + // Since the properties are private, we'll verify this through their usage in other methods + expect(apiService).toBeDefined(); + // The actual verification happens by checking if these values are used in feature flag requests + }); + + it('should handle null groupsForSession', () => { + const mockIncludeStoredUserGroups = false; + + expect(() => { + apiService.setFeatureFlagUserGroupsForSession(null as any, mockIncludeStoredUserGroups); + }).not.toThrow(); + }); + + it('should handle undefined groupsForSession', () => { + const mockIncludeStoredUserGroups = false; + + expect(() => { + apiService.setFeatureFlagUserGroupsForSession(undefined as any, mockIncludeStoredUserGroups); + }).not.toThrow(); + }); + + it('should handle empty groupsForSession object', () => { + const mockGroupsForSession = {}; + const mockIncludeStoredUserGroups = true; + + expect(() => { + apiService.setFeatureFlagUserGroupsForSession(mockGroupsForSession, mockIncludeStoredUserGroups); + }).not.toThrow(); + }); + + it('should update includeStoredUserGroups to false', () => { + const mockGroupsForSession = { + school: ['testSchool1'], + }; + const mockIncludeStoredUserGroups = false; + + expect(() => { + apiService.setFeatureFlagUserGroupsForSession(mockGroupsForSession, mockIncludeStoredUserGroups); + }).not.toThrow(); + }); + + it('should allow multiple calls to update the configuration', () => { + const firstGroupsForSession = { + school: ['testSchool1'], + }; + const secondGroupsForSession = { + school: ['testSchool2'], + class: ['testClass1'], + }; + + // First call + apiService.setFeatureFlagUserGroupsForSession(firstGroupsForSession, true); + + // Second call should overwrite the first + expect(() => { + apiService.setFeatureFlagUserGroupsForSession(secondGroupsForSession, false); + }).not.toThrow(); + }); + }); + describe('#logCaliper', () => { const expectedUrl = `${defaultConfig.hostURL}/api/${defaultConfig.apiVersion}/log/caliper`; const expectedOptions = { diff --git a/clientlibs/js/src/ApiService/ApiService.ts b/clientlibs/js/src/ApiService/ApiService.ts index df814de6fe..7c8a847014 100644 --- a/clientlibs/js/src/ApiService/ApiService.ts +++ b/clientlibs/js/src/ApiService/ApiService.ts @@ -18,6 +18,8 @@ export default class ApiService { private clientSessionId: string; private httpClient: UpGradeClientInterfaces.IHttpClientWrapper; private api: IEndpoints; + private groupsForSession: Record> | null; + private includeStoredUserGroups: boolean; constructor(config: UpGradeClientInterfaces.IConfig, private dataService: DataService) { this.context = config.context; @@ -38,6 +40,16 @@ export default class ApiService { altUserIds: `${this.hostUrl}/api/${this.apiVersion}/useraliases`, }; this.httpClient = this.setHttpClient(config.httpClient); + this.groupsForSession = config.featureFlagUserGroupsForSession?.groupsForSession ?? null; + this.includeStoredUserGroups = config.featureFlagUserGroupsForSession?.includeStoredUserGroups ?? null; + } + + public setFeatureFlagUserGroupsForSession( + groupsForSession: Record>, + includeStoredUserGroups: boolean + ): void { + this.groupsForSession = groupsForSession; + this.includeStoredUserGroups = includeStoredUserGroups; } private setHttpClient(httpClient: UpGradeClientInterfaces.IHttpClientWrapper) { @@ -278,9 +290,23 @@ export default class ApiService { } public async getAllFeatureFlags(): Promise { - const requestBody: UpGradeClientRequests.IGetAllFeatureFlagsRequestBody = { - context: this.context, - }; + let requestBody: UpGradeClientRequests.IGetAllFeatureFlagsRequestBody; + + if (this.groupsForSession && this.includeStoredUserGroups !== undefined) { + // if groupsForSession is provided, we need to include it in the request body + requestBody = { + context: this.context, + groupsForSession: this.groupsForSession, + includeStoredUserGroups: this.includeStoredUserGroups, + }; + } else { + // if no groupsForSession is provided, just use the context + requestBody = { + context: this.context, + }; + } + + console.log('[ApiService] getAllFeatureFlags requestBody:', requestBody); const response = await this.sendRequest({ path: this.api.getAllFeatureFlag, diff --git a/clientlibs/js/src/DataService/DataService.ts b/clientlibs/js/src/DataService/DataService.ts index 5e9e67138b..62ed9f70a2 100644 --- a/clientlibs/js/src/DataService/DataService.ts +++ b/clientlibs/js/src/DataService/DataService.ts @@ -1,5 +1,5 @@ import { UpGradeClientInterfaces } from '../types'; -import { IExperimentAssignmentv5, IFeatureFlag } from 'upgrade_types'; +import { IExperimentAssignmentv5 } from 'upgrade_types'; /** * Synchronous data store diff --git a/clientlibs/js/src/UpGradeClient/UpGradeClient.types.ts b/clientlibs/js/src/UpGradeClient/UpGradeClient.types.ts index 5b392d21db..0825ed35aa 100644 --- a/clientlibs/js/src/UpGradeClient/UpGradeClient.types.ts +++ b/clientlibs/js/src/UpGradeClient/UpGradeClient.types.ts @@ -1,4 +1,3 @@ -import { UpGradeClientInterfaces } from 'types'; import { MARKED_DECISION_POINT_STATUS } from 'upgrade_types'; export interface IMarkDecisionPointParams { @@ -9,9 +8,3 @@ export interface IMarkDecisionPointParams { uniquifier?: string; clientError?: string; } - -export interface IConfigOptions { - token?: string; - clientSessionId?: string; - httpClient?: UpGradeClientInterfaces.IHttpClientWrapper; -} diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts index 19f9704789..fed5842761 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts @@ -119,4 +119,84 @@ describe('UpgradeClient', () => { }); }); }); + + describe('#setFeatureFlagUserGroupsForSession', () => { + it('should call apiService "setFeatureFlagUserGroupsForSession" with valid feature flag options', () => { + const mockFeatureFlagOptions = { + groupsForSession: { + school: ['testSchool1', 'testSchool2'], + class: ['testClass1'], + }, + includeStoredUserGroups: true, + }; + ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); + + upgradeClient.setFeatureFlagUserGroupsForSession(mockFeatureFlagOptions); + + expect(ApiService.prototype.setFeatureFlagUserGroupsForSession).toHaveBeenCalledWith( + mockFeatureFlagOptions.groupsForSession, + mockFeatureFlagOptions.includeStoredUserGroups + ); + }); + + it('should call apiService "setFeatureFlagUserGroupsForSession" with null options', () => { + ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); + + upgradeClient.setFeatureFlagUserGroupsForSession(null); + + expect(ApiService.prototype.setFeatureFlagUserGroupsForSession).toHaveBeenCalledWith(undefined, undefined); + }); + + it('should call apiService "setFeatureFlagUserGroupsForSession" with undefined options', () => { + ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); + + upgradeClient.setFeatureFlagUserGroupsForSession(undefined); + + expect(ApiService.prototype.setFeatureFlagUserGroupsForSession).toHaveBeenCalledWith(undefined, undefined); + }); + + it('should throw error when groupsForSession is missing', () => { + const invalidOptions = { + includeStoredUserGroups: true, + } as any; + + expect(() => { + upgradeClient.setFeatureFlagUserGroupsForSession(invalidOptions); + }).toThrow(); + }); + + it('should throw error when includeStoredUserGroups is missing', () => { + const invalidOptions = { + groupsForSession: { + school: ['testSchool1'], + }, + } as any; + + expect(() => { + upgradeClient.setFeatureFlagUserGroupsForSession(invalidOptions); + }).toThrow(); + }); + + it('should throw error when both properties are missing', () => { + const invalidOptions = {} as any; + + expect(() => { + upgradeClient.setFeatureFlagUserGroupsForSession(invalidOptions); + }).toThrow(); + }); + + it('should throw error with proper message format', () => { + const invalidOptions = { + groupsForSession: { + school: ['testSchool1'], + }, + } as any; + + expect(() => { + upgradeClient.setFeatureFlagUserGroupsForSession(invalidOptions); + }).toThrow( + /featureFlagUserGroupsForSession must contain both groupsForSession and includeStoredUserGroups properties/ + ); + }); + }); }); diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts index 25f50e7d79..c6a0c4a40f 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts @@ -9,7 +9,6 @@ import { import Assignment from '../Assignment/Assignment'; import ApiService from '../ApiService/ApiService'; import { DataService } from '../DataService/DataService'; -import { IConfigOptions } from './UpGradeClient.types'; import { v4 as uuidv4 } from 'uuid'; declare const API_VERSION: string; @@ -63,14 +62,55 @@ export default class UpgradeClient { * const options: { * token: "someToken"; * clientSessionId: "someSessionId"; + * featureFlagUserGroupsForSession: null * } * * const upgradeClient: UpgradeClient[] = new UpgradeClient(hostURL, userId, context); * const upgradeClient: UpgradeClient[] = new UpgradeClient(hostURL, userId, context, options); * ``` + * + * UPDATE: #featureFlagUserGroupsForSession + * + * ```typescript + * // required + * const hostUrl: "htts://my-hosted-upgrade-api.com"; + * const userId: "abc123"; + * const context: "my-app-context-name"; + * + * // to configure feature flag endpoint to rely on session-only groups or merge supplemental groups with stored user groups + * // see below for usage scenarios + * // note: this is optional, and if not provided, the client will use standard user lookup with stored groups only + * const options: { + * featureFlagUserGroupsForSession: { + * groupsForSession: { "classId": ["testClass"] }; + * includeStoredUserGroups: false; // true to merge with stored user groups, false to skip any stored user entirely + * } + * } + * + * const upgradeClient: UpgradeClient[] = new UpgradeClient(hostURL, userId, context); + * const upgradeClient: UpgradeClient[] = new UpgradeClient(hostURL, userId, context, options); + * ``` + * + * **Stored-user Mode** (Standard stored user lookup): + * - Omit both `groupsForSession` and `includeStoredUserGroups` parameters + * - Uses only stored user groups from the database + * - User must already have been initialized, will 404 if user does not exist + * + * **Ephemeral Mode** (Session-only groups): + * - Set `includeStoredUserGroups` to `false` and provide `groupsForSession` + * - Uses only the groups provided in the session, ignoring any stored user groups. + * - Does not require the user to be initialized (it will bypass stored user lookup) + * - Useful when complete group information is always provided at runtime. + * + * **Merged Mode** (Stored + Session groups): + * - Set `includeStoredUserGroups` to `true` and provide `groupsForSession` + * - User must already have been initialized, will 404 if user does not exist. + * - Session groups are merged with stored groups if they don't already exist for stored user. + * - Session groups are never persisted. + * - Useful for adding context-specific ephemeral groups to an existing user. */ - constructor(userId: string, hostUrl: string, context: string, options?: IConfigOptions) { + constructor(userId: string, hostUrl: string, context: string, options?: UpGradeClientInterfaces.IConfigOptions) { const config: UpGradeClientInterfaces.IConfig = { apiVersion: 'v' + API_VERSION, userId: userId, @@ -79,10 +119,67 @@ export default class UpgradeClient { clientSessionId: options?.clientSessionId || uuidv4(), token: options?.token, httpClient: options?.httpClient, + featureFlagUserGroupsForSession: options?.featureFlagUserGroupsForSession ?? null, }; this.dataService = new DataService(); this.apiService = new ApiService(config, this.dataService); + this.validateFeatureFlagGroupOptions(config.featureFlagUserGroupsForSession); + } + + private validateFeatureFlagGroupOptions( + options: UpGradeClientInterfaces.IFeatureFlagOptions | null | undefined + ): void { + if (options && (!options.groupsForSession || options.includeStoredUserGroups === undefined)) { + throw new Error( + `${JSON.stringify( + options + )} featureFlagUserGroupsForSession must contain both groupsForSession and includeStoredUserGroups properties.` + ); + } + } + + /** + * Sets the feature flag session user group options. + * + * Note: This is a convenience method, this can also be set directly in the constructor of UpgradeClient. + * See example usage in the constructor documentation. + * + * @example + * ```typescript + * + * **Scenario 1: Session-only groups (Ephemeral user request)** + * const options: UpGradeClientInterfaces.IFeatureFlagOptions = { + * groupsForSession: { classId: ['testClass'] }, + * includeStoredUserGroups: false + * }; + * ``` + * + * **Scenario 2: Merged groups (Merged stored/ephemeral groups request mode)** + * ```typescript + * const options: UpGradeClientInterfaces.IFeatureFlagOptions = { + * groupsForSession: { classId: ['testClass'] }, + * includeStoredUserGroups: true + * }; + * ``` + * + * **Scenario 3: Default behavior (Standard mode)** + * Note this is the default behavior and does not need to be set, unless clearing previously set groupsForSession options + + * ```typescript + * const options: UpGradeClientInterfaces.IFeatureFlagOptions = null; + * ``` + */ + + public setFeatureFlagUserGroupsForSession( + featureFlagOptions: UpGradeClientInterfaces.IFeatureFlagOptions | null | undefined + ): void { + this.validateFeatureFlagGroupOptions(featureFlagOptions); + + this.apiService.setFeatureFlagUserGroupsForSession( + featureFlagOptions?.groupsForSession, + featureFlagOptions?.includeStoredUserGroups + ); } /** @@ -346,6 +443,9 @@ export default class UpgradeClient { * const featureFlags = await upgradeClient.getAllFeatureFlags(); * console.log(featureFlags); // ['feature1', 'feature2', 'feature3'] * ``` + * + * NOTE: See `#featureFlagUserGroupsForSession` option explanation in the constructor of UpgradeClient + * to see configurations that may affect the responses to this method */ async getAllFeatureFlags(): Promise { @@ -365,6 +465,9 @@ export default class UpgradeClient { * const isFeatureEnabled = await upgradeClient.hasFeatureFlag('feature1'); * console.log(isFeatureEnabled); // true or false * ``` + * + * NOTE: See `#featureFlagUserGroupsForSession` option explanation in the constructor of UpgradeClient + * to see configurations that may affect the responses to this method */ public async hasFeatureFlag(key: string): Promise { if (this.dataService.getFeatureFlags() == null) { diff --git a/clientlibs/js/src/types/Interfaces.ts b/clientlibs/js/src/types/Interfaces.ts index f2ca5a9ebc..8b2f5b024d 100644 --- a/clientlibs/js/src/types/Interfaces.ts +++ b/clientlibs/js/src/types/Interfaces.ts @@ -2,6 +2,8 @@ import { IMetricMetaData, MARKED_DECISION_POINT_STATUS } from 'upgrade_types'; export namespace UpGradeClientInterfaces { + // this namespace should be for consumer facing interface + // IConfig is really only internally used and could be confusing to consumers, should be moved in a future major version update export interface IConfig { hostURL: string; userId: string; @@ -10,7 +12,21 @@ export namespace UpGradeClientInterfaces { clientSessionId?: string; token?: string; httpClient?: UpGradeClientInterfaces.IHttpClientWrapper; + featureFlagUserGroupsForSession: IFeatureFlagOptions | null; } + + export interface IConfigOptions { + token?: string; + clientSessionId?: string; + httpClient?: UpGradeClientInterfaces.IHttpClientWrapper; + featureFlagUserGroupsForSession?: IFeatureFlagOptions | null; + } + + export interface IFeatureFlagOptions { + groupsForSession: Record; + includeStoredUserGroups: boolean; + } + export interface IResponse { status: boolean; data?: any; diff --git a/clientlibs/js/src/types/requests.ts b/clientlibs/js/src/types/requests.ts index 3eabc48faa..01015852d7 100644 --- a/clientlibs/js/src/types/requests.ts +++ b/clientlibs/js/src/types/requests.ts @@ -24,9 +24,15 @@ export namespace UpGradeClientRequests { context: string; } - export interface IGetAllFeatureFlagsRequestBody { - context: string; - } + export type IGetAllFeatureFlagsRequestBody = + | { + context: string; + } + | { + context: string; + groupsForSession: Record; + includeStoredUserGroups: boolean; + }; export interface IMarkDecisionPointRequestBody { status: MARKED_DECISION_POINT_STATUS; From ee23da5704320195fb429b2cd29488f8f662f29e Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:31:38 -0400 Subject: [PATCH 10/11] mooclet configs on for staging (#2565) --- cloudformation/backend/app-infrastructure.yml | 8 ++++++++ .../projects/upgrade/src/environments/environment.qa.ts | 2 +- .../upgrade/src/environments/environment.staging.ts | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cloudformation/backend/app-infrastructure.yml b/cloudformation/backend/app-infrastructure.yml index 7a10f349c7..377e1d6595 100644 --- a/cloudformation/backend/app-infrastructure.yml +++ b/cloudformation/backend/app-infrastructure.yml @@ -206,6 +206,12 @@ Resources: Value: '{{resolve:ssm:UPGRADE_METRICS}}' - Name: MIDDLEWARES Value: '{{resolve:ssm:UPGRADE_MIDDLEWARES}}' + - Name: MOOCLETS_ENABLED + Value: '{{resolve:ssm:MOOCLETS_ENABLED}}' + - Name: MOOCLETS_HOST_URL + Value: '{{resolve:ssm:MOOCLETS_HOST_URL}}' + - Name: MOOCLETS_API_ROUTE + Value: '{{resolve:ssm:MOOCLETS_API_ROUTE}}' Secrets: - Name: NEW_RELIC_LICENSE_KEY ValueFrom: !Sub arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/NEW_RELIC_LICENSE_KEY @@ -227,6 +233,8 @@ Resources: ValueFrom: !Sub arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/UPGRADE_SWAGGER_USERNAME - Name: TOKEN_SECRET_KEY ValueFrom: !Sub arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/UPGRADE_TOKEN_SECRET_KEY + - Name: MOOCLETS_API_TOKEN + ValueFrom: !Sub arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/MOOCLETS_API_TOKEN LogConfiguration: LogDriver: awsfirelens MemoryReservation: 256 diff --git a/frontend/projects/upgrade/src/environments/environment.qa.ts b/frontend/projects/upgrade/src/environments/environment.qa.ts index 39754b3cdc..179fea6d37 100644 --- a/frontend/projects/upgrade/src/environments/environment.qa.ts +++ b/frontend/projects/upgrade/src/environments/environment.qa.ts @@ -17,7 +17,7 @@ export const environment: Environment = { withinSubjectExperimentSupportToggle: true, errorLogsToggle: false, metricAnalyticsExperimentDisplayToggle: true, - moocletToggle: false, + moocletToggle: true, api: { getAllExperiments: '/experiments/paginated', createNewExperiments: '/experiments', diff --git a/frontend/projects/upgrade/src/environments/environment.staging.ts b/frontend/projects/upgrade/src/environments/environment.staging.ts index 564239c643..175d51d3ea 100644 --- a/frontend/projects/upgrade/src/environments/environment.staging.ts +++ b/frontend/projects/upgrade/src/environments/environment.staging.ts @@ -17,7 +17,7 @@ export const environment: Environment = { withinSubjectExperimentSupportToggle: false, errorLogsToggle: false, metricAnalyticsExperimentDisplayToggle: true, - moocletToggle: false, + moocletToggle: true, api: { getAllExperiments: '/experiments/paginated', createNewExperiments: '/experiments', From e99c2a83e46ed3eb9b827281f25831262f7d5667 Mon Sep 17 00:00:00 2001 From: doswalt Date: Tue, 2 Sep 2025 16:46:49 -0400 Subject: [PATCH 11/11] recommit --- clientlibs/java/README.md | 14 +++++++------- clientlibs/java/pom.xml | 2 +- clientlibs/js/package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/clientlibs/java/README.md b/clientlibs/java/README.md index 052c0e0861..5b6115088f 100644 --- a/clientlibs/java/README.md +++ b/clientlibs/java/README.md @@ -1,7 +1,7 @@ # educational-experiment-service-java-client-libs -Make a new instance of ExperimentClient class by passing `userId, authToken, baseUrl`. -> ExperimentClient experimentClient = new ExperimentClient(userId , authToken , baseUrl); +Make a new instance of ExperimentClient class by passing `userId, context, authToken, baseUrl`. +> ExperimentClient experimentClient = new ExperimentClient(userId , context, authToken , baseUrl); # Functions @@ -36,14 +36,14 @@ Updates/Set the working group of the initialized user ## getAllExperimentCondition Get all the experiment assignments for the initialized user -> getAllExperimentCondition(String context, callback) +> getAllExperimentCondition(callback) ## getExperimentCondition Returns the Experiment Condition for the partition and point received from the getAllExperimentConditions for the initialized user -> getExperimentCondition(String context, String experimentPoint, callback) +> getExperimentCondition(String experimentPoint, callback) -> getExperimentCondition(String context, String experimentPoint, String experimentId, callback) +> getExperimentCondition(String experimentPoint, String experimentId, callback) ## markExperimentPoint Calls markExperimentPoint for experiment point and partitionId. It will use the user definition from initialized user @@ -91,9 +91,9 @@ getFeatureFlag(String key, callback) String baseUrl = "http://upgrade-development.us-east-1.elasticbeanstalk.com/"; String userId = "user1"; - ExperimentClient experimentClient = new ExperimentClient( userId , authToken , baseUrl); + ExperimentClient experimentClient = new ExperimentClient( userId , appContext, authToken , baseUrl); - experimentClient.getExperimentCondition("appContext", "Workspace1", new ResponseCallback() { + experimentClient.getExperimentCondition("Workspace1", new ResponseCallback() { @Override public void onSuccess(@NonNull GetExperimentCondition t) { diff --git a/clientlibs/java/pom.xml b/clientlibs/java/pom.xml index 4cd8c7291d..e7114a3526 100644 --- a/clientlibs/java/pom.xml +++ b/clientlibs/java/pom.xml @@ -9,7 +9,7 @@ at the same time that happen to rev to the same new version will be caught by a merge conflict. --> - 6.1.1 + 6.1.2 diff --git a/clientlibs/js/package.json b/clientlibs/js/package.json index 47cefc0967..d0de21768a 100644 --- a/clientlibs/js/package.json +++ b/clientlibs/js/package.json @@ -1,6 +1,6 @@ { "name": "upgrade_client_lib", - "version": "6.1.1", + "version": "6.1.2", "description": "Client library to communicate with the Upgrade server", "files": [ "dist/*"