Эх сурвалжийг харах

Merge pull request #4346 from overleaf/ab-configurable-split-test-2

Configurable Split Tests

GitOrigin-RevId: e648a77848ddb8b8b55a95887f87cf7cdd300ee9
Alexandre Bourdin 5 жил өмнө
parent
commit
51546b29c4

+ 25 - 0
services/web/app/src/Features/SplitTests/SplitTestCache.js

@@ -0,0 +1,25 @@
+const SplitTestManager = require('./SplitTestManager')
+const { SplitTest } = require('../../models/SplitTest')
+const { CacheLoader } = require('cache-flow')
+
+class SplitTestCache extends CacheLoader {
+  constructor() {
+    super('split-test', {
+      expirationTime: 60, // 1min in seconds
+    })
+  }
+
+  async load(name) {
+    return await SplitTestManager.getSplitTestByName(name)
+  }
+
+  serialize(value) {
+    return value.toObject()
+  }
+
+  deserialize(value) {
+    return new SplitTest(value)
+  }
+}
+
+module.exports = new SplitTestCache()

+ 6 - 0
services/web/app/src/Features/SplitTests/SplitTestHandler.js

@@ -114,8 +114,14 @@ function _getPercentile(userId, splitTestId) {
 }
 
 module.exports = {
+  /**
+   * @deprecated: use SplitTestV2Handler.getAssignment instead
+   */
   getTestSegmentation: callbackify(getTestSegmentation),
   promises: {
+    /**
+     * @deprecated: use SplitTestV2Handler.promises.getAssignment instead
+     */
     getTestSegmentation,
   },
 }

+ 230 - 0
services/web/app/src/Features/SplitTests/SplitTestManager.js

@@ -0,0 +1,230 @@
+const { SplitTest } = require('../../models/SplitTest')
+const OError = require('@overleaf/o-error')
+const _ = require('lodash')
+
+const ALPHA_PHASE = 'alpha'
+const BETA_PHASE = 'beta'
+const RELEASE_PHASE = 'release'
+
+async function getSplitTests() {
+  try {
+    return await SplitTest.find().exec()
+  } catch (error) {
+    throw OError.tag(error, 'Failed to get split tests list')
+  }
+}
+
+async function getSplitTestByName(name) {
+  try {
+    return await SplitTest.findOne({ name }).exec()
+  } catch (error) {
+    throw OError.tag(error, 'Failed to get split test', { name })
+  }
+}
+
+async function createSplitTest(name, configuration) {
+  const stripedVariants = []
+  let stripeStart = 0
+  _checkNewVariantsConfiguration([], configuration.variants)
+  for (const variant of configuration.variants) {
+    stripedVariants.push({
+      name: variant.name,
+      active: variant.active,
+      rolloutPercent: variant.rolloutPercent,
+      rolloutStripes: [
+        {
+          start: stripeStart,
+          end: stripeStart + variant.rolloutPercent,
+        },
+      ],
+    })
+    stripeStart += variant.rolloutPercent
+  }
+  const splitTest = new SplitTest({
+    name,
+    versions: [
+      {
+        versionNumber: 1,
+        phase: configuration.phase,
+        active: configuration.active,
+        variants: stripedVariants,
+      },
+    ],
+  })
+  return _saveSplitTest(splitTest)
+}
+
+async function updateSplitTest(name, configuration) {
+  const splitTest = await getSplitTestByName(name)
+  if (splitTest) {
+    const lastVersion = splitTest.getCurrentVersion().toObject()
+    if (configuration.phase !== lastVersion.phase) {
+      throw new OError(
+        `Cannot update with different phase - use switchToNextPhase endpoint instead`
+      )
+    }
+    _checkNewVariantsConfiguration(lastVersion.variants, configuration.variants)
+    const updatedVariants = _updateVariantsWithNewConfiguration(
+      lastVersion.variants,
+      configuration.variants
+    )
+    splitTest.versions.push({
+      versionNumber: lastVersion.versionNumber + 1,
+      phase: configuration.phase,
+      active: configuration.active,
+      variants: updatedVariants,
+    })
+    return _saveSplitTest(splitTest)
+  } else {
+    throw new OError(`Cannot update split test '${name}': not found`)
+  }
+}
+
+async function switchToNextPhase(name) {
+  const splitTest = await getSplitTestByName(name)
+  if (splitTest) {
+    const lastVersionCopy = splitTest.getCurrentVersion().toObject()
+    lastVersionCopy.versionNumber++
+    if (lastVersionCopy.phase === ALPHA_PHASE) {
+      lastVersionCopy.phase = BETA_PHASE
+    } else if (lastVersionCopy.phase === BETA_PHASE) {
+      if (splitTest.forbidReleasePhase) {
+        throw new OError('Switch to release phase is disabled for this test')
+      }
+      lastVersionCopy.phase = RELEASE_PHASE
+    } else if (splitTest.phase === RELEASE_PHASE) {
+      throw new OError(
+        `Split test with ID '${name}' is already in the release phase`
+      )
+    }
+    for (const variant of lastVersionCopy.variants) {
+      variant.rolloutPercent = 0
+      variant.rolloutStripes = []
+    }
+    splitTest.versions.push(lastVersionCopy)
+    return _saveSplitTest(splitTest)
+  } else {
+    throw new OError(
+      `Cannot switch split test with ID '${name}' to next phase: not found`
+    )
+  }
+}
+
+async function revertToPreviousVersion(name, versionNumber) {
+  const splitTest = await getSplitTestByName(name)
+  if (splitTest) {
+    if (splitTest.versions.length <= 1) {
+      throw new OError(
+        `Cannot revert split test with ID '${name}' to previous version: split test must have at least 2 versions`
+      )
+    }
+    const previousVersion = splitTest.getVersion(versionNumber)
+    if (!previousVersion) {
+      throw new OError(
+        `Cannot revert split test with ID '${name}' to version number ${versionNumber}: version not found`
+      )
+    }
+    const lastVersion = splitTest.getCurrentVersion()
+    if (
+      lastVersion.phase === RELEASE_PHASE &&
+      previousVersion.phase !== RELEASE_PHASE
+    ) {
+      splitTest.forbidReleasePhase = true
+    }
+    const previousVersionCopy = previousVersion.toObject()
+    previousVersionCopy.versionNumber = lastVersion.versionNumber + 1
+    splitTest.versions.push(previousVersionCopy)
+    return _saveSplitTest(splitTest)
+  } else {
+    throw new OError(
+      `Cannot revert split test with ID '${name}' to previous version: not found`
+    )
+  }
+}
+
+function _checkNewVariantsConfiguration(variants, newVariantsConfiguration) {
+  const totalRolloutPercentage = _getTotalRolloutPercentage(
+    newVariantsConfiguration
+  )
+  if (totalRolloutPercentage > 100) {
+    throw new OError(`Total variants rollout percentage cannot exceed 100`)
+  }
+  for (const variant of variants) {
+    const newVariantConfiguration = _.find(newVariantsConfiguration, {
+      name: variant.name,
+    })
+    if (!newVariantConfiguration) {
+      throw new OError(
+        `Variant defined in previous version as ${JSON.stringify(
+          variant
+        )} cannot be removed in new configuration: either set it inactive or create a new split test`
+      )
+    }
+    if (newVariantConfiguration.rolloutPercent < variant.rolloutPercent) {
+      throw new OError(
+        `Rollout percentage for variant defined in previous version as ${JSON.stringify(
+          variant
+        )} cannot be decreased: revert to a previous configuration instead`
+      )
+    }
+  }
+}
+
+function _updateVariantsWithNewConfiguration(
+  variants,
+  newVariantsConfiguration
+) {
+  let totalRolloutPercentage = _getTotalRolloutPercentage(variants)
+  const variantsCopy = _.clone(variants)
+  for (const newVariantConfig of newVariantsConfiguration) {
+    const variant = _.find(variantsCopy, { name: newVariantConfig.name })
+    if (!variant) {
+      variantsCopy.push({
+        name: newVariantConfig.name,
+        active: newVariantConfig.active,
+        rolloutPercent: newVariantConfig.rolloutPercent,
+        rolloutStripes: [
+          {
+            start: totalRolloutPercentage,
+            end: totalRolloutPercentage + newVariantConfig.rolloutPercent,
+          },
+        ],
+      })
+      totalRolloutPercentage += newVariantConfig.rolloutPercent
+    } else if (variant.rolloutPercent < newVariantConfig.rolloutPercent) {
+      const newStripeSize =
+        newVariantConfig.rolloutPercent - variant.rolloutPercent
+      variant.active = newVariantConfig.active
+      variant.rolloutPercent = newVariantConfig.rolloutPercent
+      variant.rolloutStripes.push({
+        start: totalRolloutPercentage,
+        end: totalRolloutPercentage + newStripeSize,
+      })
+      totalRolloutPercentage += newStripeSize
+    }
+  }
+  return variantsCopy
+}
+
+function _getTotalRolloutPercentage(variants) {
+  return _.sumBy(variants, 'rolloutPercent')
+}
+
+async function _saveSplitTest(splitTest) {
+  try {
+    return (await splitTest.save()).toObject()
+  } catch (error) {
+    throw OError.tag(error, 'Failed to save split test', {
+      splitTest: JSON.stringify(splitTest),
+    })
+  }
+}
+
+module.exports = {
+  getSplitTestByName,
+  getSplitTests,
+  createSplitTest,
+  updateSplitTest,
+  switchToNextPhase,
+  revertToPreviousVersion,
+}

+ 177 - 0
services/web/app/src/Features/SplitTests/SplitTestV2Handler.js

@@ -0,0 +1,177 @@
+const UserGetter = require('../User/UserGetter')
+const UserUpdater = require('../User/UserUpdater')
+const AnalyticsManager = require('../Analytics/AnalyticsManager')
+const crypto = require('crypto')
+const _ = require('lodash')
+const { callbackify } = require('util')
+const splitTestCache = require('./SplitTestCache')
+
+const DEFAULT_VARIANT = 'default'
+const ALPHA_PHASE = 'alpha'
+const BETA_PHASE = 'beta'
+
+/**
+ * Get the assignment of a user to a split test.
+ *
+ * @example
+ * // Assign user and record an event
+ *
+ * const assignment = await SplitTestV2Handler.getAssignment(userId, 'example-project')
+ * if (assignment.variant === 'awesome-new-version') {
+ *   // execute my awesome change
+ * }
+ * else {
+ *   // execute the default behaviour (control group)
+ * }
+ * // then record an event
+ * AnalyticsManager.recordEvent(userId, 'example-project-created', {
+ *   projectId: project._id,
+ *   ...assignment.analytics.segmentation
+ * })
+ *
+ * @param userId the user's ID
+ * @param splitTestName the unique name of the split test
+ * @param options {sync: boolean} - for test purposes only, to force the synchronous update of the user's profile
+ * @returns {Promise<{analytics: {segmentation: {}}, variant: string}|{analytics: {segmentation: {phase, splitTest, variant: string, versionNumber}}, variant: string}>}
+ */
+async function getAssignment(userId, splitTestName, options) {
+  const splitTest = await splitTestCache.get(splitTestName)
+
+  if (splitTest) {
+    const currentVersion = splitTest.getCurrentVersion()
+    if (currentVersion.active) {
+      const {
+        activeForUser,
+        selectedVariantName,
+        phase,
+        versionNumber,
+      } = await _getAssignmentMetadata(userId, splitTest)
+      if (activeForUser) {
+        const assignmentConfig = {
+          userId,
+          splitTestName,
+          variantName: selectedVariantName,
+          phase,
+          versionNumber,
+        }
+        if (options && options.sync === true) {
+          await _updateVariantAssignment(assignmentConfig)
+        } else {
+          _updateVariantAssignment(assignmentConfig)
+        }
+        return {
+          variant: selectedVariantName,
+          analytics: {
+            segmentation: {
+              splitTest: splitTestName,
+              variant: selectedVariantName,
+              phase,
+              versionNumber,
+            },
+          },
+        }
+      }
+    }
+  }
+  return {
+    variant: DEFAULT_VARIANT,
+    analytics: {
+      segmentation: {},
+    },
+  }
+}
+
+async function _getAssignmentMetadata(userId, splitTest) {
+  const currentVersion = splitTest.getCurrentVersion()
+  const phase = currentVersion.phase
+  if ([ALPHA_PHASE, BETA_PHASE].includes(phase)) {
+    const user = await _getUser(userId)
+    if (
+      (phase === ALPHA_PHASE && !(user && user.alphaProgram)) ||
+      (phase === BETA_PHASE && !(user && user.betaProgram))
+    ) {
+      return {
+        activeForUser: false,
+      }
+    }
+  }
+  const percentile = _getPercentile(userId, splitTest.name, phase)
+  const selectedVariantName = _getVariantFromPercentile(
+    currentVersion.variants,
+    percentile
+  )
+  return {
+    activeForUser: true,
+    selectedVariantName: selectedVariantName || DEFAULT_VARIANT,
+    phase,
+    versionNumber: currentVersion.versionNumber,
+  }
+}
+
+function _getPercentile(userId, splitTestName, splitTestPhase) {
+  const hash = crypto
+    .createHash('md5')
+    .update(userId + splitTestName + splitTestPhase)
+    .digest('hex')
+  const hashPrefix = hash.substr(0, 8)
+  return Math.floor(
+    ((parseInt(hashPrefix, 16) % 0xffffffff) / 0xffffffff) * 100
+  )
+}
+
+function _getVariantFromPercentile(variants, percentile) {
+  for (const variant of variants) {
+    for (const stripe of variant.rolloutStripes) {
+      if (percentile >= stripe.start && percentile < stripe.end) {
+        return variant.name
+      }
+    }
+  }
+}
+
+async function _updateVariantAssignment({
+  userId,
+  splitTestName,
+  phase,
+  versionNumber,
+  variantName,
+}) {
+  const user = await _getUser(userId)
+  if (user) {
+    const assignedSplitTests = user.splitTests || []
+    const assignmentLog = assignedSplitTests[splitTestName] || []
+    const existingAssignment = _.find(assignmentLog, { versionNumber })
+    if (!existingAssignment) {
+      await UserUpdater.promises.updateUser(userId, {
+        $addToSet: {
+          [`splitTests.${splitTestName}`]: {
+            variantName,
+            versionNumber,
+            phase,
+            assignedAt: new Date(),
+          },
+        },
+      })
+      AnalyticsManager.setUserProperty(
+        userId,
+        `split-test-${splitTestName}-${versionNumber}`,
+        variantName
+      )
+    }
+  }
+}
+
+async function _getUser(id) {
+  return UserGetter.promises.getUser(id, {
+    splitTests: 1,
+    alphaProgram: 1,
+    betaProgram: 1,
+  })
+}
+
+module.exports = {
+  getAssignment: callbackify(getAssignment),
+  promises: {
+    getAssignment,
+  },
+}

+ 111 - 0
services/web/app/src/models/SplitTest.js

@@ -0,0 +1,111 @@
+const mongoose = require('../infrastructure/Mongoose')
+const { Schema } = mongoose
+const _ = require('lodash')
+
+const MIN_NAME_LENGTH = 3
+const MAX_NAME_LENGTH = 200
+const MIN_VARIANT_NAME_LENGTH = 3
+const MAX_VARIANT_NAME_LENGTH = 255
+const NAME_REGEX = /^[a-zA-Z0-9\-_]+$/
+
+const RolloutPercentType = {
+  type: Number,
+  default: 0,
+  min: [0, 'Rollout percentage must be between 0 and 100, got {VALUE}'],
+  max: [100, 'Rollout percentage must be between 0 and 100, got {VALUE}'],
+  required: true,
+}
+
+const VariantSchema = new Schema(
+  {
+    name: {
+      type: String,
+      minLength: MIN_VARIANT_NAME_LENGTH,
+      maxLength: MAX_VARIANT_NAME_LENGTH,
+      required: true,
+      validate: {
+        validator: function (input) {
+          return input !== null && input !== 'default' && NAME_REGEX.test(input)
+        },
+        message: `invalid, cannot be 'default' and must match: ${NAME_REGEX}, got {VALUE}`,
+      },
+    },
+    active: {
+      type: Boolean,
+      default: true,
+      required: true,
+    },
+    rolloutPercent: RolloutPercentType,
+    rolloutStripes: [
+      {
+        start: RolloutPercentType,
+        end: RolloutPercentType,
+      },
+    ],
+  },
+  { _id: false }
+)
+
+const VersionSchema = new Schema(
+  {
+    versionNumber: {
+      type: Number,
+      default: 1,
+      min: [1, 'must be 1 or higher, got {VALUE}'],
+      required: true,
+    },
+    phase: {
+      type: String,
+      default: 'alpha',
+      enum: ['alpha', 'beta', 'release'],
+      required: true,
+    },
+    active: {
+      type: Boolean,
+      default: true,
+      required: true,
+    },
+    variants: [VariantSchema],
+  },
+  { _id: false }
+)
+
+const SplitTestSchema = new Schema({
+  name: {
+    type: String,
+    minLength: MIN_NAME_LENGTH,
+    maxlength: MAX_NAME_LENGTH,
+    required: true,
+    unique: true,
+    validate: {
+      validator: function (input) {
+        return input !== null && NAME_REGEX.test(input)
+      },
+      message: `invalid, must match: ${NAME_REGEX}`,
+    },
+  },
+  versions: [VersionSchema],
+  forbidReleasePhase: {
+    type: Boolean,
+    required: false,
+  },
+})
+
+SplitTestSchema.methods.getCurrentVersion = function () {
+  if (this.versions && this.versions.length > 0) {
+    return _.maxBy(this.versions, 'versionNumber')
+  } else {
+    return undefined
+  }
+}
+
+SplitTestSchema.methods.getVersion = function (versionNumber) {
+  return _.find(this.versions || [], {
+    versionNumber,
+  })
+}
+
+module.exports = {
+  SplitTest: mongoose.model('SplitTest', SplitTestSchema),
+  SplitTestSchema,
+}

+ 246 - 3
services/web/package-lock.json

@@ -14856,6 +14856,74 @@
         }
       }
     },
+    "cache-flow": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/cache-flow/-/cache-flow-1.7.4.tgz",
+      "integrity": "sha512-EbU+Gcqddasv1hkUtqMTcEjP5HF+Gu7o0qDikaahsYwnu8ysusJfGa1GtUM/rfimFY7QrOO3p5sIFALrztyt0w==",
+      "requires": {
+        "cluster": "^0.7.7",
+        "date-fns": "^2.16.1",
+        "ioredis": "^4.19.2",
+        "lru-cache-for-clusters-as-promised": "^1.5.24",
+        "object-hash": "^2.0.3",
+        "typescript": "^4.0.5"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.2",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+          "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ioredis": {
+          "version": "4.27.6",
+          "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.6.tgz",
+          "integrity": "sha512-6W3ZHMbpCa8ByMyC1LJGOi7P2WiOKP9B3resoZOVLDhi+6dDBOW+KNsRq3yI36Hmnb2sifCxHX+YSarTeXh48A==",
+          "requires": {
+            "cluster-key-slot": "^1.1.0",
+            "debug": "^4.3.1",
+            "denque": "^1.1.0",
+            "lodash.defaults": "^4.2.0",
+            "lodash.flatten": "^4.4.0",
+            "p-map": "^2.1.0",
+            "redis-commands": "1.7.0",
+            "redis-errors": "^1.2.0",
+            "redis-parser": "^3.0.0",
+            "standard-as-callback": "^2.1.0"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        },
+        "p-map": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+          "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="
+        },
+        "redis-commands": {
+          "version": "1.7.0",
+          "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
+          "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
+        },
+        "redis-parser": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+          "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
+          "requires": {
+            "redis-errors": "^1.0.0"
+          }
+        },
+        "standard-as-callback": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+          "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
+        }
+      }
+    },
     "cacheable-request": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
@@ -15049,6 +15117,15 @@
         "check-error": "^1.0.2"
       }
     },
+    "chai-exclude": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/chai-exclude/-/chai-exclude-2.0.3.tgz",
+      "integrity": "sha512-6VuTQX25rsh4hKPdLzsOtL20k9+tszksLQrLtsu6szTmSVJP9+gUkqYUsyM+xqCeGZKeRJCsamCMRUQJhWsQ+g==",
+      "dev": true,
+      "requires": {
+        "fclone": "^1.0.11"
+      }
+    },
     "chaid": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/chaid/-/chaid-1.0.2.tgz",
@@ -15467,6 +15544,15 @@
         }
       }
     },
+    "cluster": {
+      "version": "0.7.7",
+      "resolved": "https://registry.npmjs.org/cluster/-/cluster-0.7.7.tgz",
+      "integrity": "sha1-5JfiZ8yVa9CwUTrbSqOTNX0Ahe8=",
+      "requires": {
+        "log": ">= 1.2.0",
+        "mkdirp": ">= 0.0.1"
+      }
+    },
     "cluster-key-slot": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
@@ -16405,6 +16491,14 @@
         }
       }
     },
+    "cron": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz",
+      "integrity": "sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==",
+      "requires": {
+        "moment-timezone": "^0.5.x"
+      }
+    },
     "cron-parser": {
       "version": "2.16.3",
       "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-2.16.3.tgz",
@@ -16878,6 +16972,15 @@
       "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==",
       "dev": true
     },
+    "d": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
+      "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
+      "requires": {
+        "es5-ext": "^0.10.50",
+        "type": "^1.0.1"
+      }
+    },
     "d3": {
       "version": "3.5.16",
       "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.16.tgz",
@@ -16886,7 +16989,7 @@
     "d64": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz",
-      "integrity": "sha512-5eNy3WZziVYnrogqgXhcdEmqcDB2IHurTqLcrgssJsfkMVCUoUaZpK6cJjxxvLV2dUm5SuJMNcYfVGoin9UIRw=="
+      "integrity": "sha1-QAKofoUMv8n52XBrYPymE6MzbpA="
     },
     "damerau-levenshtein": {
       "version": "1.0.6",
@@ -16923,6 +17026,11 @@
       "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.2.tgz",
       "integrity": "sha512-EFTCh9zRSEpGPmJaexg7HTuzZHh6cnJj1ui7IGCFNXzd2QdpsNh05Db5TF3xzJm30YN+A8/6xHSuRcQqoc3kFA=="
     },
+    "date-fns": {
+      "version": "2.22.1",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.22.1.tgz",
+      "integrity": "sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg=="
+    },
     "date-format": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz",
@@ -17693,6 +17801,15 @@
         }
       }
     },
+    "duration": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/duration/-/duration-0.2.2.tgz",
+      "integrity": "sha512-06kgtea+bGreF5eKYgI/36A6pLXggY7oR4p1pq4SmdFBn1ReOL5D8RhG64VrqfTTKNucqqtBAwEj8aB88mcqrg==",
+      "requires": {
+        "d": "1",
+        "es5-ext": "~0.10.46"
+      }
+    },
     "east": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/east/-/east-2.0.2.tgz",
@@ -18160,12 +18277,32 @@
         "is-symbol": "^1.0.1"
       }
     },
+    "es5-ext": {
+      "version": "0.10.53",
+      "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
+      "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
+      "requires": {
+        "es6-iterator": "~2.0.3",
+        "es6-symbol": "~3.1.3",
+        "next-tick": "~1.0.0"
+      }
+    },
     "es5-shim": {
       "version": "4.5.15",
       "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.5.15.tgz",
       "integrity": "sha512-FYpuxEjMeDvU4rulKqFdukQyZSTpzhg4ScQHrAosrlVpR6GFyaw14f74yn2+4BugniIS0Frpg7TvwZocU4ZMTw==",
       "dev": true
     },
+    "es6-iterator": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+      "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
+      "requires": {
+        "d": "1",
+        "es5-ext": "^0.10.35",
+        "es6-symbol": "^3.1.1"
+      }
+    },
     "es6-promise": {
       "version": "4.2.8",
       "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
@@ -18177,6 +18314,15 @@
       "integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==",
       "dev": true
     },
+    "es6-symbol": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
+      "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
+      "requires": {
+        "d": "^1.0.1",
+        "ext": "^1.1.2"
+      }
+    },
     "escalade": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -19206,6 +19352,15 @@
       "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
       "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
     },
+    "event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
+      "requires": {
+        "d": "1",
+        "es5-ext": "~0.10.14"
+      }
+    },
     "event-target-shim": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@@ -19545,6 +19700,21 @@
       "resolved": "https://registry.npmjs.org/expressionify/-/expressionify-0.9.3.tgz",
       "integrity": "sha1-/iJnx+hpRXfxP02oML/DyNgXf5I="
     },
+    "ext": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
+      "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
+      "requires": {
+        "type": "^2.0.0"
+      },
+      "dependencies": {
+        "type": {
+          "version": "2.5.0",
+          "resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz",
+          "integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw=="
+        }
+      }
+    },
     "extend": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -19791,6 +19961,12 @@
         "bser": "2.1.1"
       }
     },
+    "fclone": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz",
+      "integrity": "sha1-EOhdo4v+p/xZk0HClu4ddyZu5kA=",
+      "dev": true
+    },
     "fd-slicer": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@@ -24967,7 +25143,7 @@
     "lodash.camelcase": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
-      "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
+      "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
     },
     "lodash.debounce": {
       "version": "4.0.8",
@@ -25073,6 +25249,19 @@
       "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
       "dev": true
     },
+    "log": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/log/-/log-6.0.0.tgz",
+      "integrity": "sha512-sxChESNYJ/EcQv8C7xpmxhtTOngoXuMEqGDAkhXBEmt3MAzM3SM/TmIBOqnMEVdrOv1+VgZoYbo6U2GemQiU4g==",
+      "requires": {
+        "d": "^1.0.0",
+        "duration": "^0.2.2",
+        "es5-ext": "^0.10.49",
+        "event-emitter": "^0.3.5",
+        "sprintf-kit": "^2.0.0",
+        "type": "^1.0.1"
+      }
+    },
     "log-driver": {
       "version": "1.2.7",
       "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz",
@@ -25208,6 +25397,37 @@
         "yallist": "^4.0.0"
       }
     },
+    "lru-cache-for-clusters-as-promised": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/lru-cache-for-clusters-as-promised/-/lru-cache-for-clusters-as-promised-1.7.1.tgz",
+      "integrity": "sha512-BSfUYlSHpCrPSc20F0mhk+EDSYhTuM5AK0gvj63mtADIdZVcq3KVR1pxdmgeAEXvsV/o0qyHMKbuhVg0OHWWFg==",
+      "requires": {
+        "cron": "1.8.2",
+        "debug": "4.3.1",
+        "lru-cache": "6.0.0",
+        "uuid": "8.3.2"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        },
+        "uuid": {
+          "version": "8.3.2",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+          "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
+        }
+      }
+    },
     "lz-string": {
       "version": "1.4.4",
       "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@@ -26637,6 +26857,11 @@
       "integrity": "sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==",
       "dev": true
     },
+    "next-tick": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
+      "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
+    },
     "ngcomponent": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/ngcomponent/-/ngcomponent-4.1.0.tgz",
@@ -33791,6 +34016,14 @@
       "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
       "dev": true
     },
+    "sprintf-kit": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/sprintf-kit/-/sprintf-kit-2.0.1.tgz",
+      "integrity": "sha512-2PNlcs3j5JflQKcg4wpdqpZ+AjhQJ2OZEo34NXDtlB0tIPG84xaaXhpA8XFacFiwjKA4m49UOYG83y3hbMn/gQ==",
+      "requires": {
+        "es5-ext": "^0.10.53"
+      }
+    },
     "srcset": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz",
@@ -35805,7 +36038,7 @@
     "timed-out": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
-      "integrity": "sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA=="
+      "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8="
     },
     "timekeeper": {
       "version": "2.2.0",
@@ -36108,6 +36341,11 @@
       "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
       "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
     },
+    "type": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
+      "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
+    },
     "type-check": {
       "version": "0.3.2",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
@@ -36166,6 +36404,11 @@
         "is-typedarray": "^1.0.0"
       }
     },
+    "typescript": {
+      "version": "4.3.5",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
+      "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA=="
+    },
     "ua-parser-js": {
       "version": "0.7.21",
       "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz",

+ 2 - 0
services/web/package.json

@@ -77,6 +77,7 @@
     "bufferedstream": "1.6.0",
     "bull": "^3.18.0",
     "bunyan": "^1.8.15",
+    "cache-flow": "^1.7.4",
     "celebrate": "^10.0.1",
     "classnames": "^2.2.6",
     "codemirror": "^5.33.0",
@@ -194,6 +195,7 @@
     "c8": "^7.2.0",
     "chai": "^4.2.0",
     "chai-as-promised": "^7.1.1",
+    "chai-exclude": "^2.0.3",
     "chaid": "^1.0.2",
     "cheerio": "^1.0.0-rc.3",
     "copy-webpack-plugin": "^5.1.1",