diff --git a/.dockerignore b/.dockerignore index 98f226b22..b0efd7d4a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ _reference client redis/dockerdata hasura +harness-feature-flags-export node_modules # Files to exclude .ebignore diff --git a/.ebignore b/.ebignore index 4043a0f46..fd1dd951f 100644 --- a/.ebignore +++ b/.ebignore @@ -7,6 +7,7 @@ /client /firebase /hasura +/harness-feature-flags-export /jsreport /node_modules .env.local diff --git a/.gitignore b/.gitignore index 39129a59e..9decd50c6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +terraform.tfvars diff --git a/_reference/feature-flags.md b/_reference/feature-flags.md new file mode 100644 index 000000000..e58332d2f --- /dev/null +++ b/_reference/feature-flags.md @@ -0,0 +1,1297 @@ +# Feature Flags + +Last updated: 2026-05-19 + +## Purpose + +This document describes the in-house feature flag system that replaces the old Split/Harness runtime dependency for the +bodyshop application. + +The goal of this system is to be a practical drop-in replacement for the existing Split usage in the front end while +letting us manage feature flag definitions and bodyshop assignments from our own admin panel. It uses: + +- Hasura/Postgres for durable flag definitions and per-bodyshop assignments. +- The bodyshop backend for authenticated runtime flag evaluation. +- Redis for short-lived runtime response caching. +- Hasura event triggers plus admin-side invalidation for cache freshness. +- The admin panel for creating, editing, deleting, and assigning flags. +- A local compatibility module in the bodyshop client so existing Split-shaped hooks/components keep their API shape. + +The important design choice is that the bodyshop application does not talk to Harness/Split at runtime anymore. It asks +our backend for feature flags for the active bodyshop, and the backend reads our database. + +## Current Status + +The system currently supports: + +- Global feature flag definitions. +- Per-bodyshop flag assignments. +- Optional `activeDate` and `deactiveDate` scheduling on per-bodyshop assignments. +- Custom treatment values, not just `on`, `off`, and `control`. +- Optional JSON config per assignment. +- Runtime Redis caching. +- Targeted cache invalidation when one bodyshop's flag assignments change. +- Global cache invalidation when feature flag definitions change. +- Live browser refresh through the existing Socket.IO connection when flags change. +- Admin CRUD for flag definitions. +- Admin assignment management from both the bodyshop edit screen and the flag CRUD screen. +- Importing exported Harness/Split assignments by matching Harness target keys to `bodyshops.imexshopid`. +- A temporary footer indicator that displays `Test Feature Flag Enabled` when `TEST_FLAG` is `on`. + +## Glossary + +### Feature Flag Definition + +A global registry row in `public.feature_flags`. + +This answers: + +- What is the flag called? +- Is this flag globally active? +- What is the default treatment when a bodyshop has no explicit assignment? +- What description should admins see? + +Example: + +```text +name: Enhanced_Payroll +default_treatment: off +active: true +description: Enable enhanced payroll and labor allocation features. +``` + +### Bodyshop Assignment + +A per-shop override row in `public.bodyshop_feature_flags`. + +This answers: + +- Which bodyshop has this flag assigned? +- What treatment does that bodyshop receive? +- Is there config attached to this flag? +- Is the assignment only active during a date window? + +Example: + +```text +bodyshopid: 2f9... +name: Enhanced_Payroll +treatment: on +config: null +activeDate: null +deactiveDate: null +``` + +### Treatment + +The value returned for a flag. Historically this was commonly `on`, `off`, or `control`, but Split supports arbitrary +treatments, so our database now allows any non-empty text. + +Examples: + +```text +on +off +control +variant-a +new-workflow +demo +``` + +The client compatibility layer treats `off` and `control` as disabled in boolean-style code paths. Code that asks for +the treatment string can receive any treatment value. + +### Config + +Optional JSON attached to a bodyshop assignment. + +Example: + +```json +{ + "buttonText": "Try new workflow", + "limit": 25 +} +``` + +This is returned through `useTreatmentWithConfig` and `useTreatmentsWithConfig`. + +### Active Date and Deactive Date + +Optional schedule fields on an assignment: + +- `activeDate`: the assignment should not turn on before this timestamp. +- `deactiveDate`: the assignment should stop being active at or after this timestamp. + +Both fields are optional. If a flag is assigned with no dates, the assignment is simply active. + +The canonical field names are: + +```text +activeDate +deactiveDate +``` + +Avoid introducing aliases like `active_at`, `activeAt`, `active_date`, `inactiveDate`, etc. Keeping one naming convention +reduces mapping bugs and makes Hasura/admin/client behavior much easier to reason about. + +## High-Level Architecture + +```text +Admin Panel + | + | /adm/feature-flags + | /adm/bodyshops/:bodyshopId/feature-flags + | /adm/feature-flags/:name/bodyshops + v +Bodyshop Backend + | + | GraphQL admin client + v +Hasura/Postgres + | + | event triggers + v +Bodyshop Backend cache invalidation route + | + | updates Redis cache version/key + | emits feature-flags-changed + v +Redis + Socket.IO + | + | global event or bodyshop room event + v +Bodyshop Client SocketProvider + | + | window feature-flags-changed event + v +Feature flag compatibility layer refetches + +Bodyshop Client + | + | imports local feature flag compatibility module + v +client/src/feature-flags/splitio-react-replacement.jsx + | + | GET /feature-flags/bodyshops/:bodyshopId + v +Bodyshop Backend + | + | Redis read-through cache + v +Hasura/Postgres when cache misses +``` + +## Runtime Request Flow + +When the bodyshop client needs flags: + +1. App code imports hooks/components from the local compatibility layer: + - `client/src/feature-flags/splitio-react-replacement.jsx` +2. The compatibility layer reads the active bodyshop from Redux. +3. It requests flags from: + - `GET /feature-flags/bodyshops/:bodyshopId` +4. The backend verifies the Firebase user can access that bodyshop through Hasura permissions. +5. The backend checks Redis for cached flags. +6. If Redis has a current-version cache entry, the backend returns it. +7. If Redis misses, the backend queries Hasura for: + - active feature flag definitions + - assignments for the requested bodyshop +8. The backend combines definitions and assignments into a Split-like payload. +9. The backend writes the payload to Redis with a short TTL. +10. The compatibility layer stores the successful payload in browser `localStorage` as a 24-hour last-known fallback. +11. The compatibility layer evaluates dates and exposes treatments/configs through Split-compatible hooks. +12. If a `feature-flags-changed` socket event arrives later, the compatibility layer refetches the runtime route. + +## Database Tables + +### `public.feature_flags` + +This table stores global flag definitions. + +Important columns: + +| Column | Type | Meaning | +| --- | --- | --- | +| `name` | `text` | Primary key. This is the flag key used by the client. | +| `description` | `text` | Optional admin-facing explanation. | +| `default_treatment` | `text` | Treatment returned when a bodyshop does not have an assignment. Must be non-empty. | +| `active` | `boolean` | Globally enables/disables the flag definition. | +| `created_at` | `timestamptz` | Creation timestamp. | +| `updated_at` | `timestamptz` | Updated by trigger. | + +Important behavior: + +- `name` is the stable identifier. Renaming a flag is not currently supported from the admin UI. +- `active = false` removes the definition from normal runtime evaluation. +- `default_treatment` can be any non-empty text. +- Deleting a definition cascades per-bodyshop assignments because of the FK on `bodyshop_feature_flags.name`. + +### `public.bodyshop_feature_flags` + +This table stores per-bodyshop assignments. + +Important columns: + +| Column | Type | Meaning | +| --- | --- | --- | +| `id` | `uuid` | Row primary key. | +| `bodyshopid` | `uuid` | Bodyshop receiving this assignment. | +| `name` | `text` | Feature flag name. FK to `feature_flags.name`. | +| `treatment` | `text` | Treatment returned for this bodyshop. Must be non-empty. | +| `config` | `jsonb` | Optional treatment config. | +| `activeDate` | `timestamptz` | Optional start timestamp. | +| `deactiveDate` | `timestamptz` | Optional end timestamp. | +| `created_at` | `timestamptz` | Creation timestamp. | +| `updated_at` | `timestamptz` | Updated by trigger. | + +Important constraints: + +- Unique assignment per bodyshop and flag: + - `(bodyshopid, name)` +- `treatment` must be non-empty text. +- `deactiveDate` must be after `activeDate` if both are present. +- Assignments cascade delete when the bodyshop or feature flag is deleted. + +## Hasura Metadata + +The metadata tracks both new tables. + +### `feature_flags` + +Relationships: + +- Array relationship to `bodyshop_feature_flags`. + +Permissions: + +- `user` role can select active definitions. + +Event triggers: + +- `cache_feature_flags` +- Fires on insert, update, and delete. +- Calls: + - `/feature-flags/cache/invalidate` +- This causes a global feature flag cache version bump in Redis. + +### `bodyshop_feature_flags` + +Relationships: + +- Object relationship to `bodyshop`. +- Object relationship to `feature_flag`. + +Permissions: + +- `user` role can select rows only through active bodyshop associations and active feature flag definitions. + +Event triggers: + +- `cache_bodyshop_feature_flags` +- Fires on insert, update, and delete. +- Calls: + - `/feature-flags/cache/invalidate` +- This clears the affected bodyshop's current-version feature flag cache key. + +## Backend Runtime Route + +### `GET /feature-flags/bodyshops/:bodyshopId` + +Location: + +```text +server/routes/featureFlagRoutes.js +server/feature-flags/feature-flags.js +``` + +Middleware: + +- Firebase auth validation. +- User GraphQL client. + +Behavior: + +1. Reads `bodyshopId` from the route. +2. Verifies the authenticated user can access the bodyshop using `CHECK_BODYSHOP_ACCESS`. +3. Reads the current feature flag cache version from Redis. +4. Attempts to read: + - `bodyshop-feature-flags:v:` +5. If found, returns cached payload with: + - `source: "redis"` +6. If not found, queries Hasura using `GET_BODYSHOP_FEATURE_FLAGS`. +7. Builds a payload. +8. Stores it in Redis. +9. Returns payload with: + - `source: "database"` + +Example response: + +```json +{ + "bodyshopId": "00000000-0000-0000-0000-000000000000", + "flags": { + "Enhanced_Payroll": { + "treatment": "on", + "config": null, + "activeDate": null, + "deactiveDate": null + }, + "New_Workflow_Demo": { + "treatment": "demo", + "config": { + "buttonText": "Try it" + }, + "activeDate": "2026-06-01T13:00:00+00:00", + "deactiveDate": "2026-06-05T21:00:00+00:00" + } + }, + "cachedAt": "2026-05-19T15:00:00.000Z", + "source": "database" +} +``` + +### Dev Debug Logging + +When `NODE_ENV === "development"`, the runtime route logs each hit with level `DEBUG`: + +```text +feature-flags-route-hit +``` + +The log metadata includes: + +- `bodyshopId` +- `source` + - `redis` + - `database` +- `flagCount` + +This is helpful for confirming: + +- The client is actually hitting the backend. +- Redis is being used after the first request. +- Cache invalidation causes the next request to read from the database again. + +## Backend Cache Invalidation Route + +### `POST /feature-flags/cache/invalidate` + +Location: + +```text +server/routes/featureFlagRoutes.js +server/feature-flags/feature-flags.js +``` + +Middleware: + +- Event secret authorization. + +This route is called by Hasura event triggers. + +Behavior: + +- If the event body includes a `bodyshopid`, it invalidates only that bodyshop's current-version cache key. +- If no `bodyshopid` is present, it treats the event as a global definition change and increments the global cache + version. + +### Assignment Change + +When `bodyshop_feature_flags` changes, the event includes either `event.data.new.bodyshopid` or +`event.data.old.bodyshopid`. + +The backend deletes: + +```text +bodyshop-feature-flags:v: +``` + +The next request for that bodyshop reads from Hasura and writes a fresh current-version cache entry. + +### Definition Change + +When `feature_flags` changes, the event has no `bodyshopid`. + +The backend increments: + +```text +bodyshop-feature-flags:version +``` + +Example: + +```text +bodyshop-feature-flags:version = 12 +``` + +After increment: + +```text +bodyshop-feature-flags:version = 13 +``` + +All old keys such as: + +```text +bodyshop-feature-flags:v12: +``` + +become invisible to the runtime route, because it now reads: + +```text +bodyshop-feature-flags:v13: +``` + +The old keys are not eagerly deleted. They expire naturally after the configured TTL. + +## Redis Cache Details + +Location: + +```text +server/utils/redisHelpers.js +``` + +Important constants: + +```text +FEATURE_FLAGS_CACHE_TTL = 3600 +FEATURE_FLAGS_CACHE_VERSION_KEY = bodyshop-feature-flags:version +``` + +Important helper functions: + +- `getBodyshopFeatureFlagsCacheVersion` +- `getBodyshopFeatureFlagsFromRedis` +- `setBodyshopFeatureFlagsInRedis` +- `invalidateBodyshopFeatureFlagsInRedis` +- `invalidateAllBodyshopFeatureFlagsInRedis` + +Cache key format: + +```text +bodyshop-feature-flags:v: +``` + +Global version key: + +```text +bodyshop-feature-flags:version +``` + +Why versioning is used: + +- Redis `KEYS bodyshop-feature-flags:*` can block Redis on a large keyspace. +- Feature flag definition changes affect all bodyshops. +- Incrementing a single version key is constant-time and cheap. +- Old entries naturally expire due to the 1 hour TTL. + +Important operational note: + +The version key does not need a TTL. It should remain in Redis and monotonically increase. If Redis is flushed, the +helper recreates the version key at `1`, and the cache warms again on demand. + +## Admin Backend Routes + +Location: + +```text +server/routes/adminRoutes.js +server/admin/adminops.js +``` + +### `GET /adm/feature-flags` + +Returns feature flag definitions. + +Query params: + +- `includeInactive=true` + - returns inactive definitions too. + +Used by: + +- Bodyshop edit screen. +- Feature flag CRUD screen. + +### `POST /adm/feature-flags` + +Creates a new feature flag definition. + +Typical body: + +```json +{ + "name": "New_Workflow_Demo", + "description": "Enable the new demo workflow.", + "default_treatment": "off", + "active": true +} +``` + +After creation, all feature flag caches are invalidated by incrementing the Redis cache version. +The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch. + +### `PUT /adm/feature-flags/:name` + +Updates a feature flag definition. + +The flag `name` is not edited. Other fields can change: + +- `description` +- `default_treatment` +- `active` + +After update, all feature flag caches are invalidated by incrementing the Redis cache version. +The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch. + +### `DELETE /adm/feature-flags/:name` + +Deletes a feature flag definition. + +Because assignments FK to `feature_flags.name` with cascade delete, this also removes bodyshop assignments for that +flag. + +After delete, all feature flag caches are invalidated by incrementing the Redis cache version. +The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch. + +### `GET /adm/bodyshops/:bodyshopId/feature-flags` + +Returns assignments for one bodyshop. + +Used by the bodyshop edit screen. + +### `GET /adm/feature-flags/:name/bodyshops` + +Returns all bodyshops assigned to one flag. + +Used by the flag CRUD screen's shop assignment drawer. + +### `PUT /adm/feature-flags/:name/bodyshops` + +Replaces the shop assignment list for one flag. + +Typical body: + +```json +{ + "assignments": [ + { + "bodyshopid": "00000000-0000-0000-0000-000000000000", + "treatment": "on", + "config": null, + "activeDate": null, + "deactiveDate": null + }, + { + "bodyshopid": "11111111-1111-1111-1111-111111111111", + "treatment": "demo", + "config": { + "mode": "guided" + }, + "activeDate": "2026-06-01T13:00:00.000Z", + "deactiveDate": "2026-06-05T21:00:00.000Z" + } + ] +} +``` + +Important behavior: + +- Assignments present in the request are upserted. +- Existing assignments for the flag that are not present in the request are deleted. +- Redis caches for changed bodyshops are invalidated. +- The backend emits `feature-flags-changed` socket events for changed bodyshops. + +### `POST /adm/updateshop` + +The existing bodyshop update route also saves feature flag assignments. + +It accepts `bodyshop.featureFlags` and stores those assignments in `bodyshop_feature_flags`, not in the legacy +`bodyshops.features.featureFlags` JSON location. + +This keeps the bodyshop edit screen working while moving feature flags into first-class tables. +After assignment changes, Redis caches for the changed bodyshop are invalidated and a bodyshop-scoped +`feature-flags-changed` socket event is emitted. + +## Admin Panel UI + +Admin repo: + +```text +C:\Users\DaveRicher\WebstormProjects\bodyshop-admin +``` + +### Bodyshop Edit Screen + +File: + +```text +src/components/bodyshop-manage/bodyshop-manage.component.jsx +``` + +Capabilities: + +- View assignments for a bodyshop. +- Add a flag assignment. +- Remove a flag assignment. +- Set the assignment treatment. +- Set optional `activeDate`. +- Set optional `deactiveDate`. + +This screen is useful when the workflow starts with a bodyshop: + +> "Turn on `Enhanced_Payroll` for APPLE." + +### Feature Flag CRUD Screen + +File: + +```text +src/components/feature-flags/feature-flags.component.jsx +``` + +Route: + +```text +/feature-flags +``` + +Capabilities: + +- View all feature flag definitions. +- Search definitions by name/description. +- Create a new flag. +- Edit an existing flag. +- Delete a flag. +- See assignment counts. +- Open the shops drawer for a flag. +- Add/remove bodyshops from a flag. +- Set treatment/config/dates for each assigned shop. + +This screen is useful when the workflow starts with a feature: + +> "We built `New_Workflow_Demo`; assign it to APPLE, DEMO, and QBO." + +### Custom Treatments in Admin + +Treatment fields use preset suggestions for: + +- `on` +- `off` +- `control` + +Admins can type custom treatment values as well. + +Examples: + +```text +variant-a +variant-b +demo +new-ui +legacy-ui +``` + +## Bodyshop Frontend Compatibility Layer + +File: + +```text +client/src/feature-flags/splitio-react-replacement.jsx +``` + +The client imports the Split-shaped local API: + +```js +import { useTreatment, useTreatmentsWithConfig } from "../feature-flags/splitio-react-replacement"; +``` + +The relative path varies by file location. The important part is that all feature flag imports should point to +`client/src/feature-flags/splitio-react-replacement.jsx`, not to the old Split package. We no longer rely on a Vite +alias to replace that package; code should import our local module directly. + +Currently supported exports include: + +- `SplitFactoryProvider` +- `useSplitClient` +- `useTreatmentsWithConfig` +- `useTreatment` +- `useTreatmentWithConfig` +- `SplitContext` +- `useSplitContext` + +Unknown flags default to `off`. + +### Date Evaluation + +Dates are evaluated on the client compatibility layer. + +Expected behavior: + +- No dates: + - assignment is active immediately. +- `activeDate` only: + - assignment is inactive before `activeDate`. + - assignment is active at/after `activeDate`. +- `deactiveDate` only: + - assignment is active before `deactiveDate`. + - assignment is inactive at/after `deactiveDate`. +- Both dates: + - assignment is active in the half-open interval: + - `activeDate <= now < deactiveDate` + +This makes demo windows possible without needing a background job to flip rows. The compatibility layer also schedules a +browser timer for the next `activeDate` or `deactiveDate` boundary so an already-open tab reevaluates the flag when the +window opens or closes. + +### Browser Fallback Cache + +For outage resilience, the compatibility layer stores the last successful backend flag payload in browser +`localStorage`. The browser cache is valid for up to 24 hours. + +The key is scoped by bodyshop: + +```text +bodyshop-feature-flags: +``` + +Fallback order: + +```text +1. Backend response from GET /feature-flags/bodyshops/:bodyshopId +2. Last-known browser cache for that bodyshop +3. Unknown flags resolve to off +``` + +The client does not use old `bodyshops.features.featureFlags` JSON as a feature flag fallback. Feature flags now come +from the backend/db-backed system or from the last-known browser cache created from that backend response. + +### Live Browser Refresh + +Feature flag changes are pushed to open browser tabs through the existing Socket.IO infrastructure. + +Backend emit helper: + +```text +server/feature-flags/socket-events.js +``` + +Frontend listener/bridge: + +```text +client/src/contexts/SocketIO/socketProvider.jsx +client/src/feature-flags/splitio-react-replacement.jsx +``` + +Socket event name: + +```text +feature-flags-changed +``` + +Payload shape: + +```json +{ + "bodyshopId": "00000000-0000-0000-0000-000000000000", + "changedAt": "2026-05-19T15:00:00.000Z", + "name": "Enhanced_Payroll", + "scope": "bodyshop", + "source": "admin", + "table": "bodyshop_feature_flags" +} +``` + +Important fields: + +- `scope` + - `global`: every open tab should refetch. + - `bodyshop`: only tabs for the matching `bodyshopId` should refetch. +- `source` + - `admin`: emitted after admin backend writes. + - `hasura`: emitted after Hasura event-trigger invalidation. +- `table` + - `feature_flags`: global definition change. + - `bodyshop_feature_flags`: per-bodyshop assignment change. + +Backend behavior: + +- Definition changes emit globally with `scope: "global"`. +- Assignment changes emit to the bodyshop room when a bodyshop id is known. +- If a bodyshop room helper is unavailable, the fallback room name is: + +```text +bodyshop-broadcast-room: +``` + +Frontend behavior: + +- `SocketProvider` listens for the socket event. +- It bridges the socket event into a browser `CustomEvent` with the same event name. +- `SplitFactoryProvider` listens for that browser event. +- Global events always trigger a refetch. +- Bodyshop events trigger a refetch only when `payload.bodyshopId` matches the active Redux bodyshop. +- Refetches are debounced for 150 ms to collapse duplicate admin/Hasura events. + +If the refetch fails because the backend is unreachable, the normal browser fallback cache behavior still applies. + +## Harness/Split Export and Import + +Exporter script: + +```text +scripts/export-harness-feature-flags.js +``` + +NPM script: + +```text +npm run feature-flags:export-harness +``` + +Output folder: + +```text +harness-feature-flags-export +``` + +Important output files: + +- `feature_flags.json` +- `bodyshop_feature_flags.json` +- `global_defaults.json` +- `unmapped_rules.json` +- `raw/sdk_split_changes.json` +- `bodyshop_feature_flags_import.sql` + +The import SQL maps Harness/Split target keys to bodyshops by matching: + +```sql +lower("bodyshops"."imexshopid") = lower("exported_flags"."imexshopid") +``` + +This matters because Harness targeting was based on `imexshopid`, not the bodyshop UUID. + +The generated SQL also reports: + +- exported Harness target keys that did not match any `bodyshops.imexshopid` +- exported feature flag names that did not already exist in `public.feature_flags` + +The importer preserves custom treatment names and casts missing configs as `NULL::jsonb`, which avoids Postgres treating +the `config` column as text during import. + +### Export Completeness Caveat + +The exporter can only export the project/environment visible to the supplied key. + +If the key is an SDK key, it exports what that SDK key can read. It does not prove that every Harness project, +environment, or SDK key has been exported. + +For complete migration confidence, collect either: + +- a Harness Admin API key with access to the relevant projects/environments, or +- all SDK keys used by the environments we care about. + +## Applying DB Changes + +The feature flag migrations are in: + +```text +hasura/migrations +``` + +Important migrations: + +```text +1778870000000_create_table_public_feature_flags +1778950000000_create_table_public_bodyshop_feature_flags +1779040000000_allow_custom_feature_flag_treatments +``` + +After migrations, apply Hasura metadata so relationships, permissions, and event triggers are active. + +Important metadata file: + +```text +hasura/metadata/tables.yaml +``` + +After applying migrations/metadata: + +1. Restart the backend. +2. Restart the admin panel if needed. +3. Create or edit a flag in admin. +4. Assign it to a bodyshop. +5. Request runtime flags for that bodyshop. + +## Recommended Smoke Test + +Use this flow after applying DB changes or changing cache behavior. + +### 1. Create a Test Flag + +In admin: + +- Go to `/feature-flags`. +- Create: + +```text +name: Demo_Test_Flag +default_treatment: off +active: true +``` + +### 2. Assign a Bodyshop + +From the same flag screen: + +- Open `Shops`. +- Add one test bodyshop. +- Set treatment: + +```text +on +``` + +Optional: + +- Add an `activeDate`. +- Add a `deactiveDate`. +- Add config JSON. + +### 3. Hit Runtime Route + +Call: + +```text +GET /feature-flags/bodyshops/:bodyshopId +``` + +Expected first response: + +```text +source: database +``` + +Expected payload: + +```json +{ + "flags": { + "Demo_Test_Flag": { + "treatment": "on" + } + } +} +``` + +### 4. Hit Runtime Route Again + +Expected second response: + +```text +source: redis +``` + +### 5. Change the Assignment + +In admin: + +- Change treatment from `on` to `off`, or to a custom treatment like `demo`. +- Save. + +Expected next runtime response: + +```text +source: database +``` + +The changed bodyshop's cache should have been invalidated. +Any open browser tab for that bodyshop should update without a manual refresh. + +### 6. Change the Definition + +In admin: + +- Change the flag description, active state, or default treatment. +- Save. + +Expected behavior: + +- Redis global feature flag cache version increments. +- Next runtime request for any bodyshop reads from database and stores under the new versioned key. +- Any open browser tab should receive a global socket event and refetch. + +### 7. Confirm the Footer Test Indicator + +The bodyshop frontend footer has a temporary manual test hook: + +```text +client/src/components/global-footer/global-footer.component.jsx +``` + +When `TEST_FLAG` resolves to treatment `on`, the footer displays: + +```text +Test Feature Flag Enabled +``` + +When `TEST_FLAG` is `off`, missing, globally inactive, outside its schedule window, or unavailable with no browser cache, +the footer indicator is hidden. + +## Troubleshooting + +### Admin Cannot See Flags + +Check: + +- Backend is restarted. +- Hasura migrations are applied. +- Hasura metadata is applied. +- `/adm/feature-flags` is registered in `server/routes/adminRoutes.js`. +- Admin user passes `validateAdminMiddleware`. + +### Runtime Route Returns 403 + +The backend verifies bodyshop access with the user's Hasura permissions. + +Check: + +- The Firebase token is valid. +- The user has an active association to the bodyshop. +- The bodyshop id in the route is correct. + +### Runtime Route Returns No Custom Flag + +Check: + +- The flag definition exists in `feature_flags`. +- The flag definition has `active = true`. +- The bodyshop assignment exists in `bodyshop_feature_flags`. +- The assignment dates are not excluding the current time. +- The bodyshop id is the UUID, not `imexshopid`. + +### Assignment Save Fails with Date Constraint + +If both dates are present: + +```text +deactiveDate > activeDate +``` + +must be true. + +### Config Save Fails + +Config must be valid JSON. + +Valid: + +```json +{ + "limit": 10 +} +``` + +Invalid: + +```json +{ + limit: 10 +} +``` + +### Treatment Save Fails + +Treatment must be non-empty text. + +Valid: + +```text +on +off +control +variant-a +``` + +Invalid: + +```text + +``` + +### Cache Does Not Seem to Invalidate + +Check whether the route response has: + +```text +source: redis +``` + +or: + +```text +source: database +``` + +In development, look for `feature-flags-route-hit` debug logs. + +For assignment changes: + +- Ensure the `cache_bodyshop_feature_flags` Hasura event trigger exists. +- Ensure event secret is correct. +- Ensure the event includes `bodyshopid`. + +For definition changes: + +- Ensure the `cache_feature_flags` Hasura event trigger exists. +- Ensure event secret is correct. +- Confirm Redis version changes: + +```text +bodyshop-feature-flags:version +``` + +### Old Cache Keys Remain in Redis + +This is expected. + +Old versioned keys are intentionally not deleted during global invalidation. They become invisible because the runtime +route reads the new version. They expire naturally after `FEATURE_FLAGS_CACHE_TTL`, currently 1 hour. + +## Operational Notes + +### Renaming Flags + +Flag names are primary keys and are not editable in the admin UI. + +If a flag must be renamed: + +1. Create the new flag. +2. Reassign bodyshops to the new flag. +3. Update code references. +4. Delete the old flag once no code uses it. + +Do not rename a flag directly in the database unless all client code references are updated at the same time. + +### Deleting Flags + +Deleting a flag definition cascades assignments. + +This is usually what we want, but be careful with production flags. If unsure, set `active = false` first and observe +behavior before deleting. + +### Default Treatments + +Defaults apply only when a bodyshop has no explicit assignment for a flag. + +Example: + +- `feature_flags.default_treatment = off` +- no `bodyshop_feature_flags` row +- runtime treatment is `off` + +If an assignment exists, assignment treatment wins. + +### Active Flag Definition vs Assignment Schedule + +Both layers matter: + +- Definition `active = false` means the flag does not appear in runtime definitions. +- Assignment dates determine whether a bodyshop's assigned treatment is currently active. + +For demo scheduling, keep the definition active and place the window on the assignment. + +### Bodyshop ID vs Imex Shop ID + +Runtime route uses bodyshop UUID: + +```text +/feature-flags/bodyshops/:bodyshopId +``` + +Harness export/import maps from `imexshopid` because Harness target keys used that value. + +Do not mix these up: + +- Runtime and database assignments use `bodyshopid` UUID. +- Migration/import matching uses `imexshopid`. + +## Relevant Files + +Bodyshop backend: + +```text +server/feature-flags/feature-flags.js +server/routes/featureFlagRoutes.js +server/admin/adminops.js +server/routes/adminRoutes.js +server/utils/redisHelpers.js +server/graphql-client/queries.js +server.js +``` + +Bodyshop client: + +```text +client/vite.config.js +client/src/feature-flags/splitio-react-replacement.jsx +client/src/feature-flags/README.md +``` + +Hasura: + +```text +hasura/migrations/1778870000000_create_table_public_feature_flags +hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags +hasura/migrations/1779040000000_allow_custom_feature_flag_treatments +hasura/metadata/tables.yaml +``` + +Admin panel: + +```text +../bodyshop-admin/src/components/feature-flags/feature-flags.component.jsx +../bodyshop-admin/src/components/bodyshop-manage/bodyshop-manage.component.jsx +../bodyshop-admin/src/components/router/router.component.jsx +../bodyshop-admin/src/components/main-sider/main-sider.component.jsx +``` + +Harness export: + +```text +scripts/export-harness-feature-flags.js +harness-feature-flags-export +``` + +## Future Improvements + +These are not required for the current rollout, but they are worth considering. + +### Audit Trail + +Feature flags control production behavior. It would be useful to record: + +- who created a flag +- who edited a definition +- who assigned or removed a bodyshop +- old value +- new value +- timestamp + +This can be a dedicated audit table or integrated with the existing audit logging pattern if appropriate. + +### Export Completeness + +The current Harness export path should be repeated for every project/environment/key that matters, or replaced with an +Admin API export if Harness access allows it. + +### More Tests + +Useful additional automated coverage: + +- A fuller admin route integration test that exercises create, update, delete, and assignment replacement together. +- A browser-level smoke test that toggles `TEST_FLAG` in admin and observes the footer update without a page refresh. +- Importer coverage for multiple Harness environments once we have more than one export key. diff --git a/client/package-lock.json b/client/package-lock.json index 11ad875ab..d80022a9e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 44ca86f47..09108bc22 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/public/3rdparty-app.txt b/client/public/3rdparty-app.txt index 20022a514..7cd546906 100644 --- a/client/public/3rdparty-app.txt +++ b/client/public/3rdparty-app.txt @@ -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 diff --git a/client/src/App/App.container.backup-2026-03-04.jsx b/client/src/App/App.container.backup-2026-03-04.jsx deleted file mode 100644 index bcfc39fa1..000000000 --- a/client/src/App/App.container.backup-2026-03-04.jsx +++ /dev/null @@ -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 ( - - - - - - - - - - - - - ); -} - -export default Sentry.withProfiler(AppContainer); diff --git a/client/src/App/App.container.jsx b/client/src/App/App.container.jsx index 8abf59242..1c04d5af4 100644 --- a/client/src/App/App.container.jsx +++ b/client/src/App/App.container.jsx @@ -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() { - - + + - - + + diff --git a/client/src/App/App.container.pre-rollback-2026-03-04.jsx b/client/src/App/App.container.pre-rollback-2026-03-04.jsx deleted file mode 100644 index bcfc39fa1..000000000 --- a/client/src/App/App.container.pre-rollback-2026-03-04.jsx +++ /dev/null @@ -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 ( - - - - - - - - - - - - - ); -} - -export default Sentry.withProfiler(AppContainer); diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index 67e176604..410f81554 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -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={ - + + + } > } /> - }> + + + + } + > } /> diff --git a/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx b/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx index e4236e354..2b68bcd13 100644 --- a/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx +++ b/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx @@ -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"; diff --git a/client/src/components/bill-form/bill-form.component.jsx b/client/src/components/bill-form/bill-form.component.jsx index 7b324b3d1..2acc1a13c 100644 --- a/client/src/components/bill-form/bill-form.component.jsx +++ b/client/src/components/bill-form/bill-form.component.jsx @@ -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"; diff --git a/client/src/components/bill-form/bill-form.container.jsx b/client/src/components/bill-form/bill-form.container.jsx index ab52f5459..caedd7022 100644 --- a/client/src/components/bill-form/bill-form.container.jsx +++ b/client/src/components/bill-form/bill-form.container.jsx @@ -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"; diff --git a/client/src/components/bill-form/bill-form.lines.component.jsx b/client/src/components/bill-form/bill-form.lines.component.jsx index e29e88a2c..e11266583 100644 --- a/client/src/components/bill-form/bill-form.lines.component.jsx +++ b/client/src/components/bill-form/bill-form.lines.component.jsx @@ -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"; diff --git a/client/src/components/breadcrumbs/breadcrumbs.component.jsx b/client/src/components/breadcrumbs/breadcrumbs.component.jsx index 2ca9fcde0..4acb32176 100644 --- a/client/src/components/breadcrumbs/breadcrumbs.component.jsx +++ b/client/src/components/breadcrumbs/breadcrumbs.component.jsx @@ -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, diff --git a/client/src/components/chat-media-selector/chat-media-selector.component.jsx b/client/src/components/chat-media-selector/chat-media-selector.component.jsx index e2fb74e03..dc5cb949a 100644 --- a/client/src/components/chat-media-selector/chat-media-selector.component.jsx +++ b/client/src/components/chat-media-selector/chat-media-selector.component.jsx @@ -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"; diff --git a/client/src/components/dms-cdk-makes/dms-cdk-makes.refetch.component.jsx b/client/src/components/dms-cdk-makes/dms-cdk-makes.refetch.component.jsx index 0020cbfd5..5f6096b7e 100644 --- a/client/src/components/dms-cdk-makes/dms-cdk-makes.refetch.component.jsx +++ b/client/src/components/dms-cdk-makes/dms-cdk-makes.refetch.component.jsx @@ -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, diff --git a/client/src/components/dms-post-form/cdklike-dms-post-form.jsx b/client/src/components/dms-post-form/cdklike-dms-post-form.jsx index 09eebb5ca..5efc5cd11 100644 --- a/client/src/components/dms-post-form/cdklike-dms-post-form.jsx +++ b/client/src/components/dms-post-form/cdklike-dms-post-form.jsx @@ -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: diff --git a/client/src/components/email-documents/email-documents.component.jsx b/client/src/components/email-documents/email-documents.component.jsx index 6ba22e241..a6ba8f338 100644 --- a/client/src/components/email-documents/email-documents.component.jsx +++ b/client/src/components/email-documents/email-documents.component.jsx @@ -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({ diff --git a/client/src/components/global-footer/global-footer.component.jsx b/client/src/components/global-footer/global-footer.component.jsx index 19e9b0629..b0d4958f9 100644 --- a/client/src/components/global-footer/global-footer.component.jsx +++ b/client/src/components/global-footer/global-footer.component.jsx @@ -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 ? ( +
Test Feature Flag Enabled
+ ) : null; if (isPartsEntry) { return ( @@ -38,6 +45,7 @@ export function GlobalFooter({ isPartsEntry }) { Disclaimer & Notices + {testFlagIndicator} ); @@ -74,6 +82,7 @@ export function GlobalFooter({ isPartsEntry }) { Disclaimer & Notices + {testFlagIndicator} ); diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index 59befa6d7..c9e80b31c 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -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"; diff --git a/client/src/components/job-close-ro-guard/job-close-ro-gaurd.labor.jsx b/client/src/components/job-close-ro-guard/job-close-ro-gaurd.labor.jsx index 51233e151..d5c8627e8 100644 --- a/client/src/components/job-close-ro-guard/job-close-ro-gaurd.labor.jsx +++ b/client/src/components/job-close-ro-guard/job-close-ro-gaurd.labor.jsx @@ -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"; diff --git a/client/src/components/job-create-iou/job-create-iou.component.jsx b/client/src/components/job-create-iou/job-create-iou.component.jsx index d44dfa6ff..5fedfbd4f 100644 --- a/client/src/components/job-create-iou/job-create-iou.component.jsx +++ b/client/src/components/job-create-iou/job-create-iou.component.jsx @@ -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"; diff --git a/client/src/components/job-detail-lines/job-lines.component.jsx b/client/src/components/job-detail-lines/job-lines.component.jsx index 7e3ce8b85..91e1a201a 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -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"; diff --git a/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.component.jsx b/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.component.jsx index c2ae3d1a7..6f524f857 100644 --- a/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.component.jsx +++ b/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.component.jsx @@ -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"; diff --git a/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx b/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx index 5140e43dd..cacfe95c9 100644 --- a/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx +++ b/client/src/components/job-lines-upsert-modal/job-lines-upsert-modal.container.jsx @@ -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"; diff --git a/client/src/components/job-payments/job-payments.component.jsx b/client/src/components/job-payments/job-payments.component.jsx index c0cbb9846..798c0c97c 100644 --- a/client/src/components/job-payments/job-payments.component.jsx +++ b/client/src/components/job-payments/job-payments.component.jsx @@ -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 diff --git a/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx b/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx index dbd4915c6..7649894cc 100644 --- a/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx +++ b/client/src/components/job-watcher-toggle/job-watcher-toggle.container.jsx @@ -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"; diff --git a/client/src/components/jobs-available-table/jobs-available-table.container.jsx b/client/src/components/jobs-available-table/jobs-available-table.container.jsx index a2ca4ed11..479867306 100644 --- a/client/src/components/jobs-available-table/jobs-available-table.container.jsx +++ b/client/src/components/jobs-available-table/jobs-available-table.container.jsx @@ -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"; diff --git a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx index 8d83ff6e8..54d8eba43 100644 --- a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx +++ b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx @@ -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"; diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx index 0ab5c37ef..99a523375 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx @@ -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"; diff --git a/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx b/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx index 092d7972d..60505f4fa 100644 --- a/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx +++ b/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx @@ -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, diff --git a/client/src/components/jobs-documents-gallery/jobs-document-gallery.download.component.jsx b/client/src/components/jobs-documents-gallery/jobs-document-gallery.download.component.jsx index ae7fa6d34..c3a2cadf9 100644 --- a/client/src/components/jobs-documents-gallery/jobs-document-gallery.download.component.jsx +++ b/client/src/components/jobs-documents-gallery/jobs-document-gallery.download.component.jsx @@ -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"; diff --git a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.container.jsx b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.container.jsx index af45e3874..bbfdbed2b 100644 --- a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.container.jsx +++ b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.container.jsx @@ -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 diff --git a/client/src/components/parts-order-modal/parts-order-modal.component.jsx b/client/src/components/parts-order-modal/parts-order-modal.component.jsx index f04b5f7bc..7c0fe17c9 100644 --- a/client/src/components/parts-order-modal/parts-order-modal.component.jsx +++ b/client/src/components/parts-order-modal/parts-order-modal.component.jsx @@ -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"; diff --git a/client/src/components/parts-order-modal/parts-order-modal.container.jsx b/client/src/components/parts-order-modal/parts-order-modal.container.jsx index faf87d6e1..4f70f4f84 100644 --- a/client/src/components/parts-order-modal/parts-order-modal.container.jsx +++ b/client/src/components/parts-order-modal/parts-order-modal.container.jsx @@ -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"; diff --git a/client/src/components/payment-form/payment-form.component.jsx b/client/src/components/payment-form/payment-form.component.jsx index a11c3e617..c1a2d2c53 100644 --- a/client/src/components/payment-form/payment-form.component.jsx +++ b/client/src/components/payment-form/payment-form.component.jsx @@ -1,4 +1,4 @@ -import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; +import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement"; import { Form, Input, Radio, Select } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; diff --git a/client/src/components/print-center-jobs/print-center-jobs-parts.component.jsx b/client/src/components/print-center-jobs/print-center-jobs-parts.component.jsx index 54d9134c0..3d7034076 100644 --- a/client/src/components/print-center-jobs/print-center-jobs-parts.component.jsx +++ b/client/src/components/print-center-jobs/print-center-jobs-parts.component.jsx @@ -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"; diff --git a/client/src/components/print-center-jobs/print-center-jobs.component.jsx b/client/src/components/print-center-jobs/print-center-jobs.component.jsx index 6dc5a1352..fd0afe0ce 100644 --- a/client/src/components/print-center-jobs/print-center-jobs.component.jsx +++ b/client/src/components/print-center-jobs/print-center-jobs.component.jsx @@ -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"; diff --git a/client/src/components/production-board-kanban/production-board-kanban.container.jsx b/client/src/components/production-board-kanban/production-board-kanban.container.jsx index 99dedb7e8..a37975275 100644 --- a/client/src/components/production-board-kanban/production-board-kanban.container.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban.container.jsx @@ -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({ diff --git a/client/src/components/production-list-columns/production-list-columns.add.component.jsx b/client/src/components/production-list-columns/production-list-columns.add.component.jsx index a88b2a474..e218a7e3e 100644 --- a/client/src/components/production-list-columns/production-list-columns.add.component.jsx +++ b/client/src/components/production-list-columns/production-list-columns.add.component.jsx @@ -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, diff --git a/client/src/components/production-list-table/production-list-config-manager.component.jsx b/client/src/components/production-list-table/production-list-config-manager.component.jsx index a061551a3..11bb81169 100644 --- a/client/src/components/production-list-table/production-list-config-manager.component.jsx +++ b/client/src/components/production-list-table/production-list-config-manager.component.jsx @@ -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"; diff --git a/client/src/components/production-list-table/production-list-table.component.jsx b/client/src/components/production-list-table/production-list-table.component.jsx index 87448b68c..0fb43bbd9 100644 --- a/client/src/components/production-list-table/production-list-table.component.jsx +++ b/client/src/components/production-list-table/production-list-table.component.jsx @@ -1,6 +1,6 @@ import { HolderOutlined, SyncOutlined } from "@ant-design/icons"; import { PageHeader } from "@ant-design/pro-layout"; -import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; +import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement"; import { Button, Dropdown, Input, Space, Statistic, Table } from "antd"; import _ from "lodash"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; diff --git a/client/src/components/production-list-table/production-list-table.container.jsx b/client/src/components/production-list-table/production-list-table.container.jsx index 9a2a24243..81d9c1930 100644 --- a/client/src/components/production-list-table/production-list-table.container.jsx +++ b/client/src/components/production-list-table/production-list-table.container.jsx @@ -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" }) { diff --git a/client/src/components/report-center-modal/report-center-modal.component.jsx b/client/src/components/report-center-modal/report-center-modal.component.jsx index 77d006ae4..61c184d6c 100644 --- a/client/src/components/report-center-modal/report-center-modal.component.jsx +++ b/client/src/components/report-center-modal/report-center-modal.component.jsx @@ -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"; diff --git a/client/src/components/shop-employees/shop-employees-form.component.jsx b/client/src/components/shop-employees/shop-employees-form.component.jsx index 7ff148791..3d0b99e57 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.jsx @@ -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"; diff --git a/client/src/components/shop-employees/shop-employees-form.component.test.jsx b/client/src/components/shop-employees/shop-employees-form.component.test.jsx index 6da7ef3a6..1d2c41f70 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.test.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.test.jsx @@ -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: { diff --git a/client/src/components/shop-info/shop-info.component.jsx b/client/src/components/shop-info/shop-info.component.jsx index 901a14afe..ce7911d02 100644 --- a/client/src/components/shop-info/shop-info.component.jsx +++ b/client/src/components/shop-info/shop-info.component.jsx @@ -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"; diff --git a/client/src/components/shop-info/shop-info.orderstatus.component.jsx b/client/src/components/shop-info/shop-info.orderstatus.component.jsx index e6b9c3cd4..6a4ed182f 100644 --- a/client/src/components/shop-info/shop-info.orderstatus.component.jsx +++ b/client/src/components/shop-info/shop-info.orderstatus.component.jsx @@ -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 diff --git a/client/src/components/shop-info/shop-info.rbac.component.jsx b/client/src/components/shop-info/shop-info.rbac.component.jsx index 1e24355ec..67d0f5b64 100644 --- a/client/src/components/shop-info/shop-info.rbac.component.jsx +++ b/client/src/components/shop-info/shop-info.rbac.component.jsx @@ -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"; diff --git a/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx b/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx index c88650e13..14f5fb246 100644 --- a/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx +++ b/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx @@ -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"; diff --git a/client/src/components/shop-info/shop-info.rostatus.component.jsx b/client/src/components/shop-info/shop-info.rostatus.component.jsx index e0bb61145..b08894d42 100644 --- a/client/src/components/shop-info/shop-info.rostatus.component.jsx +++ b/client/src/components/shop-info/shop-info.rostatus.component.jsx @@ -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 diff --git a/client/src/components/tech-job-clock-in-form/tech-job-clock-in-form.component.jsx b/client/src/components/tech-job-clock-in-form/tech-job-clock-in-form.component.jsx index 31677fcba..6ba76aedc 100644 --- a/client/src/components/tech-job-clock-in-form/tech-job-clock-in-form.component.jsx +++ b/client/src/components/tech-job-clock-in-form/tech-job-clock-in-form.component.jsx @@ -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, diff --git a/client/src/components/tech-job-clock-in-form/tech-job-clock-in-form.container.jsx b/client/src/components/tech-job-clock-in-form/tech-job-clock-in-form.container.jsx index c18382d6d..ecd941a8b 100644 --- a/client/src/components/tech-job-clock-in-form/tech-job-clock-in-form.container.jsx +++ b/client/src/components/tech-job-clock-in-form/tech-job-clock-in-form.container.jsx @@ -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({ diff --git a/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx b/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx index 9742f4120..da5c90c97 100644 --- a/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx +++ b/client/src/components/tech-job-clock-out-button/tech-job-clock-out-button.component.jsx @@ -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"; diff --git a/client/src/components/tech-sider/tech-sider.component.jsx b/client/src/components/tech-sider/tech-sider.component.jsx index e403b611f..284cfff91 100644 --- a/client/src/components/tech-sider/tech-sider.component.jsx +++ b/client/src/components/tech-sider/tech-sider.component.jsx @@ -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"; diff --git a/client/src/components/time-ticket-list/time-ticket-list.component.jsx b/client/src/components/time-ticket-list/time-ticket-list.component.jsx index 5c4fda4eb..a49f5b848 100644 --- a/client/src/components/time-ticket-list/time-ticket-list.component.jsx +++ b/client/src/components/time-ticket-list/time-ticket-list.component.jsx @@ -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"; diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx index 52397b90e..a43016d4b 100644 --- a/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx +++ b/client/src/components/time-ticket-modal/time-ticket-modal.component.jsx @@ -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"; diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx index 4f664a11c..c2b33921e 100644 --- a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx +++ b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx @@ -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"; diff --git a/client/src/components/vendors-form/vendors-form.component.jsx b/client/src/components/vendors-form/vendors-form.component.jsx index ddd9b93d8..e616dcab4 100644 --- a/client/src/components/vendors-form/vendors-form.component.jsx +++ b/client/src/components/vendors-form/vendors-form.component.jsx @@ -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"; diff --git a/client/src/contexts/SocketIO/socketProvider.jsx b/client/src/contexts/SocketIO/socketProvider.jsx index 3539908b8..dab7e3bbd 100644 --- a/client/src/contexts/SocketIO/socketProvider.jsx +++ b/client/src/contexts/SocketIO/socketProvider.jsx @@ -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) { diff --git a/client/src/feature-flags/README.md b/client/src/feature-flags/README.md new file mode 100644 index 000000000..724627664 --- /dev/null +++ b/client/src/feature-flags/README.md @@ -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. diff --git a/client/src/feature-flags/splitio-react-replacement.jsx b/client/src/feature-flags/splitio-react-replacement.jsx new file mode 100644 index 000000000..8d4b0fcb7 --- /dev/null +++ b/client/src/feature-flags/splitio-react-replacement.jsx @@ -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 {children}; +} + +/** + * 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 +}; diff --git a/client/src/feature-flags/splitio-react-replacement.test.jsx b/client/src/feature-flags/splitio-react-replacement.test.jsx new file mode 100644 index 000000000..73031fa6a --- /dev/null +++ b/client/src/feature-flags/splitio-react-replacement.test.jsx @@ -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); + }); +}); diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index d3a1e374d..594d329a0 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -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"; diff --git a/client/src/pages/jobs-admin/jobs-admin.page.jsx b/client/src/pages/jobs-admin/jobs-admin.page.jsx index 22ea7577c..df1af7d18 100644 --- a/client/src/pages/jobs-admin/jobs-admin.page.jsx +++ b/client/src/pages/jobs-admin/jobs-admin.page.jsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { useParams } from "react-router-dom"; -import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; +import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement"; import { some } from "lodash"; import axios from "axios"; import AlertComponent from "../../components/alert/alert.component"; diff --git a/client/src/pages/jobs-close/jobs-close.component.jsx b/client/src/pages/jobs-close/jobs-close.component.jsx index c12695afd..3f0c0a5e8 100644 --- a/client/src/pages/jobs-close/jobs-close.component.jsx +++ b/client/src/pages/jobs-close/jobs-close.component.jsx @@ -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"; diff --git a/client/src/pages/production-board/production-board.component.jsx b/client/src/pages/production-board/production-board.component.jsx index 6729acf65..668a6309e 100644 --- a/client/src/pages/production-board/production-board.component.jsx +++ b/client/src/pages/production-board/production-board.component.jsx @@ -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 diff --git a/client/src/pages/production-list/production-list.component.jsx b/client/src/pages/production-list/production-list.component.jsx index 24a47dbb2..5f23a2402 100644 --- a/client/src/pages/production-list/production-list.component.jsx +++ b/client/src/pages/production-list/production-list.component.jsx @@ -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 diff --git a/client/src/pages/tech-dispatched-parts/tech-dispatched-parts.page.jsx b/client/src/pages/tech-dispatched-parts/tech-dispatched-parts.page.jsx index 51b5fbaf6..fbd703333 100644 --- a/client/src/pages/tech-dispatched-parts/tech-dispatched-parts.page.jsx +++ b/client/src/pages/tech-dispatched-parts/tech-dispatched-parts.page.jsx @@ -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 diff --git a/client/src/pages/temporary-docs/temporary-docs.component.jsx b/client/src/pages/temporary-docs/temporary-docs.component.jsx index 131a0df6b..2e713e71d 100644 --- a/client/src/pages/temporary-docs/temporary-docs.component.jsx +++ b/client/src/pages/temporary-docs/temporary-docs.component.jsx @@ -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"; diff --git a/client/src/pages/time-tickets/time-tickets.container.jsx b/client/src/pages/time-tickets/time-tickets.container.jsx index 23608e38b..959f555d5 100644 --- a/client/src/pages/time-tickets/time-tickets.container.jsx +++ b/client/src/pages/time-tickets/time-tickets.container.jsx @@ -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"; diff --git a/client/vite.config.js b/client/vite.config.js index 55c79e91d..3fcc685c3 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -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", diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index a73017a92..975f21528 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -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 diff --git a/hasura/migrations/1778870000000_create_table_public_feature_flags/down.sql b/hasura/migrations/1778870000000_create_table_public_feature_flags/down.sql new file mode 100644 index 000000000..580a75476 --- /dev/null +++ b/hasura/migrations/1778870000000_create_table_public_feature_flags/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "public"."feature_flags"; diff --git a/hasura/migrations/1778870000000_create_table_public_feature_flags/up.sql b/hasura/migrations/1778870000000_create_table_public_feature_flags/up.sql new file mode 100644 index 000000000..390fe28da --- /dev/null +++ b/hasura/migrations/1778870000000_create_table_public_feature_flags/up.sql @@ -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; diff --git a/hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags/down.sql b/hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags/down.sql new file mode 100644 index 000000000..098598736 --- /dev/null +++ b/hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags/down.sql @@ -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"; diff --git a/hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags/up.sql b/hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags/up.sql new file mode 100644 index 000000000..a689ea860 --- /dev/null +++ b/hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags/up.sql @@ -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'; diff --git a/hasura/migrations/1779040000000_allow_custom_feature_flag_treatments/down.sql b/hasura/migrations/1779040000000_allow_custom_feature_flag_treatments/down.sql new file mode 100644 index 000000000..637956710 --- /dev/null +++ b/hasura/migrations/1779040000000_allow_custom_feature_flag_treatments/down.sql @@ -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')); diff --git a/hasura/migrations/1779040000000_allow_custom_feature_flag_treatments/up.sql b/hasura/migrations/1779040000000_allow_custom_feature_flag_treatments/up.sql new file mode 100644 index 000000000..8e0c93aff --- /dev/null +++ b/hasura/migrations/1779040000000_allow_custom_feature_flag_treatments/up.sql @@ -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); diff --git a/hasura/migrations/1779060000000_seed_test_feature_flag/down.sql b/hasura/migrations/1779060000000_seed_test_feature_flag/down.sql new file mode 100644 index 000000000..305f409c8 --- /dev/null +++ b/hasura/migrations/1779060000000_seed_test_feature_flag/down.sql @@ -0,0 +1,2 @@ +DELETE FROM "public"."feature_flags" +WHERE "name" = 'TEST_FLAG'; diff --git a/hasura/migrations/1779060000000_seed_test_feature_flag/up.sql b/hasura/migrations/1779060000000_seed_test_feature_flag/up.sql new file mode 100644 index 000000000..b4ba959e6 --- /dev/null +++ b/hasura/migrations/1779060000000_seed_test_feature_flag/up.sql @@ -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; diff --git a/package.json b/package.json index c1f10ad4c..1f174349e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/export-harness-feature-flags.js b/scripts/export-harness-feature-flags.js new file mode 100644 index 000000000..c8afeb9c0 --- /dev/null +++ b/scripts/export-harness-feature-flags.js @@ -0,0 +1,1047 @@ +#!/usr/bin/env node + +const fs = require("node:fs/promises"); +const path = require("node:path"); + +const API_BASE_URL = "https://api.split.io/internal/api/v2"; +const MAX_FLAG_PAGE_SIZE = 50; +const MAX_SEGMENT_PAGE_SIZE = 50; +const MAX_SEGMENT_KEYS_PAGE_SIZE = 100; + +/** + * Calculates the unsigned MurmurHash3 value Split uses for percentage bucket evaluation. + */ +function murmur3Hash32(value, seed = 0) { + let h1 = seed >>> 0; + const remainder = value.length & 3; + const bytes = value.length - remainder; + const c1 = 0xcc9e2d51; + const c2 = 0x1b873593; + let index = 0; + + while (index < bytes) { + let k1 = + (value.charCodeAt(index) & 0xff) | + ((value.charCodeAt(index + 1) & 0xff) << 8) | + ((value.charCodeAt(index + 2) & 0xff) << 16) | + ((value.charCodeAt(index + 3) & 0xff) << 24); + + index += 4; + k1 = Math.imul(k1, c1); + k1 = (k1 << 15) | (k1 >>> 17); + k1 = Math.imul(k1, c2); + + h1 ^= k1; + h1 = (h1 << 13) | (h1 >>> 19); + h1 = Math.imul(h1, 5) + 0xe6546b64; + } + + let k1 = 0; + + switch (remainder) { + case 3: + k1 ^= (value.charCodeAt(index + 2) & 0xff) << 16; + // falls through + case 2: + k1 ^= (value.charCodeAt(index + 1) & 0xff) << 8; + // falls through + case 1: + k1 ^= value.charCodeAt(index) & 0xff; + k1 = Math.imul(k1, c1); + k1 = (k1 << 15) | (k1 >>> 17); + k1 = Math.imul(k1, c2); + h1 ^= k1; + break; + default: + break; + } + + h1 ^= value.length; + h1 ^= h1 >>> 16; + h1 = Math.imul(h1, 0x85ebca6b); + h1 ^= h1 >>> 13; + h1 = Math.imul(h1, 0xc2b2ae35); + h1 ^= h1 >>> 16; + + return h1 >>> 0; +} + +/** + * Converts a Split target key and seed into a one-based rollout bucket. + */ +function bucket(key, seed) { + return (murmur3Hash32(String(key), Number(seed) || 0) % 100) + 1; +} + +/** + * Parses CLI arguments and environment variables into exporter options. + */ +function parseArgs(argv) { + const args = { + apiKey: process.env.HARNESS_SPLIT_ADMIN_API_KEY || process.env.SPLIT_ADMIN_API_KEY, + sdkKey: process.env.HARNESS_SPLIT_SDK_KEY || process.env.HARNESS_SPLIT_SDK_API_KEY || process.env.SPLIT_SDK_API_KEY, + outputDir: "harness-feature-flags-export", + workspace: null, + environment: null, + includeSegments: true, + bodyshopMap: null + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + + switch (arg) { + case "--api-key": + args.apiKey = next; + index += 1; + break; + case "--sdk-key": + args.sdkKey = next; + index += 1; + break; + case "--output-dir": + args.outputDir = next; + index += 1; + break; + case "--workspace": + args.workspace = next; + index += 1; + break; + case "--environment": + args.environment = next; + index += 1; + break; + case "--bodyshop-map": + args.bodyshopMap = next; + index += 1; + break; + case "--skip-segments": + args.includeSegments = false; + break; + case "--help": + case "-h": + printHelp(); + process.exit(0); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!args.apiKey && !args.sdkKey) { + throw new Error("Missing API key. Set HARNESS_SPLIT_ADMIN_API_KEY, HARNESS_SPLIT_SDK_KEY, --api-key, or --sdk-key."); + } + + return args; +} + +/** + * Prints CLI usage for admin-key and SDK-key export modes. + */ +function printHelp() { + console.log(` +Export Harness/Split feature flags for migration into bodyshop. + +Usage: + node scripts/export-harness-feature-flags.js --workspace Default --environment Production + +Options: + --api-key Split/Harness Admin API key. Defaults to HARNESS_SPLIT_ADMIN_API_KEY. + --sdk-key Split/Harness SDK API key. Used when Admin API is not available. + --workspace Workspace/project to export. Omit to export all workspaces. + --environment Environment to export. Omit to export all environments. + --output-dir Output directory. Defaults to harness-feature-flags-export. + --bodyshop-map Optional JSON mapping of Harness target key to bodyshop UUID. + --skip-segments Do not expand segment-targeted flags. +`); +} + +/** + * Fetches JSON from the Harness/Split Admin API and throws detailed HTTP errors. + */ +async function requestJson(url, apiKey) { + const response = await fetchWithFallbackAuth(url, apiKey); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Harness request failed ${response.status} ${response.statusText}: ${url}\n${body}`); + } + + return response.json(); +} + +/** + * Fetches JSON from the SDK splitChanges endpoint. + */ +async function requestSdkJson(url, sdkKey) { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${sdkKey}`, + Accept: "application/json", + "Accept-Encoding": "identity" + } + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Harness SDK request failed ${response.status} ${response.statusText}: ${url}\n${body}`); + } + + return response.json(); +} + +/** + * Tries Bearer auth first and falls back to the Split x-api-key header. + */ +async function fetchWithFallbackAuth(url, apiKey) { + const bearerResponse = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json" + } + }); + + if (bearerResponse.status !== 401) { + return bearerResponse; + } + + return fetch(url, { + headers: { + "x-api-key": apiKey, + "Content-Type": "application/json" + } + }); +} + +/** + * Normalizes common paginated API response collection shapes into an array. + */ +function getItems(response) { + if (Array.isArray(response)) { + return response; + } + + if (Array.isArray(response.objects)) { + return response.objects; + } + + if (Array.isArray(response.results)) { + return response.results; + } + + return []; +} + +/** + * Fetches all pages from a Split Admin API collection endpoint. + */ +async function fetchPaged(apiKey, url, limit) { + const results = []; + let offset = 0; + + while (true) { + const separator = url.includes("?") ? "&" : "?"; + const response = await requestJson(`${url}${separator}offset=${offset}&limit=${limit}`, apiKey); + const items = getItems(response); + + results.push(...items); + + const totalCount = response.totalCount ?? response.total; + if (typeof totalCount === "number" && results.length >= totalCount) { + break; + } + + if (items.length < limit || Array.isArray(response)) { + break; + } + + offset += limit; + } + + return results; +} + +/** + * Applies optional workspace or environment name/id filters. + */ +function matchesFilter(value, filter) { + if (!filter) { + return true; + } + + return value.id === filter || value.name === filter; +} + +/** + * Makes a workspace/environment name safe for export file names. + */ +function safeFileName(value) { + return String(value) + .replace(/[^a-z0-9._-]+/gi, "_") + .replace(/^_+|_+$/g, ""); +} + +/** + * Writes pretty JSON and creates parent directories as needed. + */ +async function writeJson(filePath, value) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +/** + * Loads an optional Harness target key to bodyshop UUID map. + */ +async function loadBodyshopMap(filePath) { + if (!filePath) { + return new Map(); + } + + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw); + + if (Array.isArray(parsed)) { + return new Map(parsed.map((entry) => [entry.key ?? entry.harnessKey ?? entry.customerKey, entry.bodyshopid ?? entry.bodyshopId])); + } + + return new Map(Object.entries(parsed)); +} + +/** + * Parses Split treatment configuration payloads into JSON when possible. + */ +function getTreatmentConfig(treatment) { + if (treatment.configurations === undefined || treatment.configurations === "") { + return null; + } + + if (typeof treatment.configurations !== "string") { + return treatment.configurations; + } + + try { + return JSON.parse(treatment.configurations); + } catch { + return treatment.configurations; + } +} + +/** + * Checks whether a rule bucket assigns 100 percent of traffic to one treatment. + */ +function isFullTreatmentBucket(bucket) { + return bucket && bucket.size === 100 && typeof bucket.treatment === "string"; +} + +/** + * Returns a rule's single full-treatment bucket when it is simple enough to export. + */ +function getSingleFullTreatment(rule) { + if (!Array.isArray(rule?.buckets) || rule.buckets.length !== 1) { + return null; + } + + return isFullTreatmentBucket(rule.buckets[0]) ? rule.buckets[0].treatment : null; +} + +/** + * Returns the one treatment that receives 100 percent of SDK partitions. + */ +function getFullPartitionTreatment(partitions) { + const fullPartitions = (partitions || []).filter((partition) => partition.size === 100); + + return fullPartitions.length === 1 ? fullPartitions[0].treatment : null; +} + +/** + * Evaluates an SDK percentage rollout for one known target key. + */ +function getPartitionTreatmentForKey({ key, seed, partitions }) { + const totalSize = (partitions || []).reduce((sum, partition) => sum + partition.size, 0); + if (totalSize !== 100 || !key || seed == null) { + return null; + } + + const keyBucket = bucket(key, seed); + let upperBound = 0; + + for (const partition of partitions) { + upperBound += partition.size; + if (keyBucket <= upperBound) { + return { + treatment: partition.treatment, + bucket: keyBucket, + source: "sdk.partition" + }; + } + } + + return null; +} + +/** + * Extracts matchers from a Split Admin API rule. + */ +function getRuleMatchers(rule) { + const matchers = rule?.condition?.matchers; + return Array.isArray(matchers) ? matchers : []; +} + +/** + * Adds or replaces an assignment, keeping the highest-priority source for a key/flag pair. + */ +function addAssignment(assignments, assignment) { + const key = `${assignment.workspaceId}:${assignment.environmentId}:${assignment.customerKey}:${assignment.name}`; + const existing = assignments.get(key); + + if (!existing || assignment.priority < existing.priority) { + assignments.set(key, assignment); + } +} + +/** + * Converts boolean-ish and custom treatment values into database-ready text. + */ +function normalizeTreatment(treatment) { + if (treatment === true) { + return "on"; + } + + if (treatment === false) { + return "off"; + } + + if (typeof treatment === "string") { + const trimmed = treatment.trim(); + if (!trimmed) { + return "control"; + } + + const lowered = trimmed.toLowerCase(); + if (lowered === "true") { + return "on"; + } + if (lowered === "false") { + return "off"; + } + if (["on", "off", "control"].includes(lowered)) { + return lowered; + } + + return trimmed; + } + + if (treatment == null) { + return "control"; + } + + const fallback = String(treatment).trim(); + return fallback || "control"; +} + +/** + * Collects explicit per-treatment target key assignments from an Admin API flag definition. + */ +function collectDirectTreatmentAssignments({ assignments, definition, workspace, environment }) { + for (const treatment of definition.treatments || []) { + const treatmentName = normalizeTreatment(treatment.name); + const config = getTreatmentConfig(treatment); + + for (const customerKey of treatment.keys || []) { + addAssignment(assignments, { + workspaceId: workspace.id, + workspaceName: workspace.name, + environmentId: environment.id, + environmentName: environment.name, + customerKey, + imexshopid: customerKey, + name: definition.name, + treatment: treatmentName, + config, + source: "treatment.keys", + priority: 0 + }); + } + } +} + +/** + * Expands per-treatment segment assignments when segment keys were exported. + */ +function collectTreatmentSegmentAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }) { + for (const treatment of definition.treatments || []) { + const treatmentName = normalizeTreatment(treatment.name); + const config = getTreatmentConfig(treatment); + + for (const segmentName of treatment.segments || []) { + const segmentKeys = segmentKeysByName.get(segmentName); + + if (!segmentKeys) { + unmappedRules.push({ + workspace: workspace.name, + environment: environment.name, + flag: definition.name, + source: "treatment.segments", + segment: segmentName, + reason: "Segment keys were not exported." + }); + continue; + } + + for (const customerKey of segmentKeys) { + addAssignment(assignments, { + workspaceId: workspace.id, + workspaceName: workspace.name, + environmentId: environment.id, + environmentName: environment.name, + customerKey, + imexshopid: customerKey, + name: definition.name, + treatment: treatmentName, + config, + source: `treatment.segment:${segmentName}`, + priority: 10 + }); + } + } + } +} + +/** + * Converts simple list/segment rules into fixed bodyshop assignments. + */ +function collectSimpleRuleAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }) { + for (const [ruleIndex, rule] of (definition.rules || []).entries()) { + const treatment = getSingleFullTreatment(rule); + const matchers = getRuleMatchers(rule); + + if (!treatment) { + unmappedRules.push({ + workspace: workspace.name, + environment: environment.name, + flag: definition.name, + source: `rules[${ruleIndex}]`, + reason: "Rule is not a single 100% treatment bucket.", + rule + }); + continue; + } + + let expanded = false; + + for (const matcher of matchers) { + if (matcher.type === "IN_SEGMENT" && matcher.string) { + const segmentKeys = segmentKeysByName.get(matcher.string); + + if (!segmentKeys) { + unmappedRules.push({ + workspace: workspace.name, + environment: environment.name, + flag: definition.name, + source: `rules[${ruleIndex}]`, + segment: matcher.string, + reason: "Segment keys were not exported.", + rule + }); + continue; + } + + for (const customerKey of segmentKeys) { + addAssignment(assignments, { + workspaceId: workspace.id, + workspaceName: workspace.name, + environmentId: environment.id, + environmentName: environment.name, + customerKey, + imexshopid: customerKey, + name: definition.name, + treatment: normalizeTreatment(treatment), + config: null, + source: `rule.segment:${matcher.string}`, + priority: 20 + ruleIndex + }); + } + + expanded = true; + } + + if (matcher.type === "IN_LIST_STRING" && Array.isArray(matcher.strings) && !matcher.attribute) { + for (const customerKey of matcher.strings) { + addAssignment(assignments, { + workspaceId: workspace.id, + workspaceName: workspace.name, + environmentId: environment.id, + environmentName: environment.name, + customerKey, + imexshopid: customerKey, + name: definition.name, + treatment: normalizeTreatment(treatment), + config: null, + source: "rule.key-list", + priority: 20 + ruleIndex + }); + } + + expanded = true; + } + } + + if (!expanded) { + unmappedRules.push({ + workspace: workspace.name, + environment: environment.name, + flag: definition.name, + source: `rules[${ruleIndex}]`, + reason: "Rule uses attributes, percentages, dependencies, or another matcher that cannot be converted to fixed customer assignments.", + rule + }); + } + } +} + +/** + * Records all-traffic default rules that are not bodyshop-specific assignments. + */ +function collectGlobalDefaults({ globalDefaults, definition, workspace, environment }) { + const defaultRule = definition.defaultRule || definition.default_rule || []; + + if (!Array.isArray(defaultRule) || defaultRule.length !== 1 || !isFullTreatmentBucket(defaultRule[0])) { + return; + } + + globalDefaults.push({ + workspaceId: workspace.id, + workspaceName: workspace.name, + environmentId: environment.id, + environmentName: environment.name, + name: definition.name, + treatment: normalizeTreatment(defaultRule[0].treatment), + source: "defaultRule" + }); +} + +/** + * Converts an Admin API flag definition into the export's canonical definition shape. + */ +function normalizeFlagDefinition(definition, workspace, environment) { + return { + workspaceId: workspace.id, + workspaceName: workspace.name, + environmentId: environment.id, + environmentName: environment.name, + name: definition.name, + description: definition.description || null, + defaultTreatment: normalizeTreatment(definition.defaultTreatment), + baselineTreatment: normalizeTreatment(definition.baselineTreatment), + killed: Boolean(definition.killed), + trafficAllocation: definition.trafficAllocation ?? null, + treatments: (definition.treatments || []).map((treatment) => ({ + name: treatment.name, + description: treatment.description || null, + config: getTreatmentConfig(treatment) + })) + }; +} + +/** + * Parses SDK treatment config for one treatment name. + */ +function parseSdkConfig(configurations, treatment) { + const rawConfig = configurations?.[treatment]; + + if (rawConfig === undefined || rawConfig === "") { + return null; + } + + if (typeof rawConfig !== "string") { + return rawConfig; + } + + try { + return JSON.parse(rawConfig); + } catch { + return rawConfig; + } +} + +/** + * Converts an SDK splitChanges flag definition into the canonical export shape. + */ +function normalizeSdkFlagDefinition(definition) { + const treatments = new Set([definition.defaultTreatment, definition.baselineTreatment]); + + for (const condition of definition.conditions || []) { + for (const partition of condition.partitions || []) { + if (partition.treatment) { + treatments.add(partition.treatment); + } + } + } + + return { + workspaceId: null, + workspaceName: "sdk-export", + environmentId: null, + environmentName: "sdk-key-environment", + name: definition.name, + description: null, + defaultTreatment: normalizeTreatment(definition.defaultTreatment), + baselineTreatment: normalizeTreatment(definition.baselineTreatment), + killed: Boolean(definition.killed), + trafficAllocation: definition.trafficAllocation ?? null, + treatments: Array.from(treatments) + .filter(Boolean) + .map((treatment) => ({ + name: treatment, + description: null, + config: parseSdkConfig(definition.configurations, treatment) + })) + }; +} + +/** + * Converts SDK splitChanges conditions into fixed target-key assignments where possible. + */ +function collectSdkAssignments({ assignments, definition, bodyshopMap, unmappedRules, globalDefaults }) { + for (const [conditionIndex, condition] of (definition.conditions || []).entries()) { + const matchers = condition.matcherGroup?.matchers || []; + + if (matchers.length !== 1) { + unmappedRules.push({ + workspace: "sdk-export", + environment: "sdk-key-environment", + flag: definition.name, + source: `conditions[${conditionIndex}]`, + reason: "Condition is not a single matcher.", + condition + }); + continue; + } + + const matcher = matchers[0]; + + if (matcher.matcherType === "ALL_KEYS") { + const treatment = getFullPartitionTreatment(condition.partitions); + + if (!treatment) { + unmappedRules.push({ + workspace: "sdk-export", + environment: "sdk-key-environment", + flag: definition.name, + source: `conditions[${conditionIndex}]`, + reason: "ALL_KEYS percentage rollout cannot be converted without a known target key list.", + condition + }); + continue; + } + + globalDefaults.push({ + workspaceId: null, + workspaceName: "sdk-export", + environmentId: null, + environmentName: "sdk-key-environment", + name: definition.name, + treatment: normalizeTreatment(treatment), + source: "ALL_KEYS" + }); + continue; + } + + if (matcher.matcherType === "WHITELIST" && !matcher.keySelector?.attribute) { + for (const customerKey of matcher.whitelistMatcherData?.whitelist || []) { + const resolved = getFullPartitionTreatment(condition.partitions) + ? { treatment: getFullPartitionTreatment(condition.partitions), source: "sdk.whitelist" } + : getPartitionTreatmentForKey({ key: customerKey, seed: definition.seed, partitions: condition.partitions }); + + if (!resolved?.treatment) { + unmappedRules.push({ + workspace: "sdk-export", + environment: "sdk-key-environment", + flag: definition.name, + source: `conditions[${conditionIndex}]`, + reason: "Whitelist percentage rollout could not be bucketed.", + customerKey, + condition + }); + continue; + } + + addAssignment(assignments, { + workspaceId: null, + workspaceName: "sdk-export", + environmentId: null, + environmentName: "sdk-key-environment", + customerKey, + imexshopid: customerKey, + bodyshopid: bodyshopMap.get(customerKey) || null, + name: definition.name, + treatment: normalizeTreatment(resolved.treatment), + config: parseSdkConfig(definition.configurations, resolved.treatment), + source: resolved.source, + bucket: resolved.bucket, + priority: conditionIndex + }); + } + continue; + } + + unmappedRules.push({ + workspace: "sdk-export", + environment: "sdk-key-environment", + flag: definition.name, + source: `conditions[${conditionIndex}]`, + reason: `SDK matcher ${matcher.matcherType} cannot be converted to fixed customer assignments.`, + condition + }); + } +} + +/** + * Escapes a value for use as a single-quoted SQL literal. + */ +function sqlString(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +/** + * Builds import SQL that maps exported target keys to bodyshops by imexshopid. + */ +function buildImportSql(assignments) { + const rows = assignments.filter((assignment) => assignment.bodyshopid || assignment.imexshopid || assignment.customerKey); + + if (rows.length === 0) { + return "-- No bodyshop assignments were found.\n"; + } + + const values = rows + .map((assignment) => { + const imexshopid = assignment.imexshopid || assignment.customerKey; + const config = assignment.config === null ? "NULL::jsonb" : `${sqlString(JSON.stringify(assignment.config))}::jsonb`; + return `(${sqlString(imexshopid)}, ${sqlString(assignment.name)}, ${sqlString(assignment.treatment)}, ${config})`; + }) + .join(",\n "); + + const imexshopidValues = Array.from(new Set(rows.map((assignment) => assignment.imexshopid || assignment.customerKey))) + .map((imexshopid) => `(${sqlString(imexshopid)})`) + .join(",\n "); + + const flagNameValues = Array.from(new Set(rows.map((assignment) => assignment.name))) + .map((name) => `(${sqlString(name)})`) + .join(",\n "); + + return `WITH "exported_flags" ("imexshopid", "name", "treatment", "config") AS (\n VALUES\n ${values}\n),\n"matched_flags" AS (\n SELECT\n "bodyshops"."id" AS "bodyshopid",\n "exported_flags"."name",\n "exported_flags"."treatment",\n "exported_flags"."config"\n FROM "exported_flags"\n INNER JOIN "public"."bodyshops"\n ON lower("bodyshops"."imexshopid") = lower("exported_flags"."imexshopid")\n INNER JOIN "public"."feature_flags"\n ON "feature_flags"."name" = "exported_flags"."name"\n)\nINSERT INTO "public"."bodyshop_feature_flags" ("bodyshopid", "name", "treatment", "config")\nSELECT "bodyshopid", "name", "treatment", "config"\nFROM "matched_flags"\nON CONFLICT ("bodyshopid", "name") DO UPDATE\nSET\n "treatment" = EXCLUDED."treatment",\n "config" = EXCLUDED."config";\n\nWITH "exported_imexshopids" ("imexshopid") AS (\n VALUES\n ${imexshopidValues}\n)\nSELECT "exported_imexshopids"."imexshopid" AS "unmatched_imexshopid"\nFROM "exported_imexshopids"\nLEFT JOIN "public"."bodyshops"\n ON lower("bodyshops"."imexshopid") = lower("exported_imexshopids"."imexshopid")\nWHERE "bodyshops"."id" IS NULL\nORDER BY "exported_imexshopids"."imexshopid";\n\nWITH "exported_flag_names" ("name") AS (\n VALUES\n ${flagNameValues}\n)\nSELECT "exported_flag_names"."name" AS "unmatched_feature_flag"\nFROM "exported_flag_names"\nLEFT JOIN "public"."feature_flags"\n ON "feature_flags"."name" = "exported_flag_names"."name"\nWHERE "feature_flags"."name" IS NULL\nORDER BY "exported_flag_names"."name";\n`; +} + +/** + * Exports segment definitions and target keys for an Admin API environment. + */ +async function exportSegments({ apiKey, workspace, environment, outputDir }) { + const segmentKeysByName = new Map(); + const segments = await fetchPaged( + apiKey, + `${API_BASE_URL}/segments/ws/${encodeURIComponent(workspace.id)}/environments/${encodeURIComponent(environment.id)}`, + MAX_SEGMENT_PAGE_SIZE + ); + + await writeJson( + path.join(outputDir, "raw", `${safeFileName(workspace.name)}_${safeFileName(environment.name)}_segments.json`), + segments + ); + + for (const segment of segments) { + const keys = []; + let offset = 0; + + while (true) { + const response = await requestJson( + `${API_BASE_URL}/segments/${encodeURIComponent(environment.id)}/${encodeURIComponent(segment.name)}/keys?offset=${offset}&limit=${MAX_SEGMENT_KEYS_PAGE_SIZE}`, + apiKey + ); + const pageKeys = (response.keys || []).map((entry) => (typeof entry === "string" ? entry : entry.key)).filter(Boolean); + + keys.push(...pageKeys); + + if (pageKeys.length < MAX_SEGMENT_KEYS_PAGE_SIZE) { + break; + } + + offset += MAX_SEGMENT_KEYS_PAGE_SIZE; + } + + segmentKeysByName.set(segment.name, keys); + } + + return { + segments, + segmentKeysByName, + segmentKeys: Object.fromEntries(segmentKeysByName) + }; +} + +/** + * Runs a full Admin API export across matching workspaces and environments. + */ +async function exportAdmin(args) { + const outputDir = path.resolve(args.outputDir); + const bodyshopMap = await loadBodyshopMap(args.bodyshopMap); + + await fs.mkdir(outputDir, { recursive: true }); + + const workspaces = (await fetchPaged(args.apiKey, `${API_BASE_URL}/workspaces`, 200)).filter((workspace) => + matchesFilter(workspace, args.workspace) + ); + + const normalizedFlags = []; + const allSegments = []; + const allSegmentKeys = {}; + const assignments = new Map(); + const unmappedRules = []; + const globalDefaults = []; + + await writeJson(path.join(outputDir, "raw", "workspaces.json"), workspaces); + + for (const workspace of workspaces) { + const environments = (await requestJson(`${API_BASE_URL}/environments/ws/${encodeURIComponent(workspace.id)}`, args.apiKey)).filter( + (environment) => matchesFilter(environment, args.environment) + ); + + await writeJson(path.join(outputDir, "raw", `${safeFileName(workspace.name)}_environments.json`), environments); + + for (const environment of environments) { + const { segments, segmentKeysByName, segmentKeys } = args.includeSegments + ? await exportSegments({ apiKey: args.apiKey, workspace, environment, outputDir }) + : { segments: [], segmentKeysByName: new Map(), segmentKeys: {} }; + + allSegments.push( + ...segments.map((segment) => ({ + workspaceId: workspace.id, + workspaceName: workspace.name, + environmentId: environment.id, + environmentName: environment.name, + ...segment + })) + ); + allSegmentKeys[`${workspace.name}/${environment.name}`] = segmentKeys; + + const definitions = await fetchPaged( + args.apiKey, + `${API_BASE_URL}/splits/ws/${encodeURIComponent(workspace.id)}/environments/${encodeURIComponent(environment.id)}`, + MAX_FLAG_PAGE_SIZE + ); + + await writeJson( + path.join(outputDir, "raw", `${safeFileName(workspace.name)}_${safeFileName(environment.name)}_flag_definitions.json`), + definitions + ); + + for (const definition of definitions) { + normalizedFlags.push(normalizeFlagDefinition(definition, workspace, environment)); + collectGlobalDefaults({ globalDefaults, definition, workspace, environment }); + collectDirectTreatmentAssignments({ assignments, definition, workspace, environment }); + collectTreatmentSegmentAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }); + collectSimpleRuleAssignments({ assignments, definition, workspace, environment, segmentKeysByName, unmappedRules }); + } + } + } + + const normalizedAssignments = Array.from(assignments.values()) + .map((assignment) => ({ + ...assignment, + imexshopid: assignment.imexshopid || assignment.customerKey, + bodyshopid: bodyshopMap.get(assignment.customerKey) || null + })) + .sort((a, b) => a.customerKey.localeCompare(b.customerKey) || a.name.localeCompare(b.name)); + + await writeJson(path.join(outputDir, "feature_flags.json"), normalizedFlags); + await writeJson(path.join(outputDir, "bodyshop_feature_flags.json"), normalizedAssignments); + await writeJson(path.join(outputDir, "segments.json"), allSegments); + await writeJson(path.join(outputDir, "segment_keys.json"), allSegmentKeys); + await writeJson(path.join(outputDir, "global_defaults.json"), globalDefaults); + await writeJson(path.join(outputDir, "unmapped_rules.json"), unmappedRules); + await fs.writeFile(path.join(outputDir, "bodyshop_feature_flags_import.sql"), buildImportSql(normalizedAssignments)); + + console.log(`Export complete: ${outputDir}`); + console.log(`Flags: ${normalizedFlags.length}`); + console.log(`Customer assignments: ${normalizedAssignments.length}`); + console.log(`Global defaults: ${globalDefaults.length}`); + console.log(`Unmapped rules: ${unmappedRules.length}`); +} + +/** + * Runs an SDK-key export using the splitChanges endpoint. + */ +async function exportSdk(args) { + const sdkKey = args.sdkKey || args.apiKey; + const outputDir = path.resolve(args.outputDir); + const bodyshopMap = await loadBodyshopMap(args.bodyshopMap); + + await fs.mkdir(outputDir, { recursive: true }); + + const splitChanges = await requestSdkJson("https://sdk.split.io/api/splitChanges?since=-1", sdkKey); + const normalizedFlags = []; + const assignments = new Map(); + const unmappedRules = []; + const globalDefaults = []; + + await writeJson(path.join(outputDir, "raw", "sdk_split_changes.json"), splitChanges); + + for (const definition of splitChanges.splits || []) { + normalizedFlags.push(normalizeSdkFlagDefinition(definition)); + collectSdkAssignments({ assignments, definition, bodyshopMap, unmappedRules, globalDefaults }); + } + + const normalizedAssignments = Array.from(assignments.values()).sort( + (a, b) => a.customerKey.localeCompare(b.customerKey) || a.name.localeCompare(b.name) + ); + + await writeJson(path.join(outputDir, "feature_flags.json"), normalizedFlags); + await writeJson(path.join(outputDir, "bodyshop_feature_flags.json"), normalizedAssignments); + await writeJson(path.join(outputDir, "segments.json"), []); + await writeJson(path.join(outputDir, "segment_keys.json"), {}); + await writeJson(path.join(outputDir, "global_defaults.json"), globalDefaults); + await writeJson(path.join(outputDir, "unmapped_rules.json"), unmappedRules); + await fs.writeFile(path.join(outputDir, "bodyshop_feature_flags_import.sql"), buildImportSql(normalizedAssignments)); + + console.log(`SDK export complete: ${outputDir}`); + console.log(`Flags: ${normalizedFlags.length}`); + console.log(`Customer assignments: ${normalizedAssignments.length}`); + console.log(`Global defaults: ${globalDefaults.length}`); + console.log(`Unmapped rules: ${unmappedRules.length}`); +} + +/** + * Entrypoint that selects Admin API export or SDK fallback mode. + */ +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (!args.apiKey && args.sdkKey) { + await exportSdk(args); + return; + } + + try { + await exportAdmin(args); + } catch (error) { + if (!/401 Unauthorized/.test(error.message) || !(args.sdkKey || args.apiKey)) { + throw error; + } + + console.warn("Admin API key was unauthorized; falling back to SDK splitChanges export."); + await exportSdk(args); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} + +module.exports = { + buildImportSql, + normalizeTreatment, + sqlString +}; diff --git a/server.js b/server.js index 0398cf2cf..4fa71f017 100644 --- a/server.js +++ b/server.js @@ -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")); diff --git a/server/admin/adminops.feature-flags.test.js b/server/admin/adminops.feature-flags.test.js new file mode 100644 index 000000000..2bab42a4e --- /dev/null +++ b/server/admin/adminops.feature-flags.test.js @@ -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(); + }); +}); diff --git a/server/admin/adminops.js b/server/admin/adminops.js index a62a1fde3..e5b551f2c 100644 --- a/server/admin/adminops.js +++ b/server/admin/adminops.js @@ -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); diff --git a/server/feature-flags/admin-payload.js b/server/feature-flags/admin-payload.js new file mode 100644 index 000000000..1f5a470ce --- /dev/null +++ b/server/feature-flags/admin-payload.js @@ -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 +}; diff --git a/server/feature-flags/admin-payload.test.js b/server/feature-flags/admin-payload.test.js new file mode 100644 index 000000000..cb78af760 --- /dev/null +++ b/server/feature-flags/admin-payload.test.js @@ -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({}); + }); +}); diff --git a/server/feature-flags/export-harness-feature-flags.test.js b/server/feature-flags/export-harness-feature-flags.test.js new file mode 100644 index 000000000..8b52d9b7a --- /dev/null +++ b/server/feature-flags/export-harness-feature-flags.test.js @@ -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')"); + }); +}); diff --git a/server/feature-flags/feature-flags.js b/server/feature-flags/feature-flags.js new file mode 100644 index 000000000..2748aa944 --- /dev/null +++ b/server/feature-flags/feature-flags.js @@ -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 +}; diff --git a/server/feature-flags/feature-flags.test.js b/server/feature-flags/feature-flags.test.js new file mode 100644 index 000000000..4d7bcea68 --- /dev/null +++ b/server/feature-flags/feature-flags.test.js @@ -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 }); + }); +}); diff --git a/server/feature-flags/socket-events.js b/server/feature-flags/socket-events.js new file mode 100644 index 000000000..5be1be2f4 --- /dev/null +++ b/server/feature-flags/socket-events.js @@ -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 +}; diff --git a/server/feature-flags/socket-events.test.js b/server/feature-flags/socket-events.test.js new file mode 100644 index 000000000..31fdb5fe4 --- /dev/null +++ b/server/feature-flags/socket-events.test.js @@ -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" })); + }); +}); diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index fe5321176..cb102a463 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -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 } } } -` \ No newline at end of file +` diff --git a/server/routes/adminRoutes.js b/server/routes/adminRoutes.js index 909f11344..8f077f949 100644 --- a/server/routes/adminRoutes.js +++ b/server/routes/adminRoutes.js @@ -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); diff --git a/server/routes/featureFlagRoutes.js b/server/routes/featureFlagRoutes.js new file mode 100644 index 000000000..02585ae55 --- /dev/null +++ b/server/routes/featureFlagRoutes.js @@ -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; diff --git a/server/utils/redisHelpers.feature-flags.test.js b/server/utils/redisHelpers.feature-flags.test.js new file mode 100644 index 000000000..7f9ff9ec0 --- /dev/null +++ b/server/utils/redisHelpers.feature-flags.test.js @@ -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); + }); +}); diff --git a/server/utils/redisHelpers.js b/server/utils/redisHelpers.js index 0ca8b599d..b1785e415 100644 --- a/server/utils/redisHelpers.js +++ b/server/utils/redisHelpers.js @@ -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,