Skip to content

Commit 3ebfc79

Browse files
committed
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
1 parent 4a881e8 commit 3ebfc79

File tree

17 files changed

+1159
-45
lines changed

17 files changed

+1159
-45
lines changed

backend/packages/Upgrade/src/api/controllers/ExperimentClientController.v6.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { ExperimentService } from '../services/ExperimentService';
1313
import { ExperimentAssignmentService } from '../services/ExperimentAssignmentService';
1414
import { ExperimentAssignmentValidatorv6 } from './validators/ExperimentAssignmentValidator';
15+
import { FeatureFlagRequestValidator } from './validators/FeatureFlagRequestValidator';
1516
import { ExperimentUser } from '../models/ExperimentUser';
1617
import { ExperimentUserService } from '../services/ExperimentUserService';
1718
import { UpdateWorkingGroupValidatorv6 } from './validators/UpdateWorkingGroupValidator';
@@ -681,27 +682,100 @@ export class ExperimentClientController {
681682
* @swagger
682683
* /v6/featureflag:
683684
* post:
684-
* description: Get all feature flags using SDK
685+
* description: |
686+
* Get feature flags that have been assigned to the user.
687+
*
688+
* This endpoint supports three different modes of operation based on the optional parameters:
689+
*
690+
* **Stored-user Mode** (Standard stored user lookup):
691+
* - Omit both `groupsForSession` and `includeStoredUserGroups` parameters
692+
* - Uses only stored user groups from the database
693+
* - User must already have been initialized, will 404 if user does not exist
694+
*
695+
* **Ephemeral Mode** (Session-only groups):
696+
* - Set `includeStoredUserGroups` to `false` and provide `groupsForSession`
697+
* - Uses only the groups provided in the session, ignoring any stored user groups.
698+
* - Does not require the user to be initialized (it will bypass stored user lookup)
699+
* - Useful when complete group information is always provided at runtime.
700+
*
701+
* **Merged Mode** (Stored + Session groups):
702+
* - Set `includeStoredUserGroups` to `true` and provide `groupsForSession`
703+
* - User must already have been initialized, will 404 if user does not exist.
704+
* - Session groups are merged with stored groups if they don't already exist for stored user.
705+
* - Session groups are never persisted.
706+
* - Useful for adding context-specific ephemeral groups to an existing user.
707+
*
685708
* consumes:
686709
* - application/json
687710
* parameters:
711+
* - in: header
712+
* name: User-Id
713+
* required: true
714+
* schema:
715+
* type: string
716+
* example: user123
717+
* description: The unique identifier for the user
688718
* - in: body
689719
* name: user
690720
* required: true
691721
* schema:
692722
* type: object
723+
* required:
724+
* - context
693725
* properties:
694726
* context:
695727
* type: string
696-
* example: add
697-
* description: User Document
728+
* example: "test-context"
729+
* description: The context for feature flag evaluation
730+
* groupsForSession:
731+
* type: object
732+
* additionalProperties:
733+
* type: array
734+
* items:
735+
* type: string
736+
* example:
737+
* schoolId: ["temporary-school-id"]
738+
* description: Optional groups to provide for the session (not persisted)
739+
* includeStoredUserGroups:
740+
* type: boolean
741+
* description: Whether to include stored user groups in evaluation
742+
* example: false
743+
* description: Feature flag request parameters
744+
* examples:
745+
* normal_mode:
746+
* summary: Normal Mode - Standard stored user lookup
747+
* description: Uses only stored user groups from the database
748+
* value:
749+
* context: "test-context"
750+
* ephemeral_mode:
751+
* summary: Ephemeral Mode - Session-only groups
752+
* description: Uses only session groups, ignoring stored user groups
753+
* value:
754+
* context: "test-context"
755+
* groupsForSession:
756+
* schoolId: ["demo-school"]
757+
* classId: ["demo-class-advanced"]
758+
* includeStoredUserGroups: false
759+
* merged_mode:
760+
* summary: Merged Mode - Stored + Session groups
761+
* description: Combines stored user groups (if exists) with session groups
762+
* value:
763+
* context: "test-context"
764+
* groupsForSession:
765+
* classId: ["temp-class-123", "special-session"]
766+
* includeStoredUserGroups: true
698767
* produces:
699768
* - application/json
700769
* tags:
701770
* - Client Side SDK
702771
* responses:
703772
* '200':
704773
* description: Feature flags list
774+
* schema:
775+
* type: array
776+
* items:
777+
* type: string
778+
* example: ["NEW_FEATURE", "TEST_FLAG"]
705779
* '400':
706780
* description: BadRequestError - InvalidParameterValue
707781
* '401':
@@ -715,10 +789,10 @@ export class ExperimentClientController {
715789
public async getAllFlags(
716790
@Req() request: AppRequest,
717791
@Body({ validate: true })
718-
experiment: ExperimentAssignmentValidatorv6
792+
featureFlagRequest: FeatureFlagRequestValidator
719793
): Promise<string[]> {
720794
const experimentUserDoc = request.userDoc;
721-
return this.featureFlagService.getKeys(experimentUserDoc, experiment.context, request.logger);
795+
return this.featureFlagService.getKeys(experimentUserDoc, featureFlagRequest.context, request.logger);
722796
}
723797

724798
/**
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
IsNotEmpty,
3+
IsOptional,
4+
IsString,
5+
IsBoolean,
6+
IsObject,
7+
registerDecorator,
8+
ValidationOptions,
9+
ValidationArguments,
10+
} from 'class-validator';
11+
12+
export type IGetAllFeatureFlagsRequestBody =
13+
| {
14+
context: string;
15+
}
16+
| {
17+
context: string;
18+
groupsForSession: Record<string, string[]>;
19+
includeStoredUserGroups: boolean;
20+
};
21+
22+
// Custom validation decorator to ensure both session properties are provided together
23+
const BothSessionPropertiesRequired = (validationOptions?: ValidationOptions) => {
24+
const registerBothSessionPropertiesRequired = (object: object, propertyName: string) => {
25+
registerDecorator({
26+
name: 'bothSessionPropertiesRequired',
27+
target: object.constructor,
28+
propertyName: propertyName,
29+
options: validationOptions,
30+
validator: {
31+
validate(value: any, args: ValidationArguments) {
32+
const obj = args.object as any;
33+
const hasProvideGroups = obj.groupsForSession !== undefined;
34+
const hasIncludeStored = obj.includeStoredUserGroups !== undefined;
35+
36+
// Both must be provided together, or neither
37+
return (hasProvideGroups && hasIncludeStored) || (!hasProvideGroups && !hasIncludeStored);
38+
},
39+
defaultMessage() {
40+
return 'Both groupsForSession and includeStoredUserGroups must be provided together, or neither should be provided';
41+
},
42+
},
43+
});
44+
};
45+
46+
return registerBothSessionPropertiesRequired;
47+
};
48+
49+
export class FeatureFlagRequestValidator {
50+
@IsNotEmpty()
51+
@IsString()
52+
public context: string;
53+
54+
@IsOptional()
55+
@IsObject()
56+
@BothSessionPropertiesRequired()
57+
public groupsForSession?: Record<string, string[]>;
58+
59+
@IsOptional()
60+
@IsBoolean()
61+
@BothSessionPropertiesRequired()
62+
public includeStoredUserGroups?: boolean;
63+
}

backend/packages/Upgrade/src/api/middlewares/UserCheckMiddleware.ts

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SERVER_ERROR } from 'upgrade_types';
44
import { AppRequest } from '../../types';
55
import { Service } from 'typedi';
66
import { ExperimentUserService } from '../services/ExperimentUserService';
7+
import { RequestedExperimentUser } from '../controllers/validators/ExperimentUserValidator';
78

89
@Service()
910
export class UserCheckMiddleware {
@@ -22,14 +23,23 @@ export class UserCheckMiddleware {
2223
req.logger.child({ user_id });
2324
req.logger.debug({ message: 'User Id is:', user_id });
2425
}
25-
const experimentUserDoc = await this.experimentUserService.getUserDoc(user_id, req.logger);
26+
27+
let experimentUserDoc: RequestedExperimentUser;
28+
29+
if (req.url.endsWith('/v6/featureflag')) {
30+
experimentUserDoc = await this.handleProvidedGroupsForSession(req, user_id);
31+
} else {
32+
experimentUserDoc = await this.experimentUserService.getUserDoc(user_id, req.logger);
33+
}
34+
2635
if (!req.url.endsWith('/init') && !experimentUserDoc) {
2736
const error = new Error(`User not found: ${user_id}`);
2837
(error as any).type = SERVER_ERROR.EXPERIMENT_USER_NOT_DEFINED;
2938
(error as any).httpCode = 404;
3039
req.logger.error(error);
3140
return next(error);
3241
}
42+
3343
req.userDoc = experimentUserDoc;
3444
// Continue to the next middleware/controller
3545
return next();
@@ -38,4 +48,154 @@ export class UserCheckMiddleware {
3848
return next(error);
3949
}
4050
}
51+
52+
/**
53+
* Handles potential ephemeral session groups provided in the request.
54+
*
55+
* This method processes different scenarios based on the combination of
56+
* `groupsForSession` and `includeStoredUserGroups` parameters:
57+
*
58+
* Note: provided session groups are never persisted
59+
*
60+
* **Stored-user Mode** (Standard stored user lookup):
61+
* - Omit both `groupsForSession` and `includeStoredUserGroups` parameters
62+
* - Uses only stored user groups from the database
63+
* - User must already have been initialized, will 404 if user does not exist
64+
*
65+
* **Ephemeral Mode** (Session-only groups):
66+
* - Set `includeStoredUserGroups` to `false` and provide `groupsForSession`
67+
* - Uses only the groups provided in the session, ignoring any stored user groups.
68+
* - Does not require the user to be initialized (it will bypass stored user lookup)
69+
* - Useful when complete group information is always provided at runtime.
70+
*
71+
* **Merged Mode** (Stored + Session groups):
72+
* - Set `includeStoredUserGroups` to `true` and provide `groupsForSession`
73+
* - User must already have been initialized, will 404 if user does not exist.
74+
* - Session groups are merged with stored groups if they don't already exist for stored user.
75+
* - Session groups are never persisted.
76+
* - Useful for adding context-specific ephemeral groups to an existing user.
77+
*
78+
* @param req - The application request containing session group parameters
79+
* @param user_id - The user identifier for group lookup
80+
* @returns Promise resolving to RequestedExperimentUser with appropriate group configuration
81+
*
82+
* @example
83+
* ```typescript
84+
* // Scenario 1: Standard lookup (no session params)
85+
* {
86+
* context: "storedUserDependentContext",
87+
* }
88+
*
89+
* // Scenario 2: Ignore stored user and use session groups only
90+
* {
91+
* context: "independentContext",
92+
* groupsForSession: { "classId": ["testClass"], "schoolId": ["instructor"] },
93+
* includeStoredUserGroups: false
94+
* }
95+
*
96+
* // Scenario 3: Merge session and stored groups
97+
* {
98+
* context: "storedUserMergedContext",
99+
* groupsForSession: { "classId": ["testClass"], "schoolId": ["demoSchool"] },
100+
* includeStoredUserGroups: true
101+
* }
102+
*
103+
* ```
104+
*/
105+
private async handleProvidedGroupsForSession(req: AppRequest, user_id: string): Promise<RequestedExperimentUser> {
106+
// Note: validation of request will have occurred before this middleware
107+
108+
// Scenario 1: Session-only groups (ephemeral user mode)
109+
// explicitly check if includeStoredUserGroups is exactly false and not just undefined
110+
if (req.body.groupsForSession && req.body.includeStoredUserGroups === false) {
111+
const experimentUserDoc = this.createSessionUser(user_id, req.body.groupsForSession);
112+
113+
req.logger.debug({
114+
message: 'Created ephemeral user with session groups',
115+
experimentUserDoc,
116+
});
117+
118+
return experimentUserDoc;
119+
}
120+
121+
// Load stored user document, required for scenarios 2 and 3
122+
const experimentUserDoc = await this.experimentUserService.getUserDoc(user_id, req.logger);
123+
124+
if (!experimentUserDoc) {
125+
return null; // User not found, will be handled in the main middleware
126+
}
127+
128+
if (req.body.groupsForSession && req.body.includeStoredUserGroups) {
129+
// Scenario 2: Merged groups (Merged stored/ephemeral groups mode)
130+
experimentUserDoc.group = this.mergeGroupsWithUniqueValues(experimentUserDoc.group, req.body.groupsForSession);
131+
132+
req.logger.debug({
133+
message: 'Merged session groups with stored user groups',
134+
experimentUserDoc,
135+
});
136+
} else {
137+
// Scenario 3: Standard behavior (user-lookup from user-id)
138+
req.logger.debug({
139+
message: 'Using standard user lookup without session group modifications',
140+
experimentUserDoc,
141+
});
142+
}
143+
144+
return experimentUserDoc;
145+
}
146+
147+
/**
148+
* Creates a RequestedExperimentUser object for ephemeral user scenarios.
149+
*
150+
* @param user_id - The user ID for the experiment user
151+
* @param groupsForSession - The groups provided in the session
152+
* @returns RequestedExperimentUser object with session groups
153+
*/
154+
private createSessionUser(user_id: string, groupsForSession: Record<string, string[]>): RequestedExperimentUser {
155+
const sessionUser = new RequestedExperimentUser();
156+
sessionUser.id = user_id;
157+
sessionUser.requestedUserId = user_id;
158+
sessionUser.group = groupsForSession;
159+
sessionUser.workingGroup = undefined;
160+
return sessionUser;
161+
}
162+
163+
/**
164+
* Merges two group objects with unique values per key.
165+
*
166+
* This utility method combines existing user groups with incoming session groups,
167+
* ensuring no duplicate values exist within each group key's array.
168+
*
169+
* @param existing - The existing user groups (may be undefined)
170+
* @param incoming - The incoming session groups to merge
171+
* @returns A new object with merged groups containing unique values
172+
*
173+
* @example
174+
* ```typescript
175+
* const existing = { "classId": ["123", "xyz"], "schoolId": ["qwerty"] };
176+
* const incoming = { "classId": ["abc", "123"], "instructorId": ["dale123"] };
177+
* const merged = mergeGroupsWithUniqueValues(existing, incoming);
178+
* // Result: {
179+
* // "classId": ["123", "xyz", "abc"],
180+
* // "schoolId": ["qwerty"],
181+
* // "instructorId": ["dale123"]
182+
* // }
183+
* ```
184+
*/
185+
private mergeGroupsWithUniqueValues(
186+
existing: Record<string, string[]> | undefined,
187+
incoming: Record<string, string[]>
188+
): Record<string, string[]> {
189+
const result: Record<string, string[]> = { ...existing };
190+
191+
for (const [key, values] of Object.entries(incoming)) {
192+
if (result[key]) {
193+
result[key] = [...new Set([...result[key], ...values])];
194+
} else {
195+
result[key] = [...values];
196+
}
197+
}
198+
199+
return result;
200+
}
41201
}
Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
11
import { Entity, Index, ManyToOne, PrimaryColumn } from 'typeorm';
22
import { BaseModel } from './base/BaseModel';
33
import { FeatureFlag } from './FeatureFlag';
4-
import { ExperimentUser } from './ExperimentUser';
54

65
@Entity()
76
export class FeatureFlagExposure extends BaseModel {
87
// Define primary column for the foreign key
98
@PrimaryColumn()
109
featureFlagId: string;
1110

12-
// Define primary column for the foreign key
1311
@PrimaryColumn()
1412
experimentUserId: string;
1513

1614
@Index()
1715
@ManyToOne(() => FeatureFlag, { onDelete: 'CASCADE' })
1816
public featureFlag: FeatureFlag;
19-
@Index()
20-
@ManyToOne(() => ExperimentUser, { onDelete: 'CASCADE' })
21-
public experimentUser: ExperimentUser;
2217
}

0 commit comments

Comments
 (0)