/*!
* Convert JS SDK
* Version 1.0.0
* Copyright(c) 2020 Convert Insights, Inc
* License Apache-2.0
*/
import {DataManagerInterface} from '@convertcom/js-sdk-data';
import {FeatureManagerInterface} from './interfaces/feature-manager';
import {LogManagerInterface} from '@convertcom/js-sdk-logger';
import {
Config,
ConfigFeature,
BucketedFeature,
IdentityField,
VariableType,
ConfigExperience,
BucketingAttributes
} from '@convertcom/js-sdk-types';
import {
MESSAGES,
FeatureStatus,
RuleError,
VariationChangeType
} from '@convertcom/js-sdk-enums';
import {
castType,
arrayNotEmpty,
objectNotEmpty
} from '@convertcom/js-sdk-utils';
import {BucketedVariation} from '@convertcom/js-sdk-types';
/**
* Provides features specific logic
* @category Modules
* @constructor
* @implements {FeatureManagerInterface}
*/
export class FeatureManager implements FeatureManagerInterface {
private _dataManager: DataManagerInterface;
private _loggerManager: LogManagerInterface | null;
/**
* @param config
* @param {Object} dependencies
* @param {DataManagerInterface} dependencies.dataManager
* @param {LogManagerInterface=} dependencies.loggerManager
*/
constructor(
config: Config,
{
dataManager,
loggerManager
}: {
dataManager: DataManagerInterface;
loggerManager?: LogManagerInterface;
}
) {
this._dataManager = dataManager;
this._loggerManager = loggerManager;
this._loggerManager?.trace?.(
'FeatureManager()',
MESSAGES.FEATURE_CONSTRUCTOR
);
}
/**
* Get a list of all entities
* @return {Array<ConfigFeature>} Features list
*/
getList(): Array<ConfigFeature> {
return this._dataManager.getEntitiesList(
'features'
) as Array<ConfigFeature>;
}
/**
* Get a list of all entities as object of entities grouped by identity field
* @param {IdentityField=} field A field to group entities defaults to `id`
* @return {Record<string, ConfigFeature>} Features list
*/
getListAsObject(field: IdentityField = 'id'): Record<string, ConfigFeature> {
return this._dataManager.getEntitiesListObject('features', field) as Record<
string,
ConfigFeature
>;
}
/**
* Get the entity by key
* @param {string} key
* @return {ConfigFeature}
*/
getFeature(key: string): ConfigFeature {
return this._dataManager.getEntity(key, 'features') as ConfigFeature;
}
/**
* Get the entity by id
* @param {string} id
* @return {ConfigFeature}
*/
getFeatureById(id: string): ConfigFeature {
return this._dataManager.getEntityById(id, 'features') as ConfigFeature;
}
/**
* Get specific entities by array of keys
* @param {Array<string>} keys
* @return {Array<ConfigFeature>}
*/
getFeatures(keys: Array<string>): Array<ConfigFeature> {
return this._dataManager.getItemsByKeys(
keys,
'features'
) as Array<ConfigFeature>;
}
/**
* Get a specific variable type defined in a specific feature
* @param {string} key A feature's key
* @param {string} variableName
* @return {string|null}
*/
getFeatureVariableType(key: string, variableName: string): string {
const feature = this.getFeature(key);
if (Object.prototype.hasOwnProperty.call(feature, 'variables')) {
const variable = feature.variables.find((variable) => {
return variable.key === variableName;
});
return variable?.type || null;
}
return null;
}
/**
* Get a specific variable type defined in a specific feature by id
* @param {string} id A feature's id
* @param {string} variableName
* @return {string|null}
*/
getFeatureVariableTypeById(id: string, variableName: string): string {
const feature = this.getFeatureById(id);
if (Object.prototype.hasOwnProperty.call(feature, 'variables')) {
const variable = feature.variables.find((variable) => {
return variable.key === variableName;
});
return variable?.type || null;
}
return null;
}
/**
* Check that feature is declared
* @param {string} key ConfigFeature key
* @return {boolean}
*/
isFeatureDeclared(key: string): boolean {
const declaredFeature = this._dataManager.getEntity(
key,
'features'
) as ConfigFeature;
return !!declaredFeature;
}
/**
* Get feature and its status
* @param {string} visitorId
* @param {string} featureKey
* @param {BucketingAttributes} attributes
* @param {Record<any, any>} attributes.locationProperties
* @param {Record<any, any>} attributes.visitorProperties
* @param {boolean=} attributes.updateVisitorProperties
* @param {boolean=} attributes.typeCasting Defaults to `true`
* @param {string=} attributes.environment
* @param {Array<string>=} experienceKeys
* @return {BucketedFeature | RuleError | Array<BucketedFeature | RuleError>}
*/
runFeature(
visitorId: string,
featureKey: string,
attributes: BucketingAttributes,
experienceKeys?: Array<string>
): BucketedFeature | RuleError | Array<BucketedFeature | RuleError> {
const declaredFeature = this._dataManager.getEntity(
featureKey,
'features'
) as ConfigFeature;
if (declaredFeature) {
const features = this.runFeatures(visitorId, attributes, {
features: [featureKey],
experiences: experienceKeys
});
if (arrayNotEmpty(features)) {
if (features.length === 1) {
// Return the bucketed feature
return features[0];
} else {
// Return an array of bucketed features. It means the feature is used in different experiences and visitor has been bucketed to those variations
return features;
}
}
// Return disabled feature. Visitor was not bucketed
return {
id: declaredFeature.id,
name: declaredFeature.name,
key: featureKey,
status: FeatureStatus.DISABLED
} as BucketedFeature;
} else {
// The feature is not declared at all
return {
key: featureKey,
status: FeatureStatus.DISABLED
} as BucketedFeature;
}
}
/**
* Check is feature enabled.
* @param {string} visitorId
* @param {string} featureKey
* @param {BucketingAttributes} attributes
* @param {Record<any, any>} attributes.locationProperties
* @param {Record<any, any>} attributes.visitorProperties
* @param {string=} attributes.environment
* @param {Array<string>=} experienceKeys
* @return {boolean}
*/
isFeatureEnabled(
visitorId: string,
featureKey: string,
attributes: BucketingAttributes,
experienceKeys?: Array<string>
): boolean {
const declaredFeature = this._dataManager.getEntity(
featureKey,
'features'
) as ConfigFeature;
if (declaredFeature) {
const features = this.runFeatures(visitorId, attributes, {
features: [featureKey],
experiences: experienceKeys
});
return arrayNotEmpty(features);
}
return false;
}
/**
* Get feature and its status
* @param {string} visitorId
* @param {string} featureId
* @param {BucketingAttributes} attributes
* @param {Record<any, any>} attributes.locationProperties
* @param {Record<any, any>} attributes.visitorProperties
* @param {boolean=} attributes.updateVisitorProperties
* @param {boolean=} attributes.typeCasting Defaults to `true`
* @param {string=} attributes.environment
* @param {Array<string>=} experienceIds
* @return {BucketedFeature | Array<BucketedFeature> }
*/
runFeatureById(
visitorId: string,
featureId: string,
attributes: BucketingAttributes,
experienceIds?: Array<string>
): BucketedFeature | RuleError | Array<BucketedFeature | RuleError> {
const declaredFeature = this._dataManager.getEntityById(
featureId,
'features'
) as ConfigFeature;
if (declaredFeature) {
const features = this.runFeatures(visitorId, attributes, {
features: [declaredFeature.key],
experiences: this._dataManager
.getEntitiesByIds(experienceIds, 'experiences')
.map((e) => e.key)
});
if (arrayNotEmpty(features)) {
if (features.length === 1) {
// Return the bucketed feature
return features[0];
} else {
// Return rule errors if present
const matchedErrors = features.filter((match) =>
Object.values(RuleError).includes(match as RuleError)
);
if (matchedErrors.length) return matchedErrors as Array<RuleError>;
// Return an array of bucketed features. It means the feature is used in different experiences and visitor has been bucketed to those variations
return features;
}
}
// Return disabled feature. Visitor was not bucketed
return {
id: featureId,
name: declaredFeature.name,
key: declaredFeature.key,
status: FeatureStatus.DISABLED
} as BucketedFeature;
} else {
// The feature is not declared at all
return {
id: featureId,
status: FeatureStatus.DISABLED
} as BucketedFeature;
}
}
/**
* Get features and their statuses
* @param {string} visitorId
* @param {BucketingAttributes} attributes
* @param {Record<any, any>} attributes.locationProperties
* @param {Record<any, any>} attributes.visitorProperties
* @param {boolean=} attributes.updateVisitorProperties
* @param {boolean=} attributes.typeCasting Defaults to `true`
* @param {string=} attributes.environment
* @param {Record<string, Array<string>>=} filter Filter records by experiences and/or features keys
* @param {Array<string>} filter.experiences Array of experiences keys
* @param {Array<string>} filter.features Array of features keys
* @return {Array<BucketedFeature | RuleError>}
*/
runFeatures(
visitorId: string,
attributes: BucketingAttributes,
filter?: Record<string, Array<string>>
): Array<BucketedFeature | RuleError> {
const {typeCasting = true} = attributes;
// Get list of declared features grouped by id
const declaredFeatures = this.getListAsObject('id');
const bucketedFeatures: Array<BucketedFeature> = [];
// Retrieve all or filtered experiences
const experiences = (
filter && arrayNotEmpty(filter?.experiences)
? this._dataManager.getEntities(filter.experiences, 'experiences')
: this._dataManager.getEntitiesList('experiences')
) as Array<ConfigExperience>;
// Retrieve bucketed variations across the experiences
const bucketedVariations = experiences
.map((experience) => {
const variation = this._dataManager.getBucketing(
visitorId,
experience?.key,
attributes
);
if (Object.values(RuleError).includes(variation as RuleError))
return variation as RuleError;
return variation as BucketedVariation;
})
.filter(Boolean);
// Return rule errors if present
const matchedErrors = bucketedVariations.filter((match) =>
Object.values(RuleError).includes(match as RuleError)
);
if (matchedErrors.length) return matchedErrors as Array<RuleError>;
// Collect features from bucketed variations
for (const k in bucketedVariations) {
const bucketedVariation = bucketedVariations[k] as BucketedVariation;
for (const v in bucketedVariation?.changes || []) {
const changes = bucketedVariation?.changes?.[v]?.data;
if (
bucketedVariation?.changes?.[v]?.type !==
VariationChangeType.FULLSTACK_FEATURE
) {
this._loggerManager?.warn?.(
'FeatureManager.runFeatures()',
MESSAGES.VARIATION_CHANGE_NOT_SUPPORTED
);
continue;
}
const featureId = changes?.feature_id;
// Take the features filter into account
if (!featureId) {
this._loggerManager?.warn?.(
'FeatureManager.runFeatures()',
MESSAGES.FEATURE_NOT_FOUND
);
continue;
}
if (
(filter &&
arrayNotEmpty(filter?.features) &&
filter?.features?.indexOf(
declaredFeatures[String(featureId)]?.key
) !== -1) ||
!filter?.features
) {
const variables = changes?.variables_data;
if (!variables) {
this._loggerManager?.warn?.(
'FeatureManager.runFeatures()',
MESSAGES.FEATURE_VARIABLES_NOT_FOUND
);
}
if (typeCasting && objectNotEmpty(variables)) {
// Convert variables values types
for (const variableName in variables as object) {
const variableDefinition = declaredFeatures[
String(featureId)
]?.variables?.find((obj) => {
return obj.key === variableName;
});
if (variableDefinition?.type) {
variables[variableName] = this.castType(
variables[variableName],
variableDefinition.type
);
} else {
this._loggerManager?.warn?.(
'FeatureManager.runFeatures()',
MESSAGES.FEATURE_VARIABLES_TYPE_NOT_FOUND
);
}
}
}
// Build the bucketed feature object
const bucketedFeature = {
...{
experienceId: bucketedVariation.experienceId,
experienceName: bucketedVariation.experienceName,
experienceKey: bucketedVariation.experienceKey
},
...{
key: declaredFeatures[String(featureId)]?.key,
name: declaredFeatures[String(featureId)]?.name,
id: String(featureId),
status: FeatureStatus.ENABLED,
variables: variables
}
};
bucketedFeatures.push(bucketedFeature);
}
}
}
// Extend the list with not enabled features only if there is no features filter provided
if (!filter?.features) {
const bucketedFeaturesIds = bucketedFeatures.map((f) => f.id);
// console.log(bucketedFeaturesIds)
for (const k in declaredFeatures) {
if (bucketedFeaturesIds.indexOf(declaredFeatures[k].id) === -1) {
bucketedFeatures.push({
id: declaredFeatures[k].id,
name: declaredFeatures[k].name,
key: declaredFeatures[k].key,
status: FeatureStatus.DISABLED
} as BucketedFeature);
}
}
}
return bucketedFeatures;
}
/**
* Convert value's type
* @param value
* @param type
*/
castType(value: any, type: VariableType): any {
return castType(value, type);
}
}
Source