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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 151 additions & 1 deletion backend/packages/Upgrade/src/api/controllers/ExperimentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
QueryParams,
Params,
BadRequestError,
Res,
} from 'routing-controllers';
import { Experiment } from '../models/Experiment';
import { ExperimentNotFoundError } from '../errors/ExperimentNotFoundError';
Expand All @@ -27,9 +28,10 @@ import { ExperimentDTO, ExperimentFile, ValidatedExperimentError } from '../DTO/
import { ExperimentIds } from './validators/ExperimentIdsValidator';
import { MoocletExperimentService } from '../services/MoocletExperimentService';
import { env } from '../../env';
import { Response } from 'express';
import { NotFoundException } from '@nestjs/common/exceptions';
import { ExperimentIdValidator } from '../DTO/ExperimentDTO';
import { LIST_FILTER_MODE, SERVER_ERROR, SUPPORTED_MOOCLET_ALGORITHMS } from 'upgrade_types';
import { IImportError, LIST_FILTER_MODE, SERVER_ERROR, SUPPORTED_MOOCLET_ALGORITHMS } from 'upgrade_types';
import { ImportExportService } from '../services/ImportExportService';
import { ExperimentSegmentInclusion } from '../models/ExperimentSegmentInclusion';
import { SegmentInputValidator } from '../controllers/validators/SegmentInputValidator';
Expand All @@ -46,6 +48,12 @@ interface ExperimentListValidator {
experimentId: string;
}

interface ExperimentListImportValidation {
files: ExperimentFile[];
experimentId: string;
filterType: LIST_FILTER_MODE;
}

/**
* @swagger
* definitions:
Expand Down Expand Up @@ -1765,4 +1773,146 @@ export class ExperimentController {
): Promise<Segment> {
return this.experimentService.deleteList(id, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger);
}

/**
* @swagger
* /experiments/lists/import:
* post:
* description: Importing Experiment List
* consumes:
* - application/json
* parameters:
* - in: body
* name: lists
* description: Import Experiment List Files
* required: true
* schema:
* type: object
* $ref: '#/definitions/ExperimentListImportObject'
* tags:
* - Experiment Lists
* produces:
* - application/json
* responses:
* '200':
* description: New Experiment list is imported
* '401':
* description: AuthorizationRequiredError
* '500':
* description: Internal Server Error
*/
@Post('/lists/import')
public async importExperimentLists(
@Body({ validate: true }) lists: ExperimentListImportValidation,
@CurrentUser() currentUser: UserDTO,
@Req() request: AppRequest
): Promise<IImportError[]> {
return await this.experimentService.importExperimentLists(
lists.files,
lists.experimentId,
lists.filterType,
currentUser,
request.logger
);
}

/**
* @swagger
* /experiments/export/includeLists/{id}:
* get:
* description: Export All Include lists of Experiment JSON
* tags:
* - Experiments
* produces:
* - application/json
* parameters:
* - in: path
* id: Id
* description: Experiment Id
* required: true
* schema:
* type: string
* responses:
* '200':
* description: Get Experiment's All Include Lists JSON
* '401':
* description: Authorization Required Error
* '404':
* description: Experiment not found
* '400':
* description: id must be a UUID
* '500':
* description: Internal Server Error
*/
@Get('/export/includeLists/:id')
public async exportAllIncludeLists(
@Params({ validate: true }) { id }: IdValidator,
@Req() request: AppRequest,
@Res() response: Response
): Promise<SegmentInputValidator[]> {
const lists = await this.experimentService.exportAllLists(id, LIST_FILTER_MODE.INCLUSION, request.logger);
if (lists?.length) {
// download JSON file with appropriate headers to response body;
if (lists.length === 1) {
response.setHeader('Content-Disposition', `attachment; filename="${lists[0].name}.json"`);
} else {
response.setHeader('Content-Disposition', `attachment; filename="lists.zip"`);
}
response.setHeader('Content-Type', 'application/json');
} else {
throw new NotFoundException('Include lists not found.');
}

return lists;
}

/**
* @swagger
* /experiments/export/excludeLists/{id}:
* get:
* description: Export All Exclude lists of Experiment JSON
* tags:
* - Experiments
* produces:
* - application/json
* parameters:
* - in: path
* flagId: Id
* description: Experiment Id
* required: true
* schema:
* type: string
* responses:
* '200':
* description: Get Experiment's All Exclude Lists JSON
* '401':
* description: Authorization Required Error
* '404':
* description: Experiment not found
* '400':
* description: id must be a UUID
* '500':
* description: Internal Server Error
*/
@Get('/export/excludeLists/:id')
public async exportAllExcludeLists(
@Params({ validate: true }) { id }: IdValidator,
@Req() request: AppRequest,
@Res() response: Response
): Promise<SegmentInputValidator[]> {
const lists = await this.experimentService.exportAllLists(id, LIST_FILTER_MODE.EXCLUSION, request.logger);
if (lists?.length) {
// download JSON file with appropriate headers to response body;
if (lists.length === 1) {
response.setHeader('Content-Disposition', `attachment; filename="${lists[0].name}.json"`);
} else {
response.setHeader('Content-Disposition', `attachment; filename="lists.zip"`);
}
response.setHeader('Content-Type', 'application/json');
} else {
throw new NotFoundException('Exclude lists not found.');
}

return lists;
}
}
110 changes: 110 additions & 0 deletions backend/packages/Upgrade/src/api/services/ExperimentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
FILTER_MODE,
LIST_FILTER_MODE,
EXPERIMENT_LIST_OPERATION,
IImportError,
IMPORT_COMPATIBILITY_TYPE,
} from 'upgrade_types';
import { IndividualExclusionRepository } from '../repositories/IndividualExclusionRepository';
import { GroupExclusionRepository } from '../repositories/GroupExclusionRepository';
Expand Down Expand Up @@ -92,6 +94,7 @@ import { MoocletRewardsService } from './MoocletRewardsService';
import { MoocletExperimentRefRepository } from '../repositories/MoocletExperimentRefRepository';
import { ExperimentAuditLog } from '../models/ExperimentAuditLog';
import { SegmentRepository } from '../repositories/SegmentRepository';
import { NotFoundException } from '@nestjs/common/exceptions';

const errorRemovePart = 'instance of ExperimentDTO has failed the validation:\n - ';
const stratificationErrorMessage =
Expand Down Expand Up @@ -2049,6 +2052,113 @@ export class ExperimentService {
});
}

public async importExperimentLists(
experimentListFiles: ExperimentFile[],
experimentId: string,
filterType: LIST_FILTER_MODE,
currentUser: UserDTO,
logger: UpgradeLogger
): Promise<IImportError[]> {
logger.info({ message: 'Import experiment lists' });
const validatedExperiments = await this.segmentService.checkSegmentsValidity(experimentListFiles, true);
const fileStatusArray = experimentListFiles.map((file) => {
const validation = validatedExperiments.importErrors.find((error) => error.fileName === file.fileName);
const isCompatible = validation && validation.compatibilityType !== IMPORT_COMPATIBILITY_TYPE.INCOMPATIBLE;

return {
fileName: file.fileName,
error: isCompatible ? validation.compatibilityType : IMPORT_COMPATIBILITY_TYPE.INCOMPATIBLE,
};
});

const validFiles: any[] = fileStatusArray
.filter((fileStatus) => fileStatus.error !== IMPORT_COMPATIBILITY_TYPE.INCOMPATIBLE)
.map((fileStatus) => {
const experimentListFile = experimentListFiles.find((file) => file.fileName === fileStatus.fileName);
return this.segmentService.convertJSONStringToSegInputValFormat(experimentListFile.fileContent as string);
});
const experiment = await this.findOne(experimentId, logger);

const createdLists: (ExperimentSegmentInclusion | ExperimentSegmentExclusion)[] = await this.dataSource.transaction(
async (transactionalEntityManager) => {
const listDocs: (ExperimentSegmentInclusion | ExperimentSegmentExclusion)[] = [];
for (const list of validFiles) {
const listDoc: SegmentInputValidator = { ...list, id: uuid(), context: experiment.context[0] };
const createdList = await this.addList(
listDoc,
experimentId,
filterType,
currentUser,
logger,
transactionalEntityManager
);

listDocs.push(createdList);
}

return listDocs;
}
);

logger.info({ message: 'Imported experiment lists', details: createdLists });

fileStatusArray.forEach((fileStatus) => {
if (fileStatus.error !== IMPORT_COMPATIBILITY_TYPE.INCOMPATIBLE) {
fileStatus.error = null;
}
});
return fileStatusArray;
}

public async exportAllLists(
id: string,
filterType: LIST_FILTER_MODE,
logger: UpgradeLogger
): Promise<SegmentInputValidator[] | null> {
const experiment = await this.findOne(id, logger);
let listsArray: SegmentInputValidator[] = [];
if (experiment) {
let lists: (ExperimentSegmentInclusion | ExperimentSegmentExclusion)[] = [];
if (filterType === LIST_FILTER_MODE.INCLUSION) {
lists = experiment.experimentSegmentInclusion;
} else if (filterType === LIST_FILTER_MODE.EXCLUSION) {
lists = experiment.experimentSegmentExclusion;
} else {
return null;
}

if (!lists.length) return [];

listsArray = lists.map((list) => {
const { name, description, context, type, listType } = list.segment;

const userIds = list.segment.individualForSegment.map((individual) => individual.userId);

const subSegmentIds = list.segment.subSegments.map((subSegment) => subSegment.id);

const groups = list.segment.groupForSegment.map((group) => {
return { type: group.type, groupId: group.groupId };
});

const listDoc: SegmentInputValidator = {
name,
description,
context,
type,
userIds,
subSegmentIds,
groups,
listType,
};
return listDoc;
});
} else {
throw new NotFoundException('Experiment not found.');
}

return listsArray;
}

private async addFactorialDataInDB(
factors: FactorValidator[],
conditions: ConditionValidator[],
Expand Down
6 changes: 3 additions & 3 deletions backend/packages/Upgrade/src/api/services/SegmentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ export class SegmentService {
return validatedSegments;
}

convertJSONStringToSegInputValFormat(segmentDetails: string): SegmentInputValidator {
public convertJSONStringToSegInputValFormat(segmentDetails: string): SegmentInputValidator {
let segmentInfo;
try {
segmentInfo = JSON.parse(segmentDetails);
Expand Down Expand Up @@ -673,7 +673,7 @@ export class SegmentService {
segmentsData.flatMap((segmentData) =>
[segmentData.segment.id].concat([
...segmentData.segment.subSegmentIds,
...segmentData.segment.subSegments.flatMap((subSegment) =>
...(segmentData.segment.subSegments || []).flatMap((subSegment) =>
[subSegment.id].concat(subSegment.subSegments?.map((subSubSegment) => subSubSegment.id))
),
])
Expand Down Expand Up @@ -705,7 +705,7 @@ export class SegmentService {
' not found. Please import subSegment with same context and link in segment.';
compatibilityType = IMPORT_COMPATIBILITY_TYPE.WARNING;
}
if (segment.subSegments.some((subSegment) => subSegment.type === SEGMENT_TYPE.PRIVATE)) {
if (segment.subSegments?.some((subSegment) => subSegment.type === SEGMENT_TYPE.PRIVATE)) {
const subErrors = await Promise.all(
segment.subSegments.map(async (subSegment) => {
const subErrors = await collectErrors(
Expand Down
Loading