Merged in feature/IO-3701-Harness-Replacement (pull request #3261)
feature/IO-3701-Harness-Replacement - Implement
This commit is contained in:
@@ -7,6 +7,7 @@ _reference
|
||||
client
|
||||
redis/dockerdata
|
||||
hasura
|
||||
harness-feature-flags-export
|
||||
node_modules
|
||||
# Files to exclude
|
||||
.ebignore
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
/client
|
||||
/firebase
|
||||
/hasura
|
||||
/harness-feature-flags-export
|
||||
/jsreport
|
||||
/node_modules
|
||||
.env.local
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,6 +17,9 @@ jsreport/auth-server/node_modules
|
||||
client/coverage
|
||||
admin/coverage
|
||||
|
||||
# Generated Harness/Split feature flag export artifacts
|
||||
/harness-feature-flags-export/
|
||||
|
||||
# production
|
||||
/build
|
||||
client/build
|
||||
@@ -153,4 +156,4 @@ docker_data
|
||||
|
||||
.terraform
|
||||
|
||||
terraform.tfvars
|
||||
terraform.tfvars
|
||||
|
||||
1297
_reference/feature-flags.md
Normal file
1297
_reference/feature-flags.md
Normal file
File diff suppressed because it is too large
Load Diff
302
client/package-lock.json
generated
302
client/package-lock.json
generated
@@ -29,7 +29,6 @@
|
||||
"@sentry/cli": "^3.3.5",
|
||||
"@sentry/react": "^10.47.0",
|
||||
"@sentry/vite-plugin": "^4.9.1",
|
||||
"@splitsoftware/splitio-react": "^2.6.1",
|
||||
"@tanem/react-nprogress": "^5.0.63",
|
||||
"antd": "^6.3.5",
|
||||
"apollo-link-logger": "^3.0.0",
|
||||
@@ -3726,12 +3725,6 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
@@ -7249,63 +7242,6 @@
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@splitsoftware/splitio": {
|
||||
"version": "11.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.9.0.tgz",
|
||||
"integrity": "sha512-1kWFgfkV1zE6Ubq8WkLVrvxsk1TF/UY2gux8A1jUdKyBwItrKJVjS10C9dspnkJxHSfRtMl2mtxJ/8vH7XjFew==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@splitsoftware/splitio-commons": "2.9.0",
|
||||
"bloom-filters": "^3.0.4",
|
||||
"ioredis": "^4.28.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"tslib": "^2.3.1",
|
||||
"unfetch": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@splitsoftware/splitio-commons": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.9.0.tgz",
|
||||
"integrity": "sha512-dfGqtiuYcWeR235NM43z3BOULTFi+hdkB1FbOHePrufWJTYBOfuBeIgPnsW3wyg+kXyGkNN49JyywZHrJtVpDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/ioredis": "^4.28.0",
|
||||
"tslib": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ioredis": "^4.28.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ioredis": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@splitsoftware/splitio-react": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@splitsoftware/splitio-react/-/splitio-react-2.6.1.tgz",
|
||||
"integrity": "sha512-9TUrvNHcN3F1VpnlT8l12OY+s/atBgpKoThixQTBP87DMlHXEtEF4/GNl+oKKdkNEymYY9lY1iR0xGatuTvi9A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@splitsoftware/splitio": "11.9.0",
|
||||
"memoize-one": "^5.1.1",
|
||||
"shallowequal": "^1.1.0",
|
||||
"tslib": "^2.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@splitsoftware/splitio-react/node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -7619,15 +7555,6 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ioredis": {
|
||||
"version": "4.28.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz",
|
||||
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -7682,12 +7609,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/seedrandom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz",
|
||||
"integrity": "sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stylis": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz",
|
||||
@@ -8182,15 +8103,6 @@
|
||||
"@apollo/client": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
@@ -8540,15 +8452,6 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -8613,25 +8516,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bloom-filters": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/bloom-filters/-/bloom-filters-3.0.4.tgz",
|
||||
"integrity": "sha512-BdnPWo2OpYhlvuP2fRzJBdioMCkm7Zp0HCf8NJgF5Mbyqy7VQ/CnTiVWMMyq4EZCBHwj0Kq6098gW2/3RsZsrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"base64-arraybuffer": "^1.0.2",
|
||||
"is-buffer": "^2.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"long": "^5.2.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"seedrandom": "^3.0.5",
|
||||
"xxhashjs": "^0.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
|
||||
@@ -9184,15 +9068,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -9559,12 +9434,6 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cuint": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz",
|
||||
"integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
@@ -9896,15 +9765,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
|
||||
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -10734,19 +10594,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
@@ -12004,32 +11851,6 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "4.30.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.30.1.tgz",
|
||||
"integrity": "sha512-17Ed70njJ7wT7JZsdTVLb0j/cmwHwfQCFu+AP6jY7nFKd+CA7MBW7nX121mM64eT8S9ekAVtYYt8nGQPmm3euA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "^1.0.2",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.1",
|
||||
"denque": "^1.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.flatten": "^4.4.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"p-map": "^2.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
@@ -12161,29 +11982,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
@@ -12720,19 +12518,6 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "28.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||
@@ -13257,24 +13042,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -14711,15 +14478,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
|
||||
"integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -15949,27 +15707,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/reduce-reducers": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reduce-reducers/-/reduce-reducers-1.0.4.tgz",
|
||||
@@ -16038,12 +15775,6 @@
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.1.14",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz",
|
||||
"integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -16545,12 +16276,6 @@
|
||||
"compute-scroll-into-view": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/seedrandom": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -17049,12 +16774,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -17062,12 +16781,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
|
||||
@@ -17982,12 +17695,6 @@
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unfetch": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
|
||||
"integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
|
||||
@@ -19481,15 +19188,6 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/xxhashjs": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz",
|
||||
"integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cuint": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"@sentry/cli": "^3.3.5",
|
||||
"@sentry/react": "^10.47.0",
|
||||
"@sentry/vite-plugin": "^4.9.1",
|
||||
"@splitsoftware/splitio-react": "^2.6.1",
|
||||
"@tanem/react-nprogress": "^5.0.63",
|
||||
"antd": "^6.3.5",
|
||||
"apollo-link-logger": "^3.0.0",
|
||||
|
||||
@@ -5593,29 +5593,6 @@ Demo: https://rawgit.com/Sphinxxxx/color-conversion/master/demo/index.html
|
||||
|
||||
-----------
|
||||
|
||||
The following NPM packages may be included in this product:
|
||||
|
||||
- @splitsoftware/splitio-commons@1.6.1
|
||||
- @splitsoftware/splitio-react@1.7.1
|
||||
|
||||
These packages each contain the following license and notice below:
|
||||
|
||||
Copyright © 2022 Split Software, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
-----------
|
||||
|
||||
The following NPM packages may be included in this product:
|
||||
|
||||
- @stripe/react-stripe-js@1.9.0
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.withProfiler(AppContainer);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { FeatureFlagProvider, useFeatureFlagClient } from "../feature-flags/splitio-react-replacement";
|
||||
import { ConfigProvider } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -16,23 +16,21 @@ import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
function FeatureFlagClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
const featureFlagClient = useFeatureFlagClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
if (import.meta.env.DEV && featureFlagClient && imexshopid) {
|
||||
console.log(`Feature flag client initialized with key: ${imexshopid}, isReady: ${featureFlagClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
}, [featureFlagClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -124,11 +122,11 @@ function AppContainer() {
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<FeatureFlagProvider config={config}>
|
||||
<FeatureFlagClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</FeatureFlagClientProvider>
|
||||
</FeatureFlagProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.withProfiler(AppContainer);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { useSplitClient } from "../feature-flags/splitio-react-replacement";
|
||||
import { Button, Result } from "antd";
|
||||
//import LogRocket from "logrocket";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
@@ -225,13 +225,22 @@ export function App({
|
||||
path="/parts/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
||||
</Route>
|
||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route
|
||||
path="/edit/*"
|
||||
element={
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<DocumentEditorContainer />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApolloClient, useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Icon, { UploadOutlined } from "@ant-design/icons";
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLazyQuery, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import GlobalSearch from "../global-search/global-search.component";
|
||||
import GlobalSearchOs from "../global-search/global-search-os.component";
|
||||
import "./breadcrumbs.styles.scss";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
breadcrumbs: selectBreadcrumbs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PictureFilled } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Badge, Popover } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
|
||||
@@ -26,7 +26,7 @@ import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import { DMS_MAP } from "../../utils/dmsUtils";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
/**
|
||||
* CDK-like DMS post form:
|
||||
|
||||
@@ -9,7 +9,7 @@ import AlertComponent from "../alert/alert.component";
|
||||
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
|
||||
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx";
|
||||
import { useTreatment } from "../../feature-flags/splitio-react-replacement.jsx";
|
||||
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||
|
||||
@@ -16,6 +17,12 @@ const mapStateToProps = createStructuredSelector({
|
||||
|
||||
export function GlobalFooter({ isPartsEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const testFlagTreatment = useTreatment({ name: "TEST_FLAG" });
|
||||
const testFlagEnabled = testFlagTreatment === "on";
|
||||
|
||||
const testFlagIndicator = testFlagEnabled ? (
|
||||
<div style={{ fontWeight: 600, marginTop: 4 }}>Test Feature Flag Enabled</div>
|
||||
) : null;
|
||||
|
||||
if (isPartsEntry) {
|
||||
return (
|
||||
@@ -38,6 +45,7 @@ export function GlobalFooter({ isPartsEntry }) {
|
||||
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
||||
Disclaimer & Notices
|
||||
</Link>
|
||||
{testFlagIndicator}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
@@ -74,6 +82,7 @@ export function GlobalFooter({ isPartsEntry }) {
|
||||
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
||||
Disclaimer & Notices
|
||||
</Link>
|
||||
{testFlagIndicator}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { BellFilled } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Popconfirm } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -30,7 +30,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
|
||||
// import AllocationsAssignmentContainer from "../allocations-assignment/allocations-assignment.container";
|
||||
// import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container";
|
||||
// import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import _ from "lodash";
|
||||
import { FaTasks } from "react-icons/fa";
|
||||
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import Axios from "axios";
|
||||
import Dinero from "dinero.js";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -16,7 +16,7 @@ import DataLabel from "../data-label/data-label.component";
|
||||
import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component";
|
||||
import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component";
|
||||
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../gra
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
||||
import { useIsEmployee } from "../../utils/useIsEmployee.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Col, Row } from "antd";
|
||||
import Axios from "axios";
|
||||
import _ from "lodash";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DownCircleFilled } from "@ant-design/icons";
|
||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import LaborAllocationsTableComponent from "../labor-allocations-table/labor-all
|
||||
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
|
||||
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
jobRO: selectJobReadOnly,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import cleanAxios from "../../utils/CleanAxios";
|
||||
import formatBytes from "../../utils/formatbytes";
|
||||
//import yauzl from "yauzl";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -7,7 +7,7 @@ import JobDocuments from "./jobs-documents-gallery.component";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import PartsOrderModalComponent from "./parts-order-modal.component";
|
||||
import axios from "axios";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import _ from "lodash";
|
||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Form, Input, Radio, Select } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, Row, Space, Tooltip, Typography } from "antd";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Card, Col, Input, Row, Space, Tooltip, Typography } from "antd";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Card, Col, Input, Row, Space, Typography, Tooltip } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
technician: selectTechnician,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries";
|
||||
import { UPDATE_SHOP } from "../../graphql/bodyshop.queries";
|
||||
import ProductionListColumns from "../production-list-columns/production-list-columns.data";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { isFunction } from "lodash";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../../graphql/jobs.queries";
|
||||
import ProductionListTable from "./production-list-table.component";
|
||||
import _ from "lodash";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLazyQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
import queryString from "query-string";
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("@apollo/client/react", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@splitsoftware/splitio-react", () => ({
|
||||
vi.mock("../../feature-flags/splitio-react-replacement", () => ({
|
||||
useTreatmentsWithConfig: () => ({
|
||||
treatments: {
|
||||
Enhanced_Payroll: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Tabs } from "antd";
|
||||
import queryString from "query-string";
|
||||
import { useRef } from "react";
|
||||
|
||||
@@ -4,7 +4,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Form, InputNumber } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -16,7 +16,7 @@ import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./sh
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -7,7 +7,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import JobSearchSelect from "../job-search-select/job-search-select.component";
|
||||
import JobsDetailLaborContainer from "../jobs-detail-labor/jobs-detail-labor.container";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import TechJobPrintTickets from "../tech-job-print-tickets/tech-job-print-tickets.component";
|
||||
import TechClockInComponent from "./tech-job-clock-in-form.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
|
||||
import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-out-delete.component";
|
||||
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createStructuredSelector } from "reselect";
|
||||
import { techLogout } from "../../redux/tech/tech.actions";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { BsKanban } from "react-icons/bs";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EditFilled, SyncOutlined } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Checkbox, Space } from "antd";
|
||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLazyQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Form, Modal, Space } from "antd";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../../graphql/notifications.queries.js";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { FEATURE_FLAGS_CHANGED_EVENT, useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
|
||||
|
||||
const LIMIT = INITIAL_NOTIFICATIONS;
|
||||
@@ -280,6 +280,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeatureFlagsChanged = (payload) => {
|
||||
window.dispatchEvent(new CustomEvent(FEATURE_FLAGS_CHANGED_EVENT, { detail: payload }));
|
||||
};
|
||||
|
||||
const syncCurrentTokenToSocket = async () => {
|
||||
try {
|
||||
if (!auth.currentUser || !bodyshop?.id) return;
|
||||
@@ -574,6 +578,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
|
||||
socketInstance.on("notification", handleNotification);
|
||||
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
|
||||
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
|
||||
socketInstance.on(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||
socketInstance.on("token-updated", handleTokenUpdated);
|
||||
|
||||
if (tokenSyncIntervalRef.current) {
|
||||
|
||||
71
client/src/feature-flags/README.md
Normal file
71
client/src/feature-flags/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Feature Flags
|
||||
|
||||
The app imports feature-flag hooks from `src/feature-flags/splitio-react-replacement.jsx`. That module keeps the old
|
||||
Split-shaped component and hook API intact while removing the runtime dependency on Split.
|
||||
|
||||
Code should import this local module directly. We no longer rely on a Vite alias for the old Split package.
|
||||
|
||||
## Current storage contract
|
||||
|
||||
The compatibility layer reads the active shop from Redux, then fetches DB-backed assignments from:
|
||||
|
||||
```text
|
||||
GET /feature-flags/bodyshops/:bodyshopId
|
||||
```
|
||||
|
||||
That endpoint verifies the Firebase user can access the bodyshop through Hasura permissions, then returns cached Redis
|
||||
data when present or refreshes from `feature_flags` + `bodyshop_feature_flags`.
|
||||
|
||||
On successful backend responses, the client stores the last-known flag payload in browser `localStorage` for the active
|
||||
bodyshop. If the backend cannot be reached later, the client uses that bodyshop-scoped browser cache for up to 24 hours.
|
||||
If there is no browser cache, unknown flags resolve to `"off"`.
|
||||
|
||||
Recommended backend payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"flags": {
|
||||
"Enhanced_Payroll": {
|
||||
"treatment": "on",
|
||||
"config": null,
|
||||
"activeDate": null,
|
||||
"deactiveDate": null
|
||||
},
|
||||
"Demo_Feature": {
|
||||
"treatment": "on",
|
||||
"config": null,
|
||||
"activeDate": "2026-06-01T13:00:00-04:00",
|
||||
"deactiveDate": "2026-06-05T17:00:00-04:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported values:
|
||||
|
||||
- `true`, `"true"`, `1`, `"on"` -> treatment `"on"`
|
||||
- `false`, `"false"`, `0`, `"off"` -> treatment `"off"`
|
||||
- ISO-ish future date strings -> `"on"` until the date passes
|
||||
- `{ "treatment": "on" | "off" | "control" | "any-custom-treatment", "config": ... }`
|
||||
- Scheduled demo windows using `activeDate` and `deactiveDate`
|
||||
|
||||
Unknown flags default to `"off"`.
|
||||
|
||||
## Backend registry
|
||||
|
||||
Canonical feature flag definitions live in the Hasura-backed `feature_flags` table and are exposed to the admin panel
|
||||
through `GET /adm/feature-flags`.
|
||||
|
||||
Per-shop assignments live in `bodyshop_feature_flags`. The admin panel reads them through
|
||||
`GET /adm/bodyshops/:bodyshopId/feature-flags` and saves them through `POST /adm/updateshop`.
|
||||
|
||||
Hasura invalidates the Redis cache through `/feature-flags/cache/invalidate` when `bodyshop_feature_flags` or
|
||||
`feature_flags` changes. Assignment changes clear the affected shop cache for the current cache version; definition
|
||||
changes increment a global feature flag cache version so old per-shop cache entries become invisible and expire by TTL.
|
||||
|
||||
The backend also emits `feature-flags-changed` over the existing Socket.IO connection. `SocketProvider` bridges that
|
||||
socket message to a browser event, and `SplitFactoryProvider` refetches flags when the event is global or matches the
|
||||
active bodyshop. This keeps already-open tabs in sync with admin edits and Hasura-triggered invalidation.
|
||||
|
||||
For manual frontend testing, the global footer displays `Test Feature Flag Enabled` when `TEST_FLAG` resolves to
|
||||
the `on` treatment.
|
||||
411
client/src/feature-flags/splitio-react-replacement.jsx
Normal file
411
client/src/feature-flags/splitio-react-replacement.jsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import axios from "axios";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { selectBodyshop } from "../redux/user/user.selectors";
|
||||
|
||||
const FeatureFlagContext = createContext({
|
||||
config: {},
|
||||
factory: null,
|
||||
flags: {},
|
||||
isReady: true,
|
||||
source: "local"
|
||||
});
|
||||
|
||||
const OFF_TREATMENT = Object.freeze({ treatment: "off", config: null });
|
||||
const LOCAL_STORAGE_PREFIX = "bodyshop-feature-flags";
|
||||
const LOCAL_STORAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const FEATURE_FLAGS_REFRESH_DEBOUNCE_MS = 150;
|
||||
const MAX_SCHEDULE_REFRESH_DELAY_MS = 2_147_483_647;
|
||||
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
const hasSchedule = (value) => value.activeDate != null || value.deactiveDate != null;
|
||||
|
||||
export const FEATURE_FLAGS_CHANGED_EVENT = "feature-flags-changed";
|
||||
|
||||
/**
|
||||
* Parses optional schedule timestamps into comparable epoch milliseconds.
|
||||
*/
|
||||
const parseDate = (value) => {
|
||||
if (value == null || value === "") return null;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether a scheduled feature flag assignment is active at the current time.
|
||||
*/
|
||||
const isWithinSchedule = (value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return true;
|
||||
|
||||
const now = Date.now();
|
||||
const startsAt = parseDate(value.activeDate);
|
||||
const endsAt = parseDate(value.deactiveDate);
|
||||
|
||||
if (startsAt != null && now < startsAt) return false;
|
||||
if (endsAt != null && now >= endsAt) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes backend config values into the object/string/null shape Split hooks expect.
|
||||
*/
|
||||
const normalizeConfig = (config) => {
|
||||
if (config == null || config === "") return null;
|
||||
if (typeof config === "string") {
|
||||
try {
|
||||
return JSON.parse(config);
|
||||
} catch {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts legacy boolean-ish values and custom treatment strings into a stable treatment value.
|
||||
*/
|
||||
const normalizeTreatment = (value) => {
|
||||
if (typeof value === "boolean") return value ? "on" : "off";
|
||||
if (typeof value === "number") return value > 0 ? "on" : "off";
|
||||
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
const lowered = normalized.toLowerCase();
|
||||
|
||||
if (lowered === "true") return "on";
|
||||
if (lowered === "false") return "off";
|
||||
if (lowered === "on" || lowered === "off" || lowered === "control") return lowered;
|
||||
|
||||
const dateValue = Date.parse(normalized);
|
||||
if (!Number.isNaN(dateValue)) return dateValue > Date.now() ? "on" : "off";
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return value ? "on" : "off";
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts any supported backend flag value into a Split-compatible treatment/config pair.
|
||||
*/
|
||||
const normalizeFlagValue = (value) => {
|
||||
if (value == null) return OFF_TREATMENT;
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
if (!isWithinSchedule(value)) return OFF_TREATMENT;
|
||||
|
||||
if (hasOwn(value, "treatment")) {
|
||||
return {
|
||||
treatment: normalizeTreatment(value.treatment),
|
||||
config: normalizeConfig(value.config)
|
||||
};
|
||||
}
|
||||
|
||||
if (hasOwn(value, "enabled")) {
|
||||
return {
|
||||
treatment: normalizeTreatment(value.enabled),
|
||||
config: normalizeConfig(value.config)
|
||||
};
|
||||
}
|
||||
|
||||
if (hasSchedule(value)) {
|
||||
return {
|
||||
treatment: "on",
|
||||
config: normalizeConfig(value.config)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
treatment: normalizeTreatment(value),
|
||||
config: null
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether a socket/browser feature-flag change event applies to the active bodyshop.
|
||||
*/
|
||||
const isFeatureFlagChangeRelevant = (detail, bodyshopId) => {
|
||||
if (!detail || detail.scope === "global") return true;
|
||||
if (!bodyshopId) return false;
|
||||
return String(detail.bodyshopId) === String(bodyshopId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the next scheduled flag boundary that should force a local re-render.
|
||||
*/
|
||||
const getNextScheduleRefreshDelay = (flags = {}, now = Date.now()) => {
|
||||
const nextTimestamp = Object.values(flags).reduce((next, value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return next;
|
||||
|
||||
const timestamps = [parseDate(value.activeDate), parseDate(value.deactiveDate)].filter(
|
||||
(timestamp) => timestamp != null && timestamp > now
|
||||
);
|
||||
if (!timestamps.length) return next;
|
||||
|
||||
const candidate = Math.min(...timestamps);
|
||||
return next == null ? candidate : Math.min(next, candidate);
|
||||
}, null);
|
||||
|
||||
if (nextTimestamp == null) return null;
|
||||
|
||||
return Math.min(Math.max(nextTimestamp - now + 50, 0), MAX_SCHEDULE_REFRESH_DELAY_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether browser localStorage can be used in the current runtime.
|
||||
*/
|
||||
const isBrowserStorageAvailable = () => typeof window !== "undefined" && window.localStorage;
|
||||
|
||||
/**
|
||||
* Builds the browser cache key for one bodyshop's feature flags.
|
||||
*/
|
||||
const getLocalStorageKey = (bodyshopId) => `${LOCAL_STORAGE_PREFIX}:${bodyshopId}`;
|
||||
|
||||
/**
|
||||
* Reads a bodyshop-scoped last-known-good flag payload from browser storage.
|
||||
*/
|
||||
const readCachedFeatureFlags = (bodyshopId, now = Date.now()) => {
|
||||
if (!bodyshopId || !isBrowserStorageAvailable()) return null;
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(getLocalStorageKey(bodyshopId));
|
||||
if (!rawValue) return null;
|
||||
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (!parsed?.flags || typeof parsed.flags !== "object" || Array.isArray(parsed.flags)) return null;
|
||||
const cachedAt = Date.parse(parsed.cachedAt);
|
||||
if (!parsed.cachedAt || Number.isNaN(cachedAt) || now - cachedAt > LOCAL_STORAGE_MAX_AGE_MS) return null;
|
||||
|
||||
return parsed.flags;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a successful backend flag payload for short-term browser fallback.
|
||||
*/
|
||||
const writeCachedFeatureFlags = (bodyshopId, flags) => {
|
||||
if (!bodyshopId || !flags || !isBrowserStorageAvailable()) return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
getLocalStorageKey(bodyshopId),
|
||||
JSON.stringify({
|
||||
cachedAt: new Date().toISOString(),
|
||||
flags
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// localStorage may be unavailable, full, or blocked. Runtime flags still work without the browser cache.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the local client object that mimics the Split client surface used by the app.
|
||||
*/
|
||||
const createFeatureFlagClient = ({ bodyshop, key, backendFlags }) => {
|
||||
const attributes = {};
|
||||
|
||||
const getTreatmentWithConfig = (name) => normalizeFlagValue(backendFlags?.[name]);
|
||||
|
||||
return {
|
||||
client: null,
|
||||
isReady: true,
|
||||
isReadyFromCache: true,
|
||||
key: key || bodyshop?.imexshopid || "anon",
|
||||
getTreatment: (name) => getTreatmentWithConfig(name).treatment,
|
||||
getTreatmentWithConfig,
|
||||
getTreatments: (names = []) =>
|
||||
names.reduce((acc, name) => {
|
||||
acc[name] = getTreatmentWithConfig(name).treatment;
|
||||
return acc;
|
||||
}, {}),
|
||||
getTreatmentsWithConfig: (names = []) =>
|
||||
names.reduce((acc, name) => {
|
||||
acc[name] = getTreatmentWithConfig(name);
|
||||
return acc;
|
||||
}, {}),
|
||||
setAttribute: (name, value) => {
|
||||
attributes[name] = value;
|
||||
return true;
|
||||
},
|
||||
setAttributes: (values = {}) => {
|
||||
Object.assign(attributes, values);
|
||||
return true;
|
||||
},
|
||||
getAttribute: (name) => attributes[name],
|
||||
getAttributes: () => ({ ...attributes }),
|
||||
ready: () => Promise.resolve(),
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
destroy: () => {}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides database-backed feature flags through a Split-shaped React context.
|
||||
*/
|
||||
export function SplitFactoryProvider({ children, config, factory }) {
|
||||
const bodyshop = useSelector(selectBodyshop);
|
||||
const [state, setState] = useState({ flags: {}, isReady: true, source: "local" });
|
||||
const loadIdRef = useRef(0);
|
||||
const refreshTimerRef = useRef(null);
|
||||
|
||||
const loadFeatureFlags = useCallback(async () => {
|
||||
const loadId = (loadIdRef.current += 1);
|
||||
|
||||
if (!bodyshop?.id) {
|
||||
setState({ flags: {}, isReady: true, source: "local" });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => ({ ...current, isReady: false }));
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(`/feature-flags/bodyshops/${bodyshop.id}`);
|
||||
if (loadId !== loadIdRef.current) return;
|
||||
const flags = data.flags || {};
|
||||
writeCachedFeatureFlags(bodyshop.id, flags);
|
||||
setState({
|
||||
flags,
|
||||
isReady: true,
|
||||
source: data.source || "database"
|
||||
});
|
||||
} catch (error) {
|
||||
if (loadId !== loadIdRef.current) return;
|
||||
const cachedFlags = readCachedFeatureFlags(bodyshop.id);
|
||||
console.warn("Feature flags backend fetch failed; falling back to last-known browser cache.", error);
|
||||
setState({
|
||||
flags: cachedFlags || {},
|
||||
isReady: true,
|
||||
source: cachedFlags ? "browser-cache" : "local"
|
||||
});
|
||||
}
|
||||
}, [bodyshop?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatureFlags();
|
||||
|
||||
return () => {
|
||||
loadIdRef.current += 1;
|
||||
};
|
||||
}, [loadFeatureFlags]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyshop?.id) return undefined;
|
||||
|
||||
const handleFeatureFlagsChanged = (event) => {
|
||||
if (!isFeatureFlagChangeRelevant(event.detail, bodyshop.id)) return;
|
||||
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
loadFeatureFlags();
|
||||
}, FEATURE_FLAGS_REFRESH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
window.addEventListener(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [bodyshop?.id, loadFeatureFlags]);
|
||||
|
||||
useEffect(() => {
|
||||
const delay = getNextScheduleRefreshDelay(state.flags);
|
||||
if (delay == null) return undefined;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setState((current) => ({ ...current, flags: { ...current.flags } }));
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state.flags]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ config, factory, flags: state.flags, isReady: state.isReady, source: state.source }),
|
||||
[config, factory, state.flags, state.isReady, state.source]
|
||||
);
|
||||
return <FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Split-compatible client backed by the local feature flag context.
|
||||
*/
|
||||
export function useSplitClient(options = {}) {
|
||||
const bodyshop = useSelector(selectBodyshop);
|
||||
const context = useContext(FeatureFlagContext);
|
||||
|
||||
const client = useMemo(() => {
|
||||
const nextClient = createFeatureFlagClient({
|
||||
bodyshop,
|
||||
key: options.key,
|
||||
backendFlags: context.flags
|
||||
});
|
||||
nextClient.client = nextClient;
|
||||
nextClient.isReady = context.isReady;
|
||||
nextClient.isReadyFromCache = context.source === "redis" || context.source === "browser-cache";
|
||||
return nextClient;
|
||||
}, [bodyshop, options.key, context.flags, context.isReady, context.source]);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns treatment/config pairs for several feature flags.
|
||||
*/
|
||||
export function useTreatmentsWithConfig({ names = [] } = {}) {
|
||||
const client = useSplitClient();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
treatments: client.getTreatmentsWithConfig(names),
|
||||
isReady: client.isReady,
|
||||
isReadyFromCache: client.isReadyFromCache,
|
||||
lastUpdate: Date.now()
|
||||
}),
|
||||
[client, names]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the treatment string for one feature flag.
|
||||
*/
|
||||
export function useTreatment({ name } = {}) {
|
||||
const client = useSplitClient();
|
||||
return client.getTreatment(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the treatment/config pair for one feature flag.
|
||||
*/
|
||||
export function useTreatmentWithConfig({ name } = {}) {
|
||||
const client = useSplitClient();
|
||||
return client.getTreatmentWithConfig(name);
|
||||
}
|
||||
|
||||
export const FeatureFlagProvider = SplitFactoryProvider;
|
||||
export const useFeatureFlagClient = useSplitClient;
|
||||
export const SplitContext = FeatureFlagContext;
|
||||
export const useSplitContext = () => useContext(FeatureFlagContext);
|
||||
|
||||
export const __featureFlagTesting = {
|
||||
createFeatureFlagClient,
|
||||
getNextScheduleRefreshDelay,
|
||||
getLocalStorageKey,
|
||||
isFeatureFlagChangeRelevant,
|
||||
normalizeFlagValue,
|
||||
readCachedFeatureFlags,
|
||||
writeCachedFeatureFlags
|
||||
};
|
||||
166
client/src/feature-flags/splitio-react-replacement.test.jsx
Normal file
166
client/src/feature-flags/splitio-react-replacement.test.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __featureFlagTesting } from "./splitio-react-replacement";
|
||||
|
||||
const {
|
||||
createFeatureFlagClient,
|
||||
getNextScheduleRefreshDelay,
|
||||
getLocalStorageKey,
|
||||
isFeatureFlagChangeRelevant,
|
||||
normalizeFlagValue,
|
||||
readCachedFeatureFlags,
|
||||
writeCachedFeatureFlags
|
||||
} = __featureFlagTesting;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("splitio-react-replacement feature flag normalization", () => {
|
||||
it("returns off for unknown or null values", () => {
|
||||
expect(normalizeFlagValue(null)).toEqual({ treatment: "off", config: null });
|
||||
expect(normalizeFlagValue(undefined)).toEqual({ treatment: "off", config: null });
|
||||
});
|
||||
|
||||
it("normalizes primitive values into Split-like treatments", () => {
|
||||
expect(normalizeFlagValue(true)).toEqual({ treatment: "on", config: null });
|
||||
expect(normalizeFlagValue(false)).toEqual({ treatment: "off", config: null });
|
||||
expect(normalizeFlagValue(1)).toEqual({ treatment: "on", config: null });
|
||||
expect(normalizeFlagValue(0)).toEqual({ treatment: "off", config: null });
|
||||
expect(normalizeFlagValue("true")).toEqual({ treatment: "on", config: null });
|
||||
expect(normalizeFlagValue("false")).toEqual({ treatment: "off", config: null });
|
||||
expect(normalizeFlagValue("variant-a")).toEqual({ treatment: "variant-a", config: null });
|
||||
});
|
||||
|
||||
it("preserves custom treatments and parses JSON config strings", () => {
|
||||
expect(
|
||||
normalizeFlagValue({
|
||||
treatment: "demo",
|
||||
config: "{\"limit\":25}"
|
||||
})
|
||||
).toEqual({
|
||||
treatment: "demo",
|
||||
config: { limit: 25 }
|
||||
});
|
||||
});
|
||||
|
||||
it("respects activeDate and deactiveDate windows", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
|
||||
|
||||
expect(
|
||||
normalizeFlagValue({
|
||||
treatment: "on",
|
||||
activeDate: "2026-05-19T14:59:00.000Z",
|
||||
deactiveDate: "2026-05-19T15:01:00.000Z"
|
||||
})
|
||||
).toEqual({ treatment: "on", config: null });
|
||||
|
||||
expect(
|
||||
normalizeFlagValue({
|
||||
treatment: "on",
|
||||
activeDate: "2026-05-19T15:01:00.000Z"
|
||||
})
|
||||
).toEqual({ treatment: "off", config: null });
|
||||
|
||||
expect(
|
||||
normalizeFlagValue({
|
||||
treatment: "on",
|
||||
deactiveDate: "2026-05-19T15:00:00.000Z"
|
||||
})
|
||||
).toEqual({ treatment: "off", config: null });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitio-react-replacement feature flag client", () => {
|
||||
it("uses backend flags", () => {
|
||||
const client = createFeatureFlagClient({
|
||||
bodyshop: {
|
||||
imexshopid: "APPLE"
|
||||
},
|
||||
backendFlags: {
|
||||
Enhanced_Payroll: { treatment: "on" }
|
||||
}
|
||||
});
|
||||
|
||||
expect(client.getTreatment("Enhanced_Payroll")).toBe("on");
|
||||
});
|
||||
|
||||
it("ignores old bodyshop feature JSON fallback values", () => {
|
||||
const client = createFeatureFlagClient({
|
||||
bodyshop: {
|
||||
imexshopid: "APPLE",
|
||||
features: {
|
||||
featureFlags: {
|
||||
Enhanced_Payroll: { treatment: "on" }
|
||||
}
|
||||
}
|
||||
},
|
||||
backendFlags: {}
|
||||
});
|
||||
|
||||
expect(client.getTreatment("Enhanced_Payroll")).toBe("off");
|
||||
});
|
||||
|
||||
it("returns off for flags that are not present in any source", () => {
|
||||
const client = createFeatureFlagClient({
|
||||
bodyshop: { imexshopid: "APPLE", features: {} },
|
||||
backendFlags: {}
|
||||
});
|
||||
|
||||
expect(client.getTreatment("Missing_Flag")).toBe("off");
|
||||
});
|
||||
|
||||
it("uses a bodyshop-scoped browser cache key", () => {
|
||||
expect(getLocalStorageKey("shop-1")).toBe("bodyshop-feature-flags:shop-1");
|
||||
});
|
||||
|
||||
it("stores and reads last-known backend flags from browser storage", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
|
||||
|
||||
writeCachedFeatureFlags("shop-1", {
|
||||
Enhanced_Payroll: { treatment: "on", config: null }
|
||||
});
|
||||
|
||||
expect(readCachedFeatureFlags("shop-1")).toEqual({
|
||||
Enhanced_Payroll: { treatment: "on", config: null }
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores expired browser cached flags", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
|
||||
|
||||
writeCachedFeatureFlags("shop-1", {
|
||||
Enhanced_Payroll: { treatment: "on", config: null }
|
||||
});
|
||||
|
||||
expect(readCachedFeatureFlags("shop-1", Date.parse("2026-05-20T15:00:01.000Z"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitio-react-replacement live refresh helpers", () => {
|
||||
it("matches global and bodyshop-scoped socket changes", () => {
|
||||
expect(isFeatureFlagChangeRelevant({ scope: "global" }, "shop-1")).toBe(true);
|
||||
expect(isFeatureFlagChangeRelevant({ bodyshopId: "shop-1", scope: "bodyshop" }, "shop-1")).toBe(true);
|
||||
expect(isFeatureFlagChangeRelevant({ bodyshopId: "shop-2", scope: "bodyshop" }, "shop-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the next active/deactive date boundary that needs a refresh", () => {
|
||||
const now = Date.parse("2026-05-19T15:00:00.000Z");
|
||||
|
||||
expect(
|
||||
getNextScheduleRefreshDelay(
|
||||
{
|
||||
Demo: { treatment: "on", activeDate: "2026-05-19T15:05:00.000Z" },
|
||||
Expiring: { treatment: "on", deactiveDate: "2026-05-19T15:02:00.000Z" },
|
||||
Expired: { treatment: "on", deactiveDate: "2026-05-19T14:59:00.000Z" }
|
||||
},
|
||||
now
|
||||
)
|
||||
).toBe(120050);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { createStructuredSelector } from "reselect";
|
||||
import queryString from "query-string";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { some } from "lodash";
|
||||
import axios from "axios";
|
||||
import AlertComponent from "../../components/alert/alert.component";
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import Dinero from "dinero.js";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -2,7 +2,7 @@ import ProductionBoardKanbanContainer from "../../components/production-board-ka
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
|
||||
@@ -3,7 +3,7 @@ import ProductionListTable from "../../components/production-list-table/producti
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -13,7 +13,7 @@ import { GET_UNACCEPTED_PARTS_DISPATCH } from "../../graphql/parts-dispatch.quer
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { alphaSort } from "../../utils/sorters";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
|
||||
@@ -4,7 +4,7 @@ import JobsDocumentsContainer from "../../components/jobs-documents-gallery/jobs
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
|
||||
import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries";
|
||||
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import JobsDocumentsLocalGallery from "../../components/jobs-documents-local-gallery/jobs-documents-local-gallery.container";
|
||||
|
||||
@@ -18,7 +18,7 @@ import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/appli
|
||||
import TimeTicketsCommit from "../../components/time-tickets-commit/time-tickets-commit.component";
|
||||
import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wrapper.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
redux: ["redux"],
|
||||
lodash: ["lodash"],
|
||||
"@sentry/react": ["@sentry/react"],
|
||||
"@splitsoftware/splitio-react": ["@splitsoftware/splitio-react"],
|
||||
"feature-flags": ["src/feature-flags/splitio-react-replacement.jsx"],
|
||||
logrocket: ["logrocket"],
|
||||
firebase: [
|
||||
"@firebase/analytics",
|
||||
|
||||
@@ -846,6 +846,13 @@
|
||||
table:
|
||||
name: exportlog
|
||||
schema: public
|
||||
- name: feature_flags
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: bodyshopid
|
||||
table:
|
||||
name: bodyshop_feature_flags
|
||||
schema: public
|
||||
- name: inventories
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -2739,6 +2746,114 @@
|
||||
- end_date
|
||||
- content
|
||||
filter: {}
|
||||
- table:
|
||||
name: bodyshop_feature_flags
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: bodyshop
|
||||
using:
|
||||
foreign_key_constraint_on: bodyshopid
|
||||
- name: feature_flag
|
||||
using:
|
||||
foreign_key_constraint_on: name
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- id
|
||||
- bodyshopid
|
||||
- name
|
||||
- treatment
|
||||
- config
|
||||
- activeDate
|
||||
- deactiveDate
|
||||
- created_at
|
||||
- updated_at
|
||||
filter:
|
||||
_and:
|
||||
- bodyshop:
|
||||
associations:
|
||||
_and:
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
- feature_flag:
|
||||
active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: cache_bodyshop_feature_flags
|
||||
definition:
|
||||
delete:
|
||||
columns: '*'
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
update:
|
||||
columns: '*'
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/feature-flags/cache/invalidate'
|
||||
version: 2
|
||||
- table:
|
||||
name: feature_flags
|
||||
schema: public
|
||||
array_relationships:
|
||||
- name: bodyshop_feature_flags
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: name
|
||||
table:
|
||||
name: bodyshop_feature_flags
|
||||
schema: public
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- name
|
||||
- description
|
||||
- default_treatment
|
||||
- active
|
||||
- created_at
|
||||
- updated_at
|
||||
filter:
|
||||
active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: cache_feature_flags
|
||||
definition:
|
||||
delete:
|
||||
columns: '*'
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
update:
|
||||
columns: '*'
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/feature-flags/cache/invalidate'
|
||||
version: 2
|
||||
- table:
|
||||
name: exportlog
|
||||
schema: public
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS "public"."feature_flags";
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE "public"."feature_flags" (
|
||||
"name" text NOT NULL,
|
||||
"description" text NULL,
|
||||
"default_treatment" text NOT NULL DEFAULT 'off',
|
||||
"active" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("name"),
|
||||
CONSTRAINT "feature_flags_default_treatment_check" CHECK (length(btrim("default_treatment")) > 0)
|
||||
);
|
||||
|
||||
INSERT INTO "public"."feature_flags" ("name", "description")
|
||||
VALUES
|
||||
('ADPPayroll', 'Enable ADP payroll flows and reporting.'),
|
||||
('Allow_Negative_Jobline_Price', 'Allow negative pricing on job lines.'),
|
||||
('Autohouse_Detail_line', 'Enable Autohouse detail line handling.'),
|
||||
('Bill_OCR_AI', 'Enable AI bill OCR entry.'),
|
||||
('ClosingPeriod', 'Enable closing period accounting restrictions.'),
|
||||
('CriticalPartsScanning', 'Enable critical parts scanning workflows.'),
|
||||
('Direct_Media_Download', 'Enable direct media downloads.'),
|
||||
('DmsAp', 'Enable DMS accounts payable workflows.'),
|
||||
('Enhanced_Payroll', 'Enable enhanced payroll and labor allocation features.'),
|
||||
('Extended_Bill_Posting', 'Enable extended bill posting.'),
|
||||
('Fortellis', 'Enable Fortellis-backed DMS flows.'),
|
||||
('IOU_Tracking', 'Enable IOU tracking.'),
|
||||
('ImEXPay', 'Enable ImEX Pay workflows.'),
|
||||
('Imgproxy', 'Enable imgproxy-backed media rendering.'),
|
||||
('LogRocket_Tracking', 'Enable LogRocket tracking.'),
|
||||
('NewPhotoViewer', 'Enable the newer photo viewer experience.'),
|
||||
('OEConnection', 'Enable OEConnection parts ordering.'),
|
||||
('OEConnection_PriceChange', 'Enable OEConnection price changes.'),
|
||||
('OpenSearch', 'Enable OpenSearch global search.'),
|
||||
('OpenSearch_PaginatedScreens', 'Enable OpenSearch on paginated screens.'),
|
||||
('Production_List_Status_Colors', 'Enable status colors on production list.'),
|
||||
('Production_Use_View', 'Enable production view selection.'),
|
||||
('Qb_Multi_Ar', 'Enable QuickBooks multi-AR payment options.'),
|
||||
('Realtime_Notifications_UI', 'Enable realtime notification UI.'),
|
||||
('Share_To_Teams', 'Enable sharing workflows to Microsoft Teams.'),
|
||||
('Simple_Inventory', 'Enable simple inventory workflows.'),
|
||||
('TEST_FLAG', 'Manual test flag used to verify frontend feature flag plumbing.'),
|
||||
('Use_Graphql_RR', 'Enable GraphQL-backed Rome/RR flows.'),
|
||||
('Websocket_Production', 'Toggle websocket production board/list behavior.')
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TRIGGER IF EXISTS "set_public_feature_flags_updated_at" ON "public"."feature_flags";
|
||||
DROP TRIGGER IF EXISTS "set_public_bodyshop_feature_flags_updated_at" ON "public"."bodyshop_feature_flags";
|
||||
DROP TABLE IF EXISTS "public"."bodyshop_feature_flags";
|
||||
@@ -0,0 +1,89 @@
|
||||
CREATE TABLE "public"."bodyshop_feature_flags" (
|
||||
"id" uuid NOT NULL DEFAULT public.gen_random_uuid(),
|
||||
"bodyshopid" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"treatment" text NOT NULL DEFAULT 'off',
|
||||
"config" jsonb NULL,
|
||||
"activeDate" timestamptz NULL,
|
||||
"deactiveDate" timestamptz NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "bodyshop_feature_flags_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "bodyshop_feature_flags_bodyshopid_name_key" UNIQUE ("bodyshopid", "name"),
|
||||
CONSTRAINT "bodyshop_feature_flags_bodyshopid_fkey" FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "bodyshop_feature_flags_name_fkey" FOREIGN KEY ("name") REFERENCES "public"."feature_flags" ("name") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK (length(btrim("treatment")) > 0),
|
||||
CONSTRAINT "bodyshop_feature_flags_dates_check" CHECK ("deactiveDate" IS NULL OR "activeDate" IS NULL OR "deactiveDate" > "activeDate")
|
||||
);
|
||||
|
||||
CREATE INDEX "bodyshop_feature_flags_bodyshopid_idx" ON "public"."bodyshop_feature_flags" ("bodyshopid");
|
||||
CREATE INDEX "bodyshop_feature_flags_name_idx" ON "public"."bodyshop_feature_flags" ("name");
|
||||
|
||||
INSERT INTO "public"."bodyshop_feature_flags" (
|
||||
"bodyshopid",
|
||||
"name",
|
||||
"treatment",
|
||||
"config",
|
||||
"activeDate",
|
||||
"deactiveDate"
|
||||
)
|
||||
SELECT
|
||||
"bodyshops"."id",
|
||||
"feature_flag"."name",
|
||||
CASE
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'object'
|
||||
AND nullif(btrim("feature_flag"."value" ->> 'treatment'), '') IS NOT NULL
|
||||
THEN btrim("feature_flag"."value" ->> 'treatment')
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'boolean'
|
||||
THEN CASE WHEN ("feature_flag"."value" #>> '{}')::boolean THEN 'on' ELSE 'off' END
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'string'
|
||||
AND nullif(btrim("feature_flag"."value" #>> '{}'), '') IS NOT NULL
|
||||
THEN btrim("feature_flag"."value" #>> '{}')
|
||||
ELSE 'on'
|
||||
END,
|
||||
CASE
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'object'
|
||||
THEN "feature_flag"."value" -> 'config'
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'object'
|
||||
AND "feature_flag"."value" ->> 'activeDate' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
|
||||
THEN ("feature_flag"."value" ->> 'activeDate')::timestamptz
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'object'
|
||||
AND "feature_flag"."value" ->> 'deactiveDate' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
|
||||
THEN ("feature_flag"."value" ->> 'deactiveDate')::timestamptz
|
||||
ELSE NULL
|
||||
END
|
||||
FROM "public"."bodyshops"
|
||||
CROSS JOIN LATERAL jsonb_each(
|
||||
CASE
|
||||
WHEN jsonb_typeof(COALESCE("bodyshops"."features" -> 'featureFlags', '{}'::jsonb)) = 'object'
|
||||
THEN COALESCE("bodyshops"."features" -> 'featureFlags', '{}'::jsonb)
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) AS "feature_flag"("name", "value")
|
||||
INNER JOIN "public"."feature_flags" ON "feature_flags"."name" = "feature_flag"."name"
|
||||
ON CONFLICT ("bodyshopid", "name") DO UPDATE
|
||||
SET
|
||||
"treatment" = EXCLUDED."treatment",
|
||||
"config" = EXCLUDED."config",
|
||||
"activeDate" = EXCLUDED."activeDate",
|
||||
"deactiveDate" = EXCLUDED."deactiveDate";
|
||||
|
||||
CREATE TRIGGER "set_public_bodyshop_feature_flags_updated_at"
|
||||
BEFORE UPDATE ON "public"."bodyshop_feature_flags"
|
||||
FOR EACH ROW EXECUTE FUNCTION "public"."set_current_timestamp_updated_at"();
|
||||
|
||||
COMMENT ON TRIGGER "set_public_bodyshop_feature_flags_updated_at" ON "public"."bodyshop_feature_flags"
|
||||
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
|
||||
|
||||
CREATE TRIGGER "set_public_feature_flags_updated_at"
|
||||
BEFORE UPDATE ON "public"."feature_flags"
|
||||
FOR EACH ROW EXECUTE FUNCTION "public"."set_current_timestamp_updated_at"();
|
||||
|
||||
COMMENT ON TRIGGER "set_public_feature_flags_updated_at" ON "public"."feature_flags"
|
||||
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "public"."feature_flags"
|
||||
DROP CONSTRAINT IF EXISTS "feature_flags_default_treatment_check";
|
||||
|
||||
ALTER TABLE "public"."feature_flags"
|
||||
ADD CONSTRAINT "feature_flags_default_treatment_check" CHECK ("default_treatment" IN ('on', 'off', 'control'));
|
||||
|
||||
ALTER TABLE "public"."bodyshop_feature_flags"
|
||||
DROP CONSTRAINT IF EXISTS "bodyshop_feature_flags_treatment_check";
|
||||
|
||||
ALTER TABLE "public"."bodyshop_feature_flags"
|
||||
ADD CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK ("treatment" IN ('on', 'off', 'control'));
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "public"."feature_flags"
|
||||
DROP CONSTRAINT IF EXISTS "feature_flags_default_treatment_check";
|
||||
|
||||
ALTER TABLE "public"."feature_flags"
|
||||
ADD CONSTRAINT "feature_flags_default_treatment_check" CHECK (length(btrim("default_treatment")) > 0);
|
||||
|
||||
ALTER TABLE "public"."bodyshop_feature_flags"
|
||||
DROP CONSTRAINT IF EXISTS "bodyshop_feature_flags_treatment_check";
|
||||
|
||||
ALTER TABLE "public"."bodyshop_feature_flags"
|
||||
ADD CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK (length(btrim("treatment")) > 0);
|
||||
@@ -0,0 +1,2 @@
|
||||
DELETE FROM "public"."feature_flags"
|
||||
WHERE "name" = 'TEST_FLAG';
|
||||
@@ -0,0 +1,3 @@
|
||||
INSERT INTO "public"."feature_flags" ("name", "description")
|
||||
VALUES ('TEST_FLAG', 'Manual test flag used to verify frontend feature flag plumbing.')
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
@@ -15,7 +15,8 @@
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test:unit": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
|
||||
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js",
|
||||
"feature-flags:export-harness": "node scripts/export-harness-feature-flags.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1020.0",
|
||||
|
||||
1047
scripts/export-harness-feature-flags.js
Normal file
1047
scripts/export-harness-feature-flags.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,7 @@ const applyRoutes = ({ app }) => {
|
||||
app.use("/utils", require("./server/routes/utilRoutes"));
|
||||
app.use("/data", require("./server/routes/dataRoutes"));
|
||||
app.use("/adm", require("./server/routes/adminRoutes"));
|
||||
app.use("/feature-flags", require("./server/routes/featureFlagRoutes"));
|
||||
app.use("/tech", require("./server/routes/techRoutes"));
|
||||
app.use("/intellipay", require("./server/routes/intellipayRoutes"));
|
||||
app.use("/cdk", require("./server/routes/cdkRoutes"));
|
||||
|
||||
121
server/admin/adminops.feature-flags.test.js
Normal file
121
server/admin/adminops.feature-flags.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Creates the minimal Express response mock needed by admin route handlers.
|
||||
*/
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
body: null,
|
||||
code: 200,
|
||||
json: vi.fn((body) => {
|
||||
res.body = body;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code) => {
|
||||
res.code = code;
|
||||
return res;
|
||||
})
|
||||
};
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads admin operations with CommonJS dependency cache overrides for isolated route tests.
|
||||
*/
|
||||
const loadAdminOps = async ({ request }) => {
|
||||
const adminOpsPath = require.resolve("./adminops");
|
||||
const graphqlClientPath = require.resolve("../graphql-client/graphql-client");
|
||||
const loggerPath = require.resolve("../utils/logger");
|
||||
const socketEventsPath = require.resolve("../feature-flags/socket-events");
|
||||
|
||||
delete require.cache[adminOpsPath];
|
||||
require.cache[graphqlClientPath] = {
|
||||
exports: {
|
||||
client: {
|
||||
request
|
||||
}
|
||||
}
|
||||
};
|
||||
require.cache[loggerPath] = {
|
||||
exports: {
|
||||
log: vi.fn()
|
||||
}
|
||||
};
|
||||
require.cache[socketEventsPath] = {
|
||||
exports: {
|
||||
emitFeatureFlagsChanged: vi.fn()
|
||||
}
|
||||
};
|
||||
|
||||
return require("./adminops");
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete require.cache[require.resolve("./adminops")];
|
||||
delete require.cache[require.resolve("../graphql-client/graphql-client")];
|
||||
delete require.cache[require.resolve("../utils/logger")];
|
||||
delete require.cache[require.resolve("../feature-flags/socket-events")];
|
||||
});
|
||||
|
||||
describe("feature flag admin delete guard", () => {
|
||||
it("does not delete a feature flag assigned to shops", async () => {
|
||||
const request = vi.fn(async () => ({
|
||||
bodyshop_feature_flags_aggregate: {
|
||||
aggregate: {
|
||||
count: 2
|
||||
}
|
||||
}
|
||||
}));
|
||||
const { deleteFeatureFlag } = await loadAdminOps({ request });
|
||||
const req = {
|
||||
params: { name: "Enhanced_Payroll" },
|
||||
user: { email: "admin@example.com" }
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await deleteFeatureFlag(req, res);
|
||||
|
||||
expect(res.code).toBe(409);
|
||||
expect(res.body).toEqual({
|
||||
error: "Feature flag Enhanced_Payroll is assigned to 2 shops. Remove shop assignments before deleting it.",
|
||||
assignmentCount: 2
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deletes a feature flag with no shop assignments", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
bodyshop_feature_flags_aggregate: {
|
||||
aggregate: {
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
delete_feature_flags_by_pk: {
|
||||
name: "Unused_Flag"
|
||||
}
|
||||
});
|
||||
const { deleteFeatureFlag } = await loadAdminOps({ request });
|
||||
const req = {
|
||||
params: { name: "Unused_Flag" },
|
||||
sessionUtils: {
|
||||
invalidateAllBodyshopFeatureFlagsInRedis: vi.fn()
|
||||
},
|
||||
user: { email: "admin@example.com" }
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await deleteFeatureFlag(req, res);
|
||||
|
||||
expect(res.body).toEqual({ name: "Unused_Flag" });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(req.sessionUtils.invalidateAllBodyshopFeatureFlagsInRedis).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
const logger = require("../utils/logger");
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const {
|
||||
sanitizeFeatureFlagCreatePayload,
|
||||
sanitizeFeatureFlagUpdatePayload
|
||||
} = require("../feature-flags/admin-payload");
|
||||
const { emitFeatureFlagsChanged } = require("../feature-flags/socket-events");
|
||||
|
||||
exports.createAssociation = async (req, res) => {
|
||||
logger.log("admin-create-association", "debug", req.user.email, null, {
|
||||
@@ -112,12 +117,455 @@ exports.updateCounter = async (req, res) => {
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists feature flag definitions for the admin UI, optionally including inactive flags.
|
||||
*/
|
||||
exports.getFeatureFlags = async (req, res) => {
|
||||
logger.log("admin-get-feature-flags", "debug", req.user.email, null, {
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const includeInactive = req.query?.includeInactive === "true";
|
||||
const result = await client.request(
|
||||
`query GET_FEATURE_FLAGS($where: feature_flags_bool_exp!) {
|
||||
feature_flags(where: $where, order_by: { name: asc }) {
|
||||
name
|
||||
description
|
||||
default_treatment
|
||||
active
|
||||
created_at
|
||||
updated_at
|
||||
bodyshop_feature_flags_aggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ where: includeInactive ? {} : { active: { _eq: true } } }
|
||||
);
|
||||
res.json(result.feature_flags || []);
|
||||
} catch (error) {
|
||||
logger.log("admin-get-feature-flags-error", "error", req.user.email, null, {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a global feature flag definition and invalidates all runtime flag caches.
|
||||
*/
|
||||
exports.createFeatureFlag = async (req, res) => {
|
||||
logger.log("admin-create-feature-flag", "debug", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const featureFlag = sanitizeFeatureFlagCreatePayload(req.body);
|
||||
const result = await client.request(
|
||||
`mutation CREATE_FEATURE_FLAG($featureFlag: feature_flags_insert_input!) {
|
||||
insert_feature_flags_one(object: $featureFlag) {
|
||||
name
|
||||
description
|
||||
default_treatment
|
||||
active
|
||||
}
|
||||
}`,
|
||||
{ featureFlag }
|
||||
);
|
||||
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
|
||||
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: featureFlag.name });
|
||||
res.status(201).json(result.insert_feature_flags_one);
|
||||
} catch (error) {
|
||||
logger.log("admin-create-feature-flag-error", "error", req.user.email, null, {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates editable fields on a global feature flag definition.
|
||||
*/
|
||||
exports.updateFeatureFlag = async (req, res) => {
|
||||
logger.log("admin-update-feature-flag", "debug", req.user.email, null, {
|
||||
name: req.params.name,
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const featureFlag = sanitizeFeatureFlagUpdatePayload(req.body);
|
||||
if (Object.keys(featureFlag).length === 0) {
|
||||
return res.status(400).json({ error: "No editable feature flag fields were provided." });
|
||||
}
|
||||
|
||||
const result = await client.request(
|
||||
`mutation UPDATE_FEATURE_FLAG($name: String!, $featureFlag: feature_flags_set_input!) {
|
||||
update_feature_flags_by_pk(pk_columns: { name: $name }, _set: $featureFlag) {
|
||||
name
|
||||
description
|
||||
default_treatment
|
||||
active
|
||||
}
|
||||
}`,
|
||||
{ name: req.params.name, featureFlag }
|
||||
);
|
||||
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
|
||||
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: req.params.name });
|
||||
res.json(result.update_feature_flags_by_pk);
|
||||
} catch (error) {
|
||||
logger.log("admin-update-feature-flag-error", "error", req.user.email, null, {
|
||||
name: req.params.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an unassigned feature flag definition after confirming no bodyshop assignments exist.
|
||||
*/
|
||||
exports.deleteFeatureFlag = async (req, res) => {
|
||||
logger.log("admin-delete-feature-flag", "debug", req.user.email, null, {
|
||||
name: req.params.name,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const assignmentResult = await client.request(
|
||||
`query COUNT_FEATURE_FLAG_ASSIGNMENTS($name: String!) {
|
||||
bodyshop_feature_flags_aggregate(where: { name: { _eq: $name } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ name: req.params.name }
|
||||
);
|
||||
const assignmentCount = assignmentResult.bodyshop_feature_flags_aggregate?.aggregate?.count || 0;
|
||||
|
||||
if (assignmentCount > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Feature flag ${req.params.name} is assigned to ${assignmentCount} shop${
|
||||
assignmentCount === 1 ? "" : "s"
|
||||
}. Remove shop assignments before deleting it.`,
|
||||
assignmentCount
|
||||
});
|
||||
}
|
||||
|
||||
const result = await client.request(
|
||||
`mutation DELETE_FEATURE_FLAG($name: String!) {
|
||||
delete_feature_flags_by_pk(name: $name) {
|
||||
name
|
||||
}
|
||||
}`,
|
||||
{ name: req.params.name }
|
||||
);
|
||||
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
|
||||
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: req.params.name });
|
||||
res.json(result.delete_feature_flags_by_pk);
|
||||
} catch (error) {
|
||||
logger.log("admin-delete-feature-flag-error", "error", req.user.email, null, {
|
||||
name: req.params.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists feature flag assignments for one bodyshop in the admin UI.
|
||||
*/
|
||||
exports.getBodyshopFeatureFlags = async (req, res) => {
|
||||
const bodyshopId = req.params.bodyshopId;
|
||||
|
||||
logger.log("admin-get-bodyshop-feature-flags", "debug", req.user.email, null, {
|
||||
bodyshopId,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request(
|
||||
`query GET_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
|
||||
bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }, order_by: { name: asc }) {
|
||||
name
|
||||
treatment
|
||||
config
|
||||
activeDate
|
||||
deactiveDate
|
||||
}
|
||||
}`,
|
||||
{ bodyshopid: bodyshopId }
|
||||
);
|
||||
|
||||
res.json(result.bodyshop_feature_flags || []);
|
||||
} catch (error) {
|
||||
logger.log("admin-get-bodyshop-feature-flags-error", "error", req.user.email, null, {
|
||||
bodyshopId,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists bodyshop assignments for one feature flag definition.
|
||||
*/
|
||||
exports.getFeatureFlagBodyshops = async (req, res) => {
|
||||
const name = req.params.name;
|
||||
|
||||
logger.log("admin-get-feature-flag-bodyshops", "debug", req.user.email, null, {
|
||||
name,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request(
|
||||
`query GET_FEATURE_FLAG_BODYSHOPS($name: String!) {
|
||||
bodyshop_feature_flags(where: { name: { _eq: $name } }, order_by: { bodyshop: { shopname: asc } }) {
|
||||
id
|
||||
bodyshopid
|
||||
name
|
||||
treatment
|
||||
config
|
||||
activeDate
|
||||
deactiveDate
|
||||
bodyshop {
|
||||
id
|
||||
shopname
|
||||
imexshopid
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ name }
|
||||
);
|
||||
|
||||
res.json(result.bodyshop_feature_flags || []);
|
||||
} catch (error) {
|
||||
logger.log("admin-get-feature-flag-bodyshops-error", "error", req.user.email, null, {
|
||||
name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the set of bodyshops assigned to one feature flag and refreshes affected clients.
|
||||
*/
|
||||
exports.updateFeatureFlagBodyshops = async (req, res) => {
|
||||
const name = req.params.name;
|
||||
const assignments = Array.isArray(req.body?.assignments) ? req.body.assignments : [];
|
||||
const bodyshopIds = assignments.map((assignment) => assignment.bodyshopid).filter(Boolean);
|
||||
const objects = assignments
|
||||
.filter((assignment) => assignment.bodyshopid)
|
||||
.map((assignment) => ({
|
||||
bodyshopid: assignment.bodyshopid,
|
||||
name,
|
||||
treatment: assignment.treatment || "on",
|
||||
config: assignment.config ?? null,
|
||||
activeDate: assignment.activeDate || null,
|
||||
deactiveDate: assignment.deactiveDate || null
|
||||
}));
|
||||
|
||||
logger.log("admin-update-feature-flag-bodyshops", "debug", req.user.email, null, {
|
||||
name,
|
||||
assignmentCount: objects.length,
|
||||
ioadmin: true
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.request(
|
||||
`mutation UPDATE_FEATURE_FLAG_BODYSHOPS(
|
||||
$name: String!,
|
||||
$bodyshopIds: [uuid!]!,
|
||||
$objects: [bodyshop_feature_flags_insert_input!]!
|
||||
) {
|
||||
delete_bodyshop_feature_flags(where: { name: { _eq: $name }, bodyshopid: { _nin: $bodyshopIds } }) {
|
||||
affected_rows
|
||||
returning {
|
||||
bodyshopid
|
||||
}
|
||||
}
|
||||
insert_bodyshop_feature_flags(
|
||||
objects: $objects,
|
||||
on_conflict: {
|
||||
constraint: bodyshop_feature_flags_bodyshopid_name_key,
|
||||
update_columns: [treatment, config, activeDate, deactiveDate]
|
||||
}
|
||||
) {
|
||||
affected_rows
|
||||
returning {
|
||||
bodyshopid
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ name, bodyshopIds, objects }
|
||||
);
|
||||
|
||||
const changedBodyshopIds = [
|
||||
...(result.delete_bodyshop_feature_flags?.returning || []),
|
||||
...(result.insert_bodyshop_feature_flags?.returning || [])
|
||||
].map((row) => row.bodyshopid);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(new Set(changedBodyshopIds)).map(async (bodyshopId) => {
|
||||
await req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis?.(bodyshopId);
|
||||
emitFeatureFlagsChanged({ req, bodyshopId, source: "admin", table: "bodyshop_feature_flags", name });
|
||||
})
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.log("admin-update-feature-flag-bodyshops-error", "error", req.user.email, null, {
|
||||
name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
assignmentCount: objects.length,
|
||||
ioadmin: true
|
||||
});
|
||||
res.status(500).json(error);
|
||||
}
|
||||
};
|
||||
|
||||
const isPlainObject = (value) => value && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
/**
|
||||
* Normalizes legacy feature flag values into persisted treatment strings.
|
||||
*/
|
||||
const normalizeTreatment = (value, fallback = "on") => {
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || fallback;
|
||||
}
|
||||
|
||||
if (value === false) return "off";
|
||||
if (value === true) return "on";
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a legacy bodyshop feature flag map into table rows.
|
||||
*/
|
||||
const normalizeFeatureFlagRows = (featureFlags = {}) =>
|
||||
Object.entries(featureFlags)
|
||||
.filter(([name]) => Boolean(name))
|
||||
.map(([name, value]) => {
|
||||
if (isPlainObject(value)) {
|
||||
return {
|
||||
name,
|
||||
treatment: value.enabled === false ? "off" : normalizeTreatment(value.treatment),
|
||||
config: value.config ?? null,
|
||||
activeDate: value.activeDate || null,
|
||||
deactiveDate: value.deactiveDate || null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
treatment: normalizeTreatment(value),
|
||||
config: null,
|
||||
activeDate: null,
|
||||
deactiveDate: null
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Separates feature flag assignment edits from the bodyshop update payload.
|
||||
*/
|
||||
const splitBodyshopUpdate = (bodyshop) => {
|
||||
const featureFlags = bodyshop?.featureFlags || bodyshop?.features?.featureFlags;
|
||||
const nextBodyshop = { ...bodyshop };
|
||||
|
||||
delete nextBodyshop.featureFlags;
|
||||
|
||||
if (bodyshop?.features && hasOwn(bodyshop.features, "featureFlags")) {
|
||||
nextBodyshop.features = { ...bodyshop.features };
|
||||
delete nextBodyshop.features.featureFlags;
|
||||
}
|
||||
|
||||
return { bodyshop: nextBodyshop, featureFlags };
|
||||
};
|
||||
|
||||
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
/**
|
||||
* Upserts or clears all feature flag assignments for one bodyshop.
|
||||
*/
|
||||
async function saveBodyshopFeatureFlags({ bodyshopId, featureFlags }) {
|
||||
if (!isPlainObject(featureFlags)) return null;
|
||||
|
||||
const rows = normalizeFeatureFlagRows(featureFlags);
|
||||
const flagNames = rows.map((row) => row.name);
|
||||
const objects = rows.map((row) => ({
|
||||
bodyshopid: bodyshopId,
|
||||
name: row.name,
|
||||
treatment: row.treatment,
|
||||
config: row.config,
|
||||
activeDate: row.activeDate,
|
||||
deactiveDate: row.deactiveDate
|
||||
}));
|
||||
|
||||
if (objects.length === 0) {
|
||||
return client.request(
|
||||
`mutation DELETE_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
|
||||
delete_bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }) {
|
||||
affected_rows
|
||||
}
|
||||
}`,
|
||||
{ bodyshopid: bodyshopId }
|
||||
);
|
||||
}
|
||||
|
||||
return client.request(
|
||||
`mutation UPSERT_BODYSHOP_FEATURE_FLAGS(
|
||||
$bodyshopid: uuid!,
|
||||
$flagNames: [String!]!,
|
||||
$objects: [bodyshop_feature_flags_insert_input!]!
|
||||
) {
|
||||
delete_bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid }, name: { _nin: $flagNames } }) {
|
||||
affected_rows
|
||||
}
|
||||
insert_bodyshop_feature_flags(
|
||||
objects: $objects,
|
||||
on_conflict: {
|
||||
constraint: bodyshop_feature_flags_bodyshopid_name_key,
|
||||
update_columns: [treatment, config, activeDate, deactiveDate]
|
||||
}
|
||||
) {
|
||||
affected_rows
|
||||
}
|
||||
}`,
|
||||
{ bodyshopid: bodyshopId, flagNames, objects }
|
||||
);
|
||||
}
|
||||
|
||||
exports.updateShop = async (req, res) => {
|
||||
logger.log("admin-update-shop", "debug", req.user.email, null, {
|
||||
request: req.body,
|
||||
ioadmin: true
|
||||
});
|
||||
const { id, bodyshop } = req.body;
|
||||
const { id } = req.body;
|
||||
const { bodyshop, featureFlags } = splitBodyshopUpdate(req.body.bodyshop);
|
||||
|
||||
try {
|
||||
const result = await client.request(
|
||||
@@ -132,6 +580,13 @@ exports.updateShop = async (req, res) => {
|
||||
bodyshop
|
||||
}
|
||||
);
|
||||
|
||||
const featureFlagResult = await saveBodyshopFeatureFlags({ bodyshopId: id, featureFlags });
|
||||
if (featureFlagResult) {
|
||||
await req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis?.(id);
|
||||
emitFeatureFlagsChanged({ req, bodyshopId: id, source: "admin", table: "bodyshop_feature_flags" });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json(error);
|
||||
|
||||
44
server/feature-flags/admin-payload.js
Normal file
44
server/feature-flags/admin-payload.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const CREATE_FIELDS = ["name", "description", "default_treatment", "active"];
|
||||
const UPDATE_FIELDS = ["description", "default_treatment", "active"];
|
||||
|
||||
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
/**
|
||||
* Trims stable feature flag identity fields while leaving free-form text unchanged.
|
||||
*/
|
||||
const normalizeStringField = (key, value) => {
|
||||
if (value == null) return value;
|
||||
if (key !== "name" && key !== "default_treatment") return value;
|
||||
return typeof value === "string" ? value.trim() : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whitelists fields accepted by feature flag definition create/update admin endpoints.
|
||||
*/
|
||||
const pickFeatureFlagFields = (input, allowedFields) => {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return allowedFields.reduce((payload, field) => {
|
||||
if (hasOwn(input, field)) {
|
||||
payload[field] = normalizeStringField(field, input[field]);
|
||||
}
|
||||
return payload;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a safe payload for creating a feature flag definition.
|
||||
*/
|
||||
const sanitizeFeatureFlagCreatePayload = (input) => pickFeatureFlagFields(input, CREATE_FIELDS);
|
||||
|
||||
/**
|
||||
* Builds a safe payload for updating editable feature flag definition fields.
|
||||
*/
|
||||
const sanitizeFeatureFlagUpdatePayload = (input) => pickFeatureFlagFields(input, UPDATE_FIELDS);
|
||||
|
||||
module.exports = {
|
||||
sanitizeFeatureFlagCreatePayload,
|
||||
sanitizeFeatureFlagUpdatePayload
|
||||
};
|
||||
53
server/feature-flags/admin-payload.test.js
Normal file
53
server/feature-flags/admin-payload.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
sanitizeFeatureFlagCreatePayload,
|
||||
sanitizeFeatureFlagUpdatePayload
|
||||
} = require("./admin-payload");
|
||||
|
||||
describe("feature flag admin payload sanitizing", () => {
|
||||
it("keeps only editable create fields and trims stable text keys", () => {
|
||||
expect(
|
||||
sanitizeFeatureFlagCreatePayload({
|
||||
name: " TEST_FLAG ",
|
||||
description: "Manual test flag",
|
||||
default_treatment: " variant-a ",
|
||||
active: false,
|
||||
created_at: "2026-05-19T00:00:00.000Z",
|
||||
bodyshop_feature_flags_aggregate: { aggregate: { count: 3 } }
|
||||
})
|
||||
).toEqual({
|
||||
name: "TEST_FLAG",
|
||||
description: "Manual test flag",
|
||||
default_treatment: "variant-a",
|
||||
active: false
|
||||
});
|
||||
});
|
||||
|
||||
it("strips name from update payloads so flag keys cannot be renamed", () => {
|
||||
expect(
|
||||
sanitizeFeatureFlagUpdatePayload({
|
||||
name: "Renamed_Flag",
|
||||
description: null,
|
||||
default_treatment: " on ",
|
||||
active: true,
|
||||
updated_at: "2026-05-19T00:00:00.000Z"
|
||||
})
|
||||
).toEqual({
|
||||
description: null,
|
||||
default_treatment: "on",
|
||||
active: true
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty update payload when no editable fields are present", () => {
|
||||
expect(
|
||||
sanitizeFeatureFlagUpdatePayload({
|
||||
name: "Only_Name",
|
||||
created_at: "2026-05-19T00:00:00.000Z"
|
||||
})
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
54
server/feature-flags/export-harness-feature-flags.test.js
Normal file
54
server/feature-flags/export-harness-feature-flags.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildImportSql, normalizeTreatment, sqlString } = require("../../scripts/export-harness-feature-flags");
|
||||
|
||||
describe("Harness feature flag exporter", () => {
|
||||
it("preserves custom treatment names while normalizing booleans and known treatments", () => {
|
||||
expect(normalizeTreatment(true)).toBe("on");
|
||||
expect(normalizeTreatment(false)).toBe("off");
|
||||
expect(normalizeTreatment(" ON ")).toBe("on");
|
||||
expect(normalizeTreatment("false")).toBe("off");
|
||||
expect(normalizeTreatment("control")).toBe("control");
|
||||
expect(normalizeTreatment("variant-a")).toBe("variant-a");
|
||||
expect(normalizeTreatment(" custom treatment ")).toBe("custom treatment");
|
||||
expect(normalizeTreatment(null)).toBe("control");
|
||||
expect(normalizeTreatment("")).toBe("control");
|
||||
});
|
||||
|
||||
it("escapes SQL string values", () => {
|
||||
expect(sqlString("Dave's Shop")).toBe("'Dave''s Shop'");
|
||||
});
|
||||
|
||||
it("escapes custom treatments in generated import SQL", () => {
|
||||
const sql = buildImportSql([
|
||||
{
|
||||
customerKey: "SHOP'1",
|
||||
imexshopid: "SHOP'1",
|
||||
name: "Demo'Flag",
|
||||
treatment: "pilot's-choice",
|
||||
config: { text: "Dave's config" }
|
||||
}
|
||||
]);
|
||||
|
||||
expect(sql).toContain("('SHOP''1', 'Demo''Flag', 'pilot''s-choice'");
|
||||
expect(sql).toContain(`'{"text":"Dave''s config"}'::jsonb`);
|
||||
});
|
||||
|
||||
it("includes an unmatched feature flag report query", () => {
|
||||
const sql = buildImportSql([
|
||||
{
|
||||
customerKey: "SHOP1",
|
||||
imexshopid: "SHOP1",
|
||||
name: "Missing_Flag",
|
||||
treatment: "on",
|
||||
config: null
|
||||
}
|
||||
]);
|
||||
|
||||
expect(sql).toContain('AS "unmatched_feature_flag"');
|
||||
expect(sql).toContain('LEFT JOIN "public"."feature_flags"');
|
||||
expect(sql).toContain("('Missing_Flag')");
|
||||
});
|
||||
});
|
||||
144
server/feature-flags/feature-flags.js
Normal file
144
server/feature-flags/feature-flags.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const logger = require("../utils/logger");
|
||||
const { CHECK_BODYSHOP_ACCESS, GET_BODYSHOP_FEATURE_FLAGS } = require("../graphql-client/queries");
|
||||
const { emitFeatureFlagsChanged } = require("./socket-events");
|
||||
|
||||
/**
|
||||
* Indicates whether verbose feature flag route logging should be enabled.
|
||||
*/
|
||||
const isDevelopment = () => process.env.NODE_ENV === "development";
|
||||
|
||||
/**
|
||||
* Combines global feature flag definitions with per-bodyshop assignments into the runtime flag map.
|
||||
*/
|
||||
const toFlagMap = ({ feature_flags: definitions = [], bodyshop_feature_flags: assignments = [] }) => {
|
||||
const flags = definitions.reduce((acc, definition) => {
|
||||
acc[definition.name] = {
|
||||
treatment: definition.default_treatment || "off",
|
||||
config: null
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const assignment of assignments) {
|
||||
flags[assignment.name] = {
|
||||
treatment: assignment.treatment,
|
||||
config: assignment.config ?? null,
|
||||
activeDate: assignment.activeDate ?? null,
|
||||
deactiveDate: assignment.deactiveDate ?? null
|
||||
};
|
||||
}
|
||||
|
||||
return flags;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies that the authenticated user can read the requested bodyshop through Hasura permissions.
|
||||
*/
|
||||
async function assertBodyshopAccess({ req, bodyshopId }) {
|
||||
const result = await req.userGraphQLClient.request(CHECK_BODYSHOP_ACCESS, { id: bodyshopId });
|
||||
|
||||
if (!result.bodyshops_by_pk?.id) {
|
||||
const error = new Error("Feature flag bodyshop access denied");
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves runtime feature flags for one bodyshop with Redis read-through caching.
|
||||
*/
|
||||
async function getBodyshopFeatureFlags(req, res) {
|
||||
const bodyshopId = req.params.bodyshopId;
|
||||
const {
|
||||
getBodyshopFeatureFlagsCacheVersion,
|
||||
getBodyshopFeatureFlagsFromRedis,
|
||||
setBodyshopFeatureFlagsInRedis
|
||||
} = req.sessionUtils || {};
|
||||
|
||||
try {
|
||||
await assertBodyshopAccess({ req, bodyshopId });
|
||||
|
||||
const cacheVersion = getBodyshopFeatureFlagsCacheVersion
|
||||
? await getBodyshopFeatureFlagsCacheVersion()
|
||||
: null;
|
||||
const cachedFlags = getBodyshopFeatureFlagsFromRedis
|
||||
? await getBodyshopFeatureFlagsFromRedis(bodyshopId, cacheVersion)
|
||||
: null;
|
||||
|
||||
if (cachedFlags) {
|
||||
if (isDevelopment()) {
|
||||
logger.log("feature-flags-route-hit", "DEBUG", req.user?.email, null, {
|
||||
bodyshopId,
|
||||
source: "redis",
|
||||
flagCount: Object.keys(cachedFlags.flags || {}).length
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ ...cachedFlags, source: "redis" });
|
||||
}
|
||||
|
||||
const result = await req.userGraphQLClient.request(GET_BODYSHOP_FEATURE_FLAGS, { bodyshopid: bodyshopId });
|
||||
const payload = {
|
||||
bodyshopId,
|
||||
flags: toFlagMap(result),
|
||||
cachedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (setBodyshopFeatureFlagsInRedis) {
|
||||
await setBodyshopFeatureFlagsInRedis(bodyshopId, payload, cacheVersion);
|
||||
}
|
||||
|
||||
if (isDevelopment()) {
|
||||
logger.log("feature-flags-route-hit", "DEBUG", req.user?.email, null, {
|
||||
bodyshopId,
|
||||
source: "database",
|
||||
flagCount: Object.keys(payload.flags || {}).length
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ ...payload, source: "database" });
|
||||
} catch (error) {
|
||||
const statusCode = error.statusCode || 500;
|
||||
logger.log("get-bodyshop-feature-flags-error", "ERROR", req.user?.email, null, {
|
||||
bodyshopId,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return res.status(statusCode).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Hasura/admin cache invalidation events and notifies connected clients.
|
||||
*/
|
||||
async function invalidateBodyshopFeatureFlags(req, res) {
|
||||
const bodyshopId = req.body?.event?.data?.new?.bodyshopid || req.body?.event?.data?.old?.bodyshopid || req.body?.bodyshopid;
|
||||
const tableName = req.body?.event?.table?.name;
|
||||
const flagName = req.body?.event?.data?.new?.name || req.body?.event?.data?.old?.name || req.body?.name || null;
|
||||
|
||||
try {
|
||||
if (bodyshopId && req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis) {
|
||||
await req.sessionUtils.invalidateBodyshopFeatureFlagsInRedis(bodyshopId);
|
||||
emitFeatureFlagsChanged({ req, bodyshopId, source: "hasura", table: tableName, name: flagName });
|
||||
return res.status(200).json({ ok: true, bodyshopId });
|
||||
}
|
||||
|
||||
const invalidated = await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
|
||||
emitFeatureFlagsChanged({ req, source: "hasura", table: tableName, name: flagName });
|
||||
return res.status(200).json({ ok: true, table: tableName, cacheVersion: invalidated || 0 });
|
||||
} catch (error) {
|
||||
logger.log("invalidate-bodyshop-feature-flags-error", "ERROR", "feature-flags", null, {
|
||||
bodyshopId,
|
||||
tableName,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBodyshopFeatureFlags,
|
||||
invalidateBodyshopFeatureFlags
|
||||
};
|
||||
197
server/feature-flags/feature-flags.test.js
Normal file
197
server/feature-flags/feature-flags.test.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { getBodyshopFeatureFlags, invalidateBodyshopFeatureFlags } = require("./feature-flags");
|
||||
|
||||
/**
|
||||
* Creates the minimal Express response mock needed by feature flag route tests.
|
||||
*/
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
body: null,
|
||||
code: 200,
|
||||
json: vi.fn((body) => {
|
||||
res.body = body;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code) => {
|
||||
res.code = code;
|
||||
return res;
|
||||
})
|
||||
};
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
describe("feature flag runtime route", () => {
|
||||
it("returns cached flags without re-querying runtime assignments", async () => {
|
||||
const req = {
|
||||
params: { bodyshopId: "shop-1" },
|
||||
sessionUtils: {
|
||||
getBodyshopFeatureFlagsCacheVersion: vi.fn(async () => "7"),
|
||||
getBodyshopFeatureFlagsFromRedis: vi.fn(async () => ({
|
||||
bodyshopId: "shop-1",
|
||||
flags: {
|
||||
Demo: { treatment: "on", config: null }
|
||||
}
|
||||
}))
|
||||
},
|
||||
user: { email: "tester@example.com" },
|
||||
userGraphQLClient: {
|
||||
request: vi.fn(async () => ({ bodyshops_by_pk: { id: "shop-1" } }))
|
||||
}
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await getBodyshopFeatureFlags(req, res);
|
||||
|
||||
expect(req.sessionUtils.getBodyshopFeatureFlagsFromRedis).toHaveBeenCalledWith("shop-1", "7");
|
||||
expect(req.userGraphQLClient.request).toHaveBeenCalledTimes(1);
|
||||
expect(res.body).toEqual({
|
||||
bodyshopId: "shop-1",
|
||||
flags: {
|
||||
Demo: { treatment: "on", config: null }
|
||||
},
|
||||
source: "redis"
|
||||
});
|
||||
});
|
||||
|
||||
it("merges active definitions with bodyshop assignments on cache miss", async () => {
|
||||
const req = {
|
||||
params: { bodyshopId: "shop-1" },
|
||||
sessionUtils: {
|
||||
getBodyshopFeatureFlagsCacheVersion: vi.fn(async () => "3"),
|
||||
getBodyshopFeatureFlagsFromRedis: vi.fn(async () => null),
|
||||
setBodyshopFeatureFlagsInRedis: vi.fn()
|
||||
},
|
||||
user: { email: "tester@example.com" },
|
||||
userGraphQLClient: {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ bodyshops_by_pk: { id: "shop-1" } })
|
||||
.mockResolvedValueOnce({
|
||||
feature_flags: [
|
||||
{ name: "Default_Off", default_treatment: "off" },
|
||||
{ name: "Default_Custom", default_treatment: "variant-a" }
|
||||
],
|
||||
bodyshop_feature_flags: [
|
||||
{
|
||||
name: "Default_Off",
|
||||
treatment: "on",
|
||||
config: { limit: 10 },
|
||||
activeDate: "2026-05-19T15:00:00.000Z",
|
||||
deactiveDate: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await getBodyshopFeatureFlags(req, res);
|
||||
|
||||
expect(req.sessionUtils.setBodyshopFeatureFlagsInRedis).toHaveBeenCalledWith(
|
||||
"shop-1",
|
||||
expect.objectContaining({
|
||||
bodyshopId: "shop-1",
|
||||
flags: {
|
||||
Default_Off: {
|
||||
treatment: "on",
|
||||
config: { limit: 10 },
|
||||
activeDate: "2026-05-19T15:00:00.000Z",
|
||||
deactiveDate: null
|
||||
},
|
||||
Default_Custom: {
|
||||
treatment: "variant-a",
|
||||
config: null
|
||||
}
|
||||
}
|
||||
}),
|
||||
"3"
|
||||
);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
bodyshopId: "shop-1",
|
||||
flags: {
|
||||
Default_Off: {
|
||||
treatment: "on",
|
||||
config: { limit: 10 },
|
||||
activeDate: "2026-05-19T15:00:00.000Z",
|
||||
deactiveDate: null
|
||||
},
|
||||
Default_Custom: {
|
||||
treatment: "variant-a",
|
||||
config: null
|
||||
}
|
||||
},
|
||||
source: "database"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature flag cache invalidation route", () => {
|
||||
it("invalidates one bodyshop when a bodyshop assignment event is received", async () => {
|
||||
const emit = vi.fn();
|
||||
const req = {
|
||||
body: {
|
||||
event: {
|
||||
table: { name: "bodyshop_feature_flags" },
|
||||
data: {
|
||||
new: { bodyshopid: "shop-1" }
|
||||
}
|
||||
}
|
||||
},
|
||||
ioHelpers: {
|
||||
getBodyshopRoom: vi.fn(() => "bodyshop-room-shop-1")
|
||||
},
|
||||
ioRedis: {
|
||||
to: vi.fn(() => ({ emit }))
|
||||
},
|
||||
sessionUtils: {
|
||||
invalidateBodyshopFeatureFlagsInRedis: vi.fn()
|
||||
}
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await invalidateBodyshopFeatureFlags(req, res);
|
||||
|
||||
expect(req.sessionUtils.invalidateBodyshopFeatureFlagsInRedis).toHaveBeenCalledWith("shop-1");
|
||||
expect(req.ioRedis.to).toHaveBeenCalledWith("bodyshop-room-shop-1");
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
"feature-flags-changed",
|
||||
expect.objectContaining({ bodyshopId: "shop-1", scope: "bodyshop", source: "hasura", table: "bodyshop_feature_flags" })
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true, bodyshopId: "shop-1" });
|
||||
});
|
||||
|
||||
it("bumps global cache version when a feature definition event is received", async () => {
|
||||
const req = {
|
||||
body: {
|
||||
event: {
|
||||
table: { name: "feature_flags" },
|
||||
data: {
|
||||
new: { name: "Demo" }
|
||||
}
|
||||
}
|
||||
},
|
||||
ioRedis: {
|
||||
emit: vi.fn()
|
||||
},
|
||||
sessionUtils: {
|
||||
invalidateAllBodyshopFeatureFlagsInRedis: vi.fn(async () => 12)
|
||||
}
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await invalidateBodyshopFeatureFlags(req, res);
|
||||
|
||||
expect(req.sessionUtils.invalidateAllBodyshopFeatureFlagsInRedis).toHaveBeenCalled();
|
||||
expect(req.ioRedis.emit).toHaveBeenCalledWith(
|
||||
"feature-flags-changed",
|
||||
expect.objectContaining({ name: "Demo", scope: "global", source: "hasura", table: "feature_flags" })
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true, table: "feature_flags", cacheVersion: 12 });
|
||||
});
|
||||
});
|
||||
40
server/feature-flags/socket-events.js
Normal file
40
server/feature-flags/socket-events.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const FEATURE_FLAGS_CHANGED_EVENT = "feature-flags-changed";
|
||||
|
||||
/**
|
||||
* Creates the Socket.IO payload used to tell browsers that feature flags changed.
|
||||
*/
|
||||
const createFeatureFlagsChangedPayload = ({ bodyshopId = null, source = "unknown", table = null, name = null } = {}) => ({
|
||||
bodyshopId,
|
||||
changedAt: new Date().toISOString(),
|
||||
name,
|
||||
scope: bodyshopId ? "bodyshop" : "global",
|
||||
source,
|
||||
table
|
||||
});
|
||||
|
||||
/**
|
||||
* Emits a feature-flag change event globally or to one bodyshop room.
|
||||
*/
|
||||
const emitFeatureFlagsChanged = ({ req, bodyshopId = null, source = "unknown", table = null, name = null } = {}) => {
|
||||
const io = req?.ioRedis;
|
||||
if (!io) return null;
|
||||
|
||||
const payload = createFeatureFlagsChangedPayload({ bodyshopId, source, table, name });
|
||||
|
||||
if (bodyshopId) {
|
||||
const room = req?.ioHelpers?.getBodyshopRoom
|
||||
? req.ioHelpers.getBodyshopRoom(bodyshopId)
|
||||
: `bodyshop-broadcast-room:${bodyshopId}`;
|
||||
io.to(room).emit(FEATURE_FLAGS_CHANGED_EVENT, payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.emit(FEATURE_FLAGS_CHANGED_EVENT, payload);
|
||||
return payload;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
FEATURE_FLAGS_CHANGED_EVENT,
|
||||
createFeatureFlagsChangedPayload,
|
||||
emitFeatureFlagsChanged
|
||||
};
|
||||
60
server/feature-flags/socket-events.test.js
Normal file
60
server/feature-flags/socket-events.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
FEATURE_FLAGS_CHANGED_EVENT,
|
||||
createFeatureFlagsChangedPayload,
|
||||
emitFeatureFlagsChanged
|
||||
} = require("./socket-events");
|
||||
|
||||
describe("feature flag socket events", () => {
|
||||
it("creates a global payload when no bodyshop id is provided", () => {
|
||||
expect(createFeatureFlagsChangedPayload({ source: "admin", table: "feature_flags", name: "Demo" })).toEqual(
|
||||
expect.objectContaining({
|
||||
bodyshopId: null,
|
||||
name: "Demo",
|
||||
scope: "global",
|
||||
source: "admin",
|
||||
table: "feature_flags"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("emits bodyshop-scoped changes to the bodyshop room", () => {
|
||||
const emit = vi.fn();
|
||||
const req = {
|
||||
ioHelpers: {
|
||||
getBodyshopRoom: vi.fn(() => "bodyshop-room-shop-1")
|
||||
},
|
||||
ioRedis: {
|
||||
to: vi.fn(() => ({ emit }))
|
||||
}
|
||||
};
|
||||
|
||||
const payload = emitFeatureFlagsChanged({
|
||||
req,
|
||||
bodyshopId: "shop-1",
|
||||
source: "hasura",
|
||||
table: "bodyshop_feature_flags",
|
||||
name: "Demo"
|
||||
});
|
||||
|
||||
expect(req.ioRedis.to).toHaveBeenCalledWith("bodyshop-room-shop-1");
|
||||
expect(emit).toHaveBeenCalledWith(FEATURE_FLAGS_CHANGED_EVENT, payload);
|
||||
expect(payload).toEqual(expect.objectContaining({ bodyshopId: "shop-1", scope: "bodyshop" }));
|
||||
});
|
||||
|
||||
it("broadcasts global changes to all sockets", () => {
|
||||
const req = {
|
||||
ioRedis: {
|
||||
emit: vi.fn()
|
||||
}
|
||||
};
|
||||
|
||||
const payload = emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags" });
|
||||
|
||||
expect(req.ioRedis.emit).toHaveBeenCalledWith(FEATURE_FLAGS_CHANGED_EVENT, payload);
|
||||
expect(payload).toEqual(expect.objectContaining({ bodyshopId: null, scope: "global" }));
|
||||
});
|
||||
});
|
||||
@@ -2968,6 +2968,30 @@ exports.GET_BODYSHOP_BY_ID = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports.CHECK_BODYSHOP_ACCESS = `
|
||||
query CHECK_BODYSHOP_ACCESS($id: uuid!) {
|
||||
bodyshops_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.GET_BODYSHOP_FEATURE_FLAGS = `
|
||||
query GET_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
|
||||
feature_flags(where: { active: { _eq: true } }, order_by: { name: asc }) {
|
||||
name
|
||||
default_treatment
|
||||
}
|
||||
bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }, order_by: { name: asc }) {
|
||||
name
|
||||
treatment
|
||||
config
|
||||
activeDate
|
||||
deactiveDate
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
exports.GET_BODYSHOP_WATCHERS_BY_ID = `
|
||||
query GET_BODYSHOP_BY_ID($id: uuid!) {
|
||||
bodyshops_by_pk(id: $id) {
|
||||
@@ -3356,4 +3380,4 @@ exports.GET_DOCUMENSO_KEY_BY_JOBID = `query GET_DOCUMENSO_KEY_BY_JOBID($jobid: u
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops");
|
||||
const {
|
||||
createAssociation,
|
||||
createShop,
|
||||
updateShop,
|
||||
updateCounter,
|
||||
getFeatureFlags,
|
||||
getBodyshopFeatureFlags,
|
||||
createFeatureFlag,
|
||||
updateFeatureFlag,
|
||||
deleteFeatureFlag,
|
||||
getFeatureFlagBodyshops,
|
||||
updateFeatureFlagBodyshops
|
||||
} = require("../admin/adminops");
|
||||
const { updateUser, getUser, createUser, getWelcomeEmail, getResetLink } = require("../firebase/firebase-handler");
|
||||
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
|
||||
|
||||
@@ -12,6 +24,13 @@ router.post("/createassociation", createAssociation);
|
||||
router.post("/createshop", createShop);
|
||||
router.post("/updateshop", updateShop);
|
||||
router.post("/updatecounter", updateCounter);
|
||||
router.get("/feature-flags", getFeatureFlags);
|
||||
router.post("/feature-flags", createFeatureFlag);
|
||||
router.get("/feature-flags/:name/bodyshops", getFeatureFlagBodyshops);
|
||||
router.put("/feature-flags/:name/bodyshops", updateFeatureFlagBodyshops);
|
||||
router.put("/feature-flags/:name", updateFeatureFlag);
|
||||
router.delete("/feature-flags/:name", deleteFeatureFlag);
|
||||
router.get("/bodyshops/:bodyshopId/feature-flags", getBodyshopFeatureFlags);
|
||||
router.post("/updateuser", updateUser);
|
||||
router.post("/getuser", getUser);
|
||||
router.post("/createuser", createUser);
|
||||
|
||||
27
server/routes/featureFlagRoutes.js
Normal file
27
server/routes/featureFlagRoutes.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const express = require("express");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
|
||||
const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware");
|
||||
const {
|
||||
getBodyshopFeatureFlags,
|
||||
invalidateBodyshopFeatureFlags
|
||||
} = require("../feature-flags/feature-flags");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Returns runtime feature flags for a bodyshop the authenticated user can access.
|
||||
*/
|
||||
router.get(
|
||||
"/bodyshops/:bodyshopId",
|
||||
validateFirebaseIdTokenMiddleware,
|
||||
withUserGraphQLClientMiddleware,
|
||||
getBodyshopFeatureFlags
|
||||
);
|
||||
|
||||
/**
|
||||
* Receives Hasura event-trigger callbacks that invalidate feature flag runtime caches.
|
||||
*/
|
||||
router.post("/cache/invalidate", eventAuthorizationMiddleware, invalidateBodyshopFeatureFlags);
|
||||
|
||||
module.exports = router;
|
||||
103
server/utils/redisHelpers.feature-flags.test.js
Normal file
103
server/utils/redisHelpers.feature-flags.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { applyRedisHelpers } = require("./redisHelpers");
|
||||
|
||||
/**
|
||||
* Creates an in-memory Redis-like test double for feature flag cache helper tests.
|
||||
*/
|
||||
const createRedis = () => {
|
||||
const values = new Map();
|
||||
const expirations = new Map();
|
||||
|
||||
return {
|
||||
del: vi.fn(async (...keys) => {
|
||||
let deleted = 0;
|
||||
for (const key of keys) {
|
||||
if (values.delete(key)) deleted += 1;
|
||||
}
|
||||
return deleted;
|
||||
}),
|
||||
expire: vi.fn(async (key, ttl) => {
|
||||
expirations.set(key, ttl);
|
||||
return 1;
|
||||
}),
|
||||
get: vi.fn(async (key) => values.get(key) ?? null),
|
||||
incr: vi.fn(async (key) => {
|
||||
const nextValue = Number(values.get(key) || 0) + 1;
|
||||
values.set(key, String(nextValue));
|
||||
return nextValue;
|
||||
}),
|
||||
set: vi.fn(async (key, value) => {
|
||||
values.set(key, String(value));
|
||||
return "OK";
|
||||
}),
|
||||
setnx: vi.fn(async (key, value) => {
|
||||
if (values.has(key)) return 0;
|
||||
values.set(key, String(value));
|
||||
return 1;
|
||||
}),
|
||||
values,
|
||||
expirations
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies Redis helpers to the in-memory test double and returns the mounted API.
|
||||
*/
|
||||
const createHelpers = () => {
|
||||
const pubClient = createRedis();
|
||||
const app = { use: vi.fn() };
|
||||
const logger = { log: vi.fn() };
|
||||
const helpers = applyRedisHelpers({ pubClient, app, logger });
|
||||
|
||||
return { app, helpers, logger, pubClient };
|
||||
};
|
||||
|
||||
describe("feature flag Redis cache helpers", () => {
|
||||
it("stores and reads bodyshop feature flags under the current cache version", async () => {
|
||||
const { helpers, pubClient } = createHelpers();
|
||||
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
|
||||
|
||||
expect(await helpers.getBodyshopFeatureFlagsCacheVersion()).toBe("1");
|
||||
expect(await helpers.getBodyshopFeatureFlagsFromRedis("shop-1")).toEqual({
|
||||
flags: {
|
||||
Demo: {
|
||||
treatment: "on"
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
|
||||
expect(pubClient.expirations.get("bodyshop-feature-flags:v1:shop-1")).toBe(3600);
|
||||
});
|
||||
|
||||
it("global invalidation bumps the cache version instead of deleting old versioned keys", async () => {
|
||||
const { helpers, pubClient } = createHelpers();
|
||||
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
|
||||
const nextVersion = await helpers.invalidateAllBodyshopFeatureFlagsInRedis();
|
||||
|
||||
expect(nextVersion).toBe(2);
|
||||
expect(pubClient.del).not.toHaveBeenCalledWith("bodyshop-feature-flags:v1:shop-1");
|
||||
expect(await helpers.getBodyshopFeatureFlagsFromRedis("shop-1")).toBeNull();
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("bodyshop invalidation deletes only the current version cache key for that shop", async () => {
|
||||
const { helpers, pubClient } = createHelpers();
|
||||
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-2", { flags: { Demo: { treatment: "on" } } });
|
||||
await helpers.invalidateAllBodyshopFeatureFlagsInRedis();
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "off" } } });
|
||||
await helpers.setBodyshopFeatureFlagsInRedis("shop-2", { flags: { Demo: { treatment: "on" } } });
|
||||
|
||||
await helpers.invalidateBodyshopFeatureFlagsInRedis("shop-1");
|
||||
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v2:shop-1")).toBe(false);
|
||||
expect(pubClient.values.has("bodyshop-feature-flags:v2:shop-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ const client = require("../graphql-client/graphql-client").client;
|
||||
* @type {number}
|
||||
*/
|
||||
const BODYSHOP_CACHE_TTL = 3600; // 1 hour
|
||||
const FEATURE_FLAGS_CACHE_TTL = 3600; // 1 hour
|
||||
const FEATURE_FLAGS_CACHE_VERSION_KEY = "bodyshop-feature-flags:version";
|
||||
|
||||
/**
|
||||
* Chatter API token cache TTL in seconds
|
||||
@@ -20,6 +22,7 @@ const CHATTER_TOKEN_CACHE_TTL = 3600; // 1 hour
|
||||
* @returns {`bodyshop-cache:${string}`}
|
||||
*/
|
||||
const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`;
|
||||
const getBodyshopFeatureFlagsCacheKey = (bodyshopId, version = "1") => `bodyshop-feature-flags:v${version}:${bodyshopId}`;
|
||||
|
||||
/**
|
||||
* Generate a cache key for a Chatter API token
|
||||
@@ -418,6 +421,92 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads or initializes the global feature flag cache version used by per-shop keys.
|
||||
*/
|
||||
const getBodyshopFeatureFlagsCacheVersion = async () => {
|
||||
try {
|
||||
const version = await pubClient.get(FEATURE_FLAGS_CACHE_VERSION_KEY);
|
||||
if (version) return version;
|
||||
|
||||
await pubClient.setnx(FEATURE_FLAGS_CACHE_VERSION_KEY, "1");
|
||||
return "1";
|
||||
} catch (error) {
|
||||
logger.log("get-bodyshop-feature-flags-cache-version", "ERROR", "redis", null, {
|
||||
error: error.message
|
||||
});
|
||||
return "1";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a bodyshop feature flag payload from the current or supplied cache version.
|
||||
*/
|
||||
const getBodyshopFeatureFlagsFromRedis = async (bodyshopId, version) => {
|
||||
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
|
||||
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
|
||||
|
||||
try {
|
||||
const cachedData = await pubClient.get(key);
|
||||
return cachedData ? JSON.parse(cachedData) : null;
|
||||
} catch (error) {
|
||||
logger.log("get-bodyshop-feature-flags-from-redis", "ERROR", "redis", null, {
|
||||
bodyshopId,
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stores a bodyshop feature flag payload under a versioned Redis key.
|
||||
*/
|
||||
const setBodyshopFeatureFlagsInRedis = async (bodyshopId, value, version) => {
|
||||
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
|
||||
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
|
||||
|
||||
try {
|
||||
await pubClient.set(key, toRedisJson(value));
|
||||
await pubClient.expire(key, FEATURE_FLAGS_CACHE_TTL);
|
||||
} catch (error) {
|
||||
logger.log("set-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
|
||||
bodyshopId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes one bodyshop's feature flag cache entry for the current or supplied version.
|
||||
*/
|
||||
const invalidateBodyshopFeatureFlagsInRedis = async (bodyshopId, version) => {
|
||||
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
|
||||
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
|
||||
|
||||
try {
|
||||
await pubClient.del(key);
|
||||
} catch (error) {
|
||||
logger.log("invalidate-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
|
||||
bodyshopId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidates all bodyshop feature flag caches by incrementing the global version.
|
||||
*/
|
||||
const invalidateAllBodyshopFeatureFlagsInRedis = async () => {
|
||||
try {
|
||||
return await pubClient.incr(FEATURE_FLAGS_CACHE_VERSION_KEY);
|
||||
} catch (error) {
|
||||
logger.log("invalidate-all-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
|
||||
error: error.message
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set provider cache data
|
||||
* @param ns
|
||||
@@ -482,6 +571,7 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
const api = {
|
||||
getUserSocketMappingKey,
|
||||
getBodyshopCacheKey,
|
||||
getBodyshopFeatureFlagsCacheKey,
|
||||
getChatterTokenCacheKey,
|
||||
setSessionData,
|
||||
getSessionData,
|
||||
@@ -493,6 +583,11 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
refreshUserSocketTTL,
|
||||
getBodyshopFromRedis,
|
||||
updateOrInvalidateBodyshopFromRedis,
|
||||
getBodyshopFeatureFlagsCacheVersion,
|
||||
getBodyshopFeatureFlagsFromRedis,
|
||||
setBodyshopFeatureFlagsInRedis,
|
||||
invalidateBodyshopFeatureFlagsInRedis,
|
||||
invalidateAllBodyshopFeatureFlagsInRedis,
|
||||
setSessionTransactionData,
|
||||
getSessionTransactionData,
|
||||
clearSessionTransactionData,
|
||||
|
||||
Reference in New Issue
Block a user