Merged in feature/IO-3701-Harness-Replacement (pull request #3261)

feature/IO-3701-Harness-Replacement - Implement
This commit is contained in:
Dave Richer
2026-05-25 15:44:04 +00:00
100 changed files with 4814 additions and 774 deletions

View File

@@ -7,6 +7,7 @@ _reference
client
redis/dockerdata
hasura
harness-feature-flags-export
node_modules
# Files to exclude
.ebignore

View File

@@ -7,6 +7,7 @@
/client
/firebase
/hasura
/harness-feature-flags-export
/jsreport
/node_modules
.env.local

5
.gitignore vendored
View File

@@ -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

File diff suppressed because it is too large Load Diff

302
client/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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";

View File

@@ -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,

View File

@@ -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:

View File

@@ -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({

View File

@@ -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>
);

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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";

View File

@@ -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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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({

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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" }) {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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: {

View File

@@ -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";

View File

@@ -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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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

View File

@@ -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,

View File

@@ -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({

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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) {

View 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.

View 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
};

View 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);
});
});

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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",

View File

@@ -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

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS "public"."feature_flags";

View File

@@ -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;

View File

@@ -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";

View File

@@ -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';

View File

@@ -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'));

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
DELETE FROM "public"."feature_flags"
WHERE "name" = 'TEST_FLAG';

View File

@@ -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;

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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"));

View 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();
});
});

View File

@@ -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);

View 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
};

View 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({});
});
});

View 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')");
});
});

View 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
};

View 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 });
});
});

View 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
};

View 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" }));
});
});

View File

@@ -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
}
}
}
`
`

View File

@@ -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);

View 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;

View 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);
});
});

View File

@@ -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,