feature/IO-3701-Harness-Replacement - Implement

This commit is contained in:
Dave
2026-05-20 14:41:24 -04:00
parent 84ec68f142
commit deb2fc28ce
100 changed files with 4813 additions and 773 deletions

View File

@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
/**
* Creates the minimal Express response mock needed by admin route handlers.
*/
const createResponse = () => {
const res = {
body: null,
code: 200,
json: vi.fn((body) => {
res.body = body;
return res;
}),
status: vi.fn((code) => {
res.code = code;
return res;
})
};
return res;
};
/**
* Loads admin operations with CommonJS dependency cache overrides for isolated route tests.
*/
const loadAdminOps = async ({ request }) => {
const adminOpsPath = require.resolve("./adminops");
const graphqlClientPath = require.resolve("../graphql-client/graphql-client");
const loggerPath = require.resolve("../utils/logger");
const socketEventsPath = require.resolve("../feature-flags/socket-events");
delete require.cache[adminOpsPath];
require.cache[graphqlClientPath] = {
exports: {
client: {
request
}
}
};
require.cache[loggerPath] = {
exports: {
log: vi.fn()
}
};
require.cache[socketEventsPath] = {
exports: {
emitFeatureFlagsChanged: vi.fn()
}
};
return require("./adminops");
};
afterEach(() => {
delete require.cache[require.resolve("./adminops")];
delete require.cache[require.resolve("../graphql-client/graphql-client")];
delete require.cache[require.resolve("../utils/logger")];
delete require.cache[require.resolve("../feature-flags/socket-events")];
});
describe("feature flag admin delete guard", () => {
it("does not delete a feature flag assigned to shops", async () => {
const request = vi.fn(async () => ({
bodyshop_feature_flags_aggregate: {
aggregate: {
count: 2
}
}
}));
const { deleteFeatureFlag } = await loadAdminOps({ request });
const req = {
params: { name: "Enhanced_Payroll" },
user: { email: "admin@example.com" }
};
const res = createResponse();
await deleteFeatureFlag(req, res);
expect(res.code).toBe(409);
expect(res.body).toEqual({
error: "Feature flag Enhanced_Payroll is assigned to 2 shops. Remove shop assignments before deleting it.",
assignmentCount: 2
});
expect(request).toHaveBeenCalledTimes(1);
});
it("deletes a feature flag with no shop assignments", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
bodyshop_feature_flags_aggregate: {
aggregate: {
count: 0
}
}
})
.mockResolvedValueOnce({
delete_feature_flags_by_pk: {
name: "Unused_Flag"
}
});
const { deleteFeatureFlag } = await loadAdminOps({ request });
const req = {
params: { name: "Unused_Flag" },
sessionUtils: {
invalidateAllBodyshopFeatureFlagsInRedis: vi.fn()
},
user: { email: "admin@example.com" }
};
const res = createResponse();
await deleteFeatureFlag(req, res);
expect(res.body).toEqual({ name: "Unused_Flag" });
expect(request).toHaveBeenCalledTimes(2);
expect(req.sessionUtils.invalidateAllBodyshopFeatureFlagsInRedis).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,10 @@
const logger = require("../utils/logger");
const client = require("../graphql-client/graphql-client").client;
const {
sanitizeFeatureFlagCreatePayload,
sanitizeFeatureFlagUpdatePayload
} = require("../feature-flags/admin-payload");
const { emitFeatureFlagsChanged } = require("../feature-flags/socket-events");
exports.createAssociation = async (req, res) => {
logger.log("admin-create-association", "debug", req.user.email, null, {
@@ -112,12 +117,455 @@ exports.updateCounter = async (req, res) => {
res.status(500).json(error);
}
};
/**
* Lists feature flag definitions for the admin UI, optionally including inactive flags.
*/
exports.getFeatureFlags = async (req, res) => {
logger.log("admin-get-feature-flags", "debug", req.user.email, null, {
ioadmin: true
});
try {
const includeInactive = req.query?.includeInactive === "true";
const result = await client.request(
`query GET_FEATURE_FLAGS($where: feature_flags_bool_exp!) {
feature_flags(where: $where, order_by: { name: asc }) {
name
description
default_treatment
active
created_at
updated_at
bodyshop_feature_flags_aggregate {
aggregate {
count
}
}
}
}`,
{ where: includeInactive ? {} : { active: { _eq: true } } }
);
res.json(result.feature_flags || []);
} catch (error) {
logger.log("admin-get-feature-flags-error", "error", req.user.email, null, {
message: error.message,
stack: error.stack,
ioadmin: true
});
res.status(500).json(error);
}
};
/**
* Creates a global feature flag definition and invalidates all runtime flag caches.
*/
exports.createFeatureFlag = async (req, res) => {
logger.log("admin-create-feature-flag", "debug", req.user.email, null, {
request: req.body,
ioadmin: true
});
try {
const featureFlag = sanitizeFeatureFlagCreatePayload(req.body);
const result = await client.request(
`mutation CREATE_FEATURE_FLAG($featureFlag: feature_flags_insert_input!) {
insert_feature_flags_one(object: $featureFlag) {
name
description
default_treatment
active
}
}`,
{ featureFlag }
);
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: featureFlag.name });
res.status(201).json(result.insert_feature_flags_one);
} catch (error) {
logger.log("admin-create-feature-flag-error", "error", req.user.email, null, {
message: error.message,
stack: error.stack,
request: req.body,
ioadmin: true
});
res.status(500).json(error);
}
};
/**
* Updates editable fields on a global feature flag definition.
*/
exports.updateFeatureFlag = async (req, res) => {
logger.log("admin-update-feature-flag", "debug", req.user.email, null, {
name: req.params.name,
request: req.body,
ioadmin: true
});
try {
const featureFlag = sanitizeFeatureFlagUpdatePayload(req.body);
if (Object.keys(featureFlag).length === 0) {
return res.status(400).json({ error: "No editable feature flag fields were provided." });
}
const result = await client.request(
`mutation UPDATE_FEATURE_FLAG($name: String!, $featureFlag: feature_flags_set_input!) {
update_feature_flags_by_pk(pk_columns: { name: $name }, _set: $featureFlag) {
name
description
default_treatment
active
}
}`,
{ name: req.params.name, featureFlag }
);
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: req.params.name });
res.json(result.update_feature_flags_by_pk);
} catch (error) {
logger.log("admin-update-feature-flag-error", "error", req.user.email, null, {
name: req.params.name,
message: error.message,
stack: error.stack,
request: req.body,
ioadmin: true
});
res.status(500).json(error);
}
};
/**
* Deletes an unassigned feature flag definition after confirming no bodyshop assignments exist.
*/
exports.deleteFeatureFlag = async (req, res) => {
logger.log("admin-delete-feature-flag", "debug", req.user.email, null, {
name: req.params.name,
ioadmin: true
});
try {
const assignmentResult = await client.request(
`query COUNT_FEATURE_FLAG_ASSIGNMENTS($name: String!) {
bodyshop_feature_flags_aggregate(where: { name: { _eq: $name } }) {
aggregate {
count
}
}
}`,
{ name: req.params.name }
);
const assignmentCount = assignmentResult.bodyshop_feature_flags_aggregate?.aggregate?.count || 0;
if (assignmentCount > 0) {
return res.status(409).json({
error: `Feature flag ${req.params.name} is assigned to ${assignmentCount} shop${
assignmentCount === 1 ? "" : "s"
}. Remove shop assignments before deleting it.`,
assignmentCount
});
}
const result = await client.request(
`mutation DELETE_FEATURE_FLAG($name: String!) {
delete_feature_flags_by_pk(name: $name) {
name
}
}`,
{ name: req.params.name }
);
await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags", name: req.params.name });
res.json(result.delete_feature_flags_by_pk);
} catch (error) {
logger.log("admin-delete-feature-flag-error", "error", req.user.email, null, {
name: req.params.name,
message: error.message,
stack: error.stack,
ioadmin: true
});
res.status(500).json(error);
}
};
/**
* Lists feature flag assignments for one bodyshop in the admin UI.
*/
exports.getBodyshopFeatureFlags = async (req, res) => {
const bodyshopId = req.params.bodyshopId;
logger.log("admin-get-bodyshop-feature-flags", "debug", req.user.email, null, {
bodyshopId,
ioadmin: true
});
try {
const result = await client.request(
`query GET_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }, order_by: { name: asc }) {
name
treatment
config
activeDate
deactiveDate
}
}`,
{ bodyshopid: bodyshopId }
);
res.json(result.bodyshop_feature_flags || []);
} catch (error) {
logger.log("admin-get-bodyshop-feature-flags-error", "error", req.user.email, null, {
bodyshopId,
message: error.message,
stack: error.stack,
ioadmin: true
});
res.status(500).json(error);
}
};
/**
* Lists bodyshop assignments for one feature flag definition.
*/
exports.getFeatureFlagBodyshops = async (req, res) => {
const name = req.params.name;
logger.log("admin-get-feature-flag-bodyshops", "debug", req.user.email, null, {
name,
ioadmin: true
});
try {
const result = await client.request(
`query GET_FEATURE_FLAG_BODYSHOPS($name: String!) {
bodyshop_feature_flags(where: { name: { _eq: $name } }, order_by: { bodyshop: { shopname: asc } }) {
id
bodyshopid
name
treatment
config
activeDate
deactiveDate
bodyshop {
id
shopname
imexshopid
}
}
}`,
{ name }
);
res.json(result.bodyshop_feature_flags || []);
} catch (error) {
logger.log("admin-get-feature-flag-bodyshops-error", "error", req.user.email, null, {
name,
message: error.message,
stack: error.stack,
ioadmin: true
});
res.status(500).json(error);
}
};
/**
* Replaces the set of bodyshops assigned to one feature flag and refreshes affected clients.
*/
exports.updateFeatureFlagBodyshops = async (req, res) => {
const name = req.params.name;
const assignments = Array.isArray(req.body?.assignments) ? req.body.assignments : [];
const bodyshopIds = assignments.map((assignment) => assignment.bodyshopid).filter(Boolean);
const objects = assignments
.filter((assignment) => assignment.bodyshopid)
.map((assignment) => ({
bodyshopid: assignment.bodyshopid,
name,
treatment: assignment.treatment || "on",
config: assignment.config ?? null,
activeDate: assignment.activeDate || null,
deactiveDate: assignment.deactiveDate || null
}));
logger.log("admin-update-feature-flag-bodyshops", "debug", req.user.email, null, {
name,
assignmentCount: objects.length,
ioadmin: true
});
try {
const result = await client.request(
`mutation UPDATE_FEATURE_FLAG_BODYSHOPS(
$name: String!,
$bodyshopIds: [uuid!]!,
$objects: [bodyshop_feature_flags_insert_input!]!
) {
delete_bodyshop_feature_flags(where: { name: { _eq: $name }, bodyshopid: { _nin: $bodyshopIds } }) {
affected_rows
returning {
bodyshopid
}
}
insert_bodyshop_feature_flags(
objects: $objects,
on_conflict: {
constraint: bodyshop_feature_flags_bodyshopid_name_key,
update_columns: [treatment, config, activeDate, deactiveDate]
}
) {
affected_rows
returning {
bodyshopid
}
}
}`,
{ name, bodyshopIds, objects }
);
const changedBodyshopIds = [
...(result.delete_bodyshop_feature_flags?.returning || []),
...(result.insert_bodyshop_feature_flags?.returning || [])
].map((row) => row.bodyshopid);
await Promise.all(
Array.from(new Set(changedBodyshopIds)).map(async (bodyshopId) => {
await req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis?.(bodyshopId);
emitFeatureFlagsChanged({ req, bodyshopId, source: "admin", table: "bodyshop_feature_flags", name });
})
);
res.json(result);
} catch (error) {
logger.log("admin-update-feature-flag-bodyshops-error", "error", req.user.email, null, {
name,
message: error.message,
stack: error.stack,
assignmentCount: objects.length,
ioadmin: true
});
res.status(500).json(error);
}
};
const isPlainObject = (value) => value && typeof value === "object" && !Array.isArray(value);
/**
* Normalizes legacy feature flag values into persisted treatment strings.
*/
const normalizeTreatment = (value, fallback = "on") => {
if (typeof value === "string") {
return value.trim() || fallback;
}
if (value === false) return "off";
if (value === true) return "on";
return fallback;
};
/**
* Converts a legacy bodyshop feature flag map into table rows.
*/
const normalizeFeatureFlagRows = (featureFlags = {}) =>
Object.entries(featureFlags)
.filter(([name]) => Boolean(name))
.map(([name, value]) => {
if (isPlainObject(value)) {
return {
name,
treatment: value.enabled === false ? "off" : normalizeTreatment(value.treatment),
config: value.config ?? null,
activeDate: value.activeDate || null,
deactiveDate: value.deactiveDate || null
};
}
return {
name,
treatment: normalizeTreatment(value),
config: null,
activeDate: null,
deactiveDate: null
};
});
/**
* Separates feature flag assignment edits from the bodyshop update payload.
*/
const splitBodyshopUpdate = (bodyshop) => {
const featureFlags = bodyshop?.featureFlags || bodyshop?.features?.featureFlags;
const nextBodyshop = { ...bodyshop };
delete nextBodyshop.featureFlags;
if (bodyshop?.features && hasOwn(bodyshop.features, "featureFlags")) {
nextBodyshop.features = { ...bodyshop.features };
delete nextBodyshop.features.featureFlags;
}
return { bodyshop: nextBodyshop, featureFlags };
};
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
/**
* Upserts or clears all feature flag assignments for one bodyshop.
*/
async function saveBodyshopFeatureFlags({ bodyshopId, featureFlags }) {
if (!isPlainObject(featureFlags)) return null;
const rows = normalizeFeatureFlagRows(featureFlags);
const flagNames = rows.map((row) => row.name);
const objects = rows.map((row) => ({
bodyshopid: bodyshopId,
name: row.name,
treatment: row.treatment,
config: row.config,
activeDate: row.activeDate,
deactiveDate: row.deactiveDate
}));
if (objects.length === 0) {
return client.request(
`mutation DELETE_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
delete_bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }) {
affected_rows
}
}`,
{ bodyshopid: bodyshopId }
);
}
return client.request(
`mutation UPSERT_BODYSHOP_FEATURE_FLAGS(
$bodyshopid: uuid!,
$flagNames: [String!]!,
$objects: [bodyshop_feature_flags_insert_input!]!
) {
delete_bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid }, name: { _nin: $flagNames } }) {
affected_rows
}
insert_bodyshop_feature_flags(
objects: $objects,
on_conflict: {
constraint: bodyshop_feature_flags_bodyshopid_name_key,
update_columns: [treatment, config, activeDate, deactiveDate]
}
) {
affected_rows
}
}`,
{ bodyshopid: bodyshopId, flagNames, objects }
);
}
exports.updateShop = async (req, res) => {
logger.log("admin-update-shop", "debug", req.user.email, null, {
request: req.body,
ioadmin: true
});
const { id, bodyshop } = req.body;
const { id } = req.body;
const { bodyshop, featureFlags } = splitBodyshopUpdate(req.body.bodyshop);
try {
const result = await client.request(
@@ -132,6 +580,13 @@ exports.updateShop = async (req, res) => {
bodyshop
}
);
const featureFlagResult = await saveBodyshopFeatureFlags({ bodyshopId: id, featureFlags });
if (featureFlagResult) {
await req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis?.(id);
emitFeatureFlagsChanged({ req, bodyshopId: id, source: "admin", table: "bodyshop_feature_flags" });
}
res.json(result);
} catch (error) {
res.status(500).json(error);

View File

@@ -0,0 +1,44 @@
const CREATE_FIELDS = ["name", "description", "default_treatment", "active"];
const UPDATE_FIELDS = ["description", "default_treatment", "active"];
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
/**
* Trims stable feature flag identity fields while leaving free-form text unchanged.
*/
const normalizeStringField = (key, value) => {
if (value == null) return value;
if (key !== "name" && key !== "default_treatment") return value;
return typeof value === "string" ? value.trim() : value;
};
/**
* Whitelists fields accepted by feature flag definition create/update admin endpoints.
*/
const pickFeatureFlagFields = (input, allowedFields) => {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return {};
}
return allowedFields.reduce((payload, field) => {
if (hasOwn(input, field)) {
payload[field] = normalizeStringField(field, input[field]);
}
return payload;
}, {});
};
/**
* Builds a safe payload for creating a feature flag definition.
*/
const sanitizeFeatureFlagCreatePayload = (input) => pickFeatureFlagFields(input, CREATE_FIELDS);
/**
* Builds a safe payload for updating editable feature flag definition fields.
*/
const sanitizeFeatureFlagUpdatePayload = (input) => pickFeatureFlagFields(input, UPDATE_FIELDS);
module.exports = {
sanitizeFeatureFlagCreatePayload,
sanitizeFeatureFlagUpdatePayload
};

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const {
sanitizeFeatureFlagCreatePayload,
sanitizeFeatureFlagUpdatePayload
} = require("./admin-payload");
describe("feature flag admin payload sanitizing", () => {
it("keeps only editable create fields and trims stable text keys", () => {
expect(
sanitizeFeatureFlagCreatePayload({
name: " TEST_FLAG ",
description: "Manual test flag",
default_treatment: " variant-a ",
active: false,
created_at: "2026-05-19T00:00:00.000Z",
bodyshop_feature_flags_aggregate: { aggregate: { count: 3 } }
})
).toEqual({
name: "TEST_FLAG",
description: "Manual test flag",
default_treatment: "variant-a",
active: false
});
});
it("strips name from update payloads so flag keys cannot be renamed", () => {
expect(
sanitizeFeatureFlagUpdatePayload({
name: "Renamed_Flag",
description: null,
default_treatment: " on ",
active: true,
updated_at: "2026-05-19T00:00:00.000Z"
})
).toEqual({
description: null,
default_treatment: "on",
active: true
});
});
it("returns an empty update payload when no editable fields are present", () => {
expect(
sanitizeFeatureFlagUpdatePayload({
name: "Only_Name",
created_at: "2026-05-19T00:00:00.000Z"
})
).toEqual({});
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { buildImportSql, normalizeTreatment, sqlString } = require("../../scripts/export-harness-feature-flags");
describe("Harness feature flag exporter", () => {
it("preserves custom treatment names while normalizing booleans and known treatments", () => {
expect(normalizeTreatment(true)).toBe("on");
expect(normalizeTreatment(false)).toBe("off");
expect(normalizeTreatment(" ON ")).toBe("on");
expect(normalizeTreatment("false")).toBe("off");
expect(normalizeTreatment("control")).toBe("control");
expect(normalizeTreatment("variant-a")).toBe("variant-a");
expect(normalizeTreatment(" custom treatment ")).toBe("custom treatment");
expect(normalizeTreatment(null)).toBe("control");
expect(normalizeTreatment("")).toBe("control");
});
it("escapes SQL string values", () => {
expect(sqlString("Dave's Shop")).toBe("'Dave''s Shop'");
});
it("escapes custom treatments in generated import SQL", () => {
const sql = buildImportSql([
{
customerKey: "SHOP'1",
imexshopid: "SHOP'1",
name: "Demo'Flag",
treatment: "pilot's-choice",
config: { text: "Dave's config" }
}
]);
expect(sql).toContain("('SHOP''1', 'Demo''Flag', 'pilot''s-choice'");
expect(sql).toContain(`'{"text":"Dave''s config"}'::jsonb`);
});
it("includes an unmatched feature flag report query", () => {
const sql = buildImportSql([
{
customerKey: "SHOP1",
imexshopid: "SHOP1",
name: "Missing_Flag",
treatment: "on",
config: null
}
]);
expect(sql).toContain('AS "unmatched_feature_flag"');
expect(sql).toContain('LEFT JOIN "public"."feature_flags"');
expect(sql).toContain("('Missing_Flag')");
});
});

View File

@@ -0,0 +1,144 @@
const logger = require("../utils/logger");
const { CHECK_BODYSHOP_ACCESS, GET_BODYSHOP_FEATURE_FLAGS } = require("../graphql-client/queries");
const { emitFeatureFlagsChanged } = require("./socket-events");
/**
* Indicates whether verbose feature flag route logging should be enabled.
*/
const isDevelopment = () => process.env.NODE_ENV === "development";
/**
* Combines global feature flag definitions with per-bodyshop assignments into the runtime flag map.
*/
const toFlagMap = ({ feature_flags: definitions = [], bodyshop_feature_flags: assignments = [] }) => {
const flags = definitions.reduce((acc, definition) => {
acc[definition.name] = {
treatment: definition.default_treatment || "off",
config: null
};
return acc;
}, {});
for (const assignment of assignments) {
flags[assignment.name] = {
treatment: assignment.treatment,
config: assignment.config ?? null,
activeDate: assignment.activeDate ?? null,
deactiveDate: assignment.deactiveDate ?? null
};
}
return flags;
};
/**
* Verifies that the authenticated user can read the requested bodyshop through Hasura permissions.
*/
async function assertBodyshopAccess({ req, bodyshopId }) {
const result = await req.userGraphQLClient.request(CHECK_BODYSHOP_ACCESS, { id: bodyshopId });
if (!result.bodyshops_by_pk?.id) {
const error = new Error("Feature flag bodyshop access denied");
error.statusCode = 403;
throw error;
}
}
/**
* Serves runtime feature flags for one bodyshop with Redis read-through caching.
*/
async function getBodyshopFeatureFlags(req, res) {
const bodyshopId = req.params.bodyshopId;
const {
getBodyshopFeatureFlagsCacheVersion,
getBodyshopFeatureFlagsFromRedis,
setBodyshopFeatureFlagsInRedis
} = req.sessionUtils || {};
try {
await assertBodyshopAccess({ req, bodyshopId });
const cacheVersion = getBodyshopFeatureFlagsCacheVersion
? await getBodyshopFeatureFlagsCacheVersion()
: null;
const cachedFlags = getBodyshopFeatureFlagsFromRedis
? await getBodyshopFeatureFlagsFromRedis(bodyshopId, cacheVersion)
: null;
if (cachedFlags) {
if (isDevelopment()) {
logger.log("feature-flags-route-hit", "DEBUG", req.user?.email, null, {
bodyshopId,
source: "redis",
flagCount: Object.keys(cachedFlags.flags || {}).length
});
}
return res.json({ ...cachedFlags, source: "redis" });
}
const result = await req.userGraphQLClient.request(GET_BODYSHOP_FEATURE_FLAGS, { bodyshopid: bodyshopId });
const payload = {
bodyshopId,
flags: toFlagMap(result),
cachedAt: new Date().toISOString()
};
if (setBodyshopFeatureFlagsInRedis) {
await setBodyshopFeatureFlagsInRedis(bodyshopId, payload, cacheVersion);
}
if (isDevelopment()) {
logger.log("feature-flags-route-hit", "DEBUG", req.user?.email, null, {
bodyshopId,
source: "database",
flagCount: Object.keys(payload.flags || {}).length
});
}
return res.json({ ...payload, source: "database" });
} catch (error) {
const statusCode = error.statusCode || 500;
logger.log("get-bodyshop-feature-flags-error", "ERROR", req.user?.email, null, {
bodyshopId,
statusCode,
error: error.message,
stack: error.stack
});
return res.status(statusCode).json({ error: error.message });
}
}
/**
* Handles Hasura/admin cache invalidation events and notifies connected clients.
*/
async function invalidateBodyshopFeatureFlags(req, res) {
const bodyshopId = req.body?.event?.data?.new?.bodyshopid || req.body?.event?.data?.old?.bodyshopid || req.body?.bodyshopid;
const tableName = req.body?.event?.table?.name;
const flagName = req.body?.event?.data?.new?.name || req.body?.event?.data?.old?.name || req.body?.name || null;
try {
if (bodyshopId && req.sessionUtils?.invalidateBodyshopFeatureFlagsInRedis) {
await req.sessionUtils.invalidateBodyshopFeatureFlagsInRedis(bodyshopId);
emitFeatureFlagsChanged({ req, bodyshopId, source: "hasura", table: tableName, name: flagName });
return res.status(200).json({ ok: true, bodyshopId });
}
const invalidated = await req.sessionUtils?.invalidateAllBodyshopFeatureFlagsInRedis?.();
emitFeatureFlagsChanged({ req, source: "hasura", table: tableName, name: flagName });
return res.status(200).json({ ok: true, table: tableName, cacheVersion: invalidated || 0 });
} catch (error) {
logger.log("invalidate-bodyshop-feature-flags-error", "ERROR", "feature-flags", null, {
bodyshopId,
tableName,
error: error.message,
stack: error.stack
});
return res.status(500).json({ error: error.message });
}
}
module.exports = {
getBodyshopFeatureFlags,
invalidateBodyshopFeatureFlags
};

View File

@@ -0,0 +1,197 @@
import { describe, expect, it, vi } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { getBodyshopFeatureFlags, invalidateBodyshopFeatureFlags } = require("./feature-flags");
/**
* Creates the minimal Express response mock needed by feature flag route tests.
*/
const createResponse = () => {
const res = {
body: null,
code: 200,
json: vi.fn((body) => {
res.body = body;
return res;
}),
status: vi.fn((code) => {
res.code = code;
return res;
})
};
return res;
};
describe("feature flag runtime route", () => {
it("returns cached flags without re-querying runtime assignments", async () => {
const req = {
params: { bodyshopId: "shop-1" },
sessionUtils: {
getBodyshopFeatureFlagsCacheVersion: vi.fn(async () => "7"),
getBodyshopFeatureFlagsFromRedis: vi.fn(async () => ({
bodyshopId: "shop-1",
flags: {
Demo: { treatment: "on", config: null }
}
}))
},
user: { email: "tester@example.com" },
userGraphQLClient: {
request: vi.fn(async () => ({ bodyshops_by_pk: { id: "shop-1" } }))
}
};
const res = createResponse();
await getBodyshopFeatureFlags(req, res);
expect(req.sessionUtils.getBodyshopFeatureFlagsFromRedis).toHaveBeenCalledWith("shop-1", "7");
expect(req.userGraphQLClient.request).toHaveBeenCalledTimes(1);
expect(res.body).toEqual({
bodyshopId: "shop-1",
flags: {
Demo: { treatment: "on", config: null }
},
source: "redis"
});
});
it("merges active definitions with bodyshop assignments on cache miss", async () => {
const req = {
params: { bodyshopId: "shop-1" },
sessionUtils: {
getBodyshopFeatureFlagsCacheVersion: vi.fn(async () => "3"),
getBodyshopFeatureFlagsFromRedis: vi.fn(async () => null),
setBodyshopFeatureFlagsInRedis: vi.fn()
},
user: { email: "tester@example.com" },
userGraphQLClient: {
request: vi
.fn()
.mockResolvedValueOnce({ bodyshops_by_pk: { id: "shop-1" } })
.mockResolvedValueOnce({
feature_flags: [
{ name: "Default_Off", default_treatment: "off" },
{ name: "Default_Custom", default_treatment: "variant-a" }
],
bodyshop_feature_flags: [
{
name: "Default_Off",
treatment: "on",
config: { limit: 10 },
activeDate: "2026-05-19T15:00:00.000Z",
deactiveDate: null
}
]
})
}
};
const res = createResponse();
await getBodyshopFeatureFlags(req, res);
expect(req.sessionUtils.setBodyshopFeatureFlagsInRedis).toHaveBeenCalledWith(
"shop-1",
expect.objectContaining({
bodyshopId: "shop-1",
flags: {
Default_Off: {
treatment: "on",
config: { limit: 10 },
activeDate: "2026-05-19T15:00:00.000Z",
deactiveDate: null
},
Default_Custom: {
treatment: "variant-a",
config: null
}
}
}),
"3"
);
expect(res.body).toEqual(
expect.objectContaining({
bodyshopId: "shop-1",
flags: {
Default_Off: {
treatment: "on",
config: { limit: 10 },
activeDate: "2026-05-19T15:00:00.000Z",
deactiveDate: null
},
Default_Custom: {
treatment: "variant-a",
config: null
}
},
source: "database"
})
);
});
});
describe("feature flag cache invalidation route", () => {
it("invalidates one bodyshop when a bodyshop assignment event is received", async () => {
const emit = vi.fn();
const req = {
body: {
event: {
table: { name: "bodyshop_feature_flags" },
data: {
new: { bodyshopid: "shop-1" }
}
}
},
ioHelpers: {
getBodyshopRoom: vi.fn(() => "bodyshop-room-shop-1")
},
ioRedis: {
to: vi.fn(() => ({ emit }))
},
sessionUtils: {
invalidateBodyshopFeatureFlagsInRedis: vi.fn()
}
};
const res = createResponse();
await invalidateBodyshopFeatureFlags(req, res);
expect(req.sessionUtils.invalidateBodyshopFeatureFlagsInRedis).toHaveBeenCalledWith("shop-1");
expect(req.ioRedis.to).toHaveBeenCalledWith("bodyshop-room-shop-1");
expect(emit).toHaveBeenCalledWith(
"feature-flags-changed",
expect.objectContaining({ bodyshopId: "shop-1", scope: "bodyshop", source: "hasura", table: "bodyshop_feature_flags" })
);
expect(res.body).toEqual({ ok: true, bodyshopId: "shop-1" });
});
it("bumps global cache version when a feature definition event is received", async () => {
const req = {
body: {
event: {
table: { name: "feature_flags" },
data: {
new: { name: "Demo" }
}
}
},
ioRedis: {
emit: vi.fn()
},
sessionUtils: {
invalidateAllBodyshopFeatureFlagsInRedis: vi.fn(async () => 12)
}
};
const res = createResponse();
await invalidateBodyshopFeatureFlags(req, res);
expect(req.sessionUtils.invalidateAllBodyshopFeatureFlagsInRedis).toHaveBeenCalled();
expect(req.ioRedis.emit).toHaveBeenCalledWith(
"feature-flags-changed",
expect.objectContaining({ name: "Demo", scope: "global", source: "hasura", table: "feature_flags" })
);
expect(res.body).toEqual({ ok: true, table: "feature_flags", cacheVersion: 12 });
});
});

View File

@@ -0,0 +1,40 @@
const FEATURE_FLAGS_CHANGED_EVENT = "feature-flags-changed";
/**
* Creates the Socket.IO payload used to tell browsers that feature flags changed.
*/
const createFeatureFlagsChangedPayload = ({ bodyshopId = null, source = "unknown", table = null, name = null } = {}) => ({
bodyshopId,
changedAt: new Date().toISOString(),
name,
scope: bodyshopId ? "bodyshop" : "global",
source,
table
});
/**
* Emits a feature-flag change event globally or to one bodyshop room.
*/
const emitFeatureFlagsChanged = ({ req, bodyshopId = null, source = "unknown", table = null, name = null } = {}) => {
const io = req?.ioRedis;
if (!io) return null;
const payload = createFeatureFlagsChangedPayload({ bodyshopId, source, table, name });
if (bodyshopId) {
const room = req?.ioHelpers?.getBodyshopRoom
? req.ioHelpers.getBodyshopRoom(bodyshopId)
: `bodyshop-broadcast-room:${bodyshopId}`;
io.to(room).emit(FEATURE_FLAGS_CHANGED_EVENT, payload);
return payload;
}
io.emit(FEATURE_FLAGS_CHANGED_EVENT, payload);
return payload;
};
module.exports = {
FEATURE_FLAGS_CHANGED_EVENT,
createFeatureFlagsChangedPayload,
emitFeatureFlagsChanged
};

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const {
FEATURE_FLAGS_CHANGED_EVENT,
createFeatureFlagsChangedPayload,
emitFeatureFlagsChanged
} = require("./socket-events");
describe("feature flag socket events", () => {
it("creates a global payload when no bodyshop id is provided", () => {
expect(createFeatureFlagsChangedPayload({ source: "admin", table: "feature_flags", name: "Demo" })).toEqual(
expect.objectContaining({
bodyshopId: null,
name: "Demo",
scope: "global",
source: "admin",
table: "feature_flags"
})
);
});
it("emits bodyshop-scoped changes to the bodyshop room", () => {
const emit = vi.fn();
const req = {
ioHelpers: {
getBodyshopRoom: vi.fn(() => "bodyshop-room-shop-1")
},
ioRedis: {
to: vi.fn(() => ({ emit }))
}
};
const payload = emitFeatureFlagsChanged({
req,
bodyshopId: "shop-1",
source: "hasura",
table: "bodyshop_feature_flags",
name: "Demo"
});
expect(req.ioRedis.to).toHaveBeenCalledWith("bodyshop-room-shop-1");
expect(emit).toHaveBeenCalledWith(FEATURE_FLAGS_CHANGED_EVENT, payload);
expect(payload).toEqual(expect.objectContaining({ bodyshopId: "shop-1", scope: "bodyshop" }));
});
it("broadcasts global changes to all sockets", () => {
const req = {
ioRedis: {
emit: vi.fn()
}
};
const payload = emitFeatureFlagsChanged({ req, source: "admin", table: "feature_flags" });
expect(req.ioRedis.emit).toHaveBeenCalledWith(FEATURE_FLAGS_CHANGED_EVENT, payload);
expect(payload).toEqual(expect.objectContaining({ bodyshopId: null, scope: "global" }));
});
});

View File

@@ -2968,6 +2968,30 @@ exports.GET_BODYSHOP_BY_ID = `
}
`;
exports.CHECK_BODYSHOP_ACCESS = `
query CHECK_BODYSHOP_ACCESS($id: uuid!) {
bodyshops_by_pk(id: $id) {
id
}
}
`;
exports.GET_BODYSHOP_FEATURE_FLAGS = `
query GET_BODYSHOP_FEATURE_FLAGS($bodyshopid: uuid!) {
feature_flags(where: { active: { _eq: true } }, order_by: { name: asc }) {
name
default_treatment
}
bodyshop_feature_flags(where: { bodyshopid: { _eq: $bodyshopid } }, order_by: { name: asc }) {
name
treatment
config
activeDate
deactiveDate
}
}
`;
exports.GET_BODYSHOP_WATCHERS_BY_ID = `
query GET_BODYSHOP_BY_ID($id: uuid!) {
bodyshops_by_pk(id: $id) {
@@ -3356,4 +3380,4 @@ exports.GET_DOCUMENSO_KEY_BY_JOBID = `query GET_DOCUMENSO_KEY_BY_JOBID($jobid: u
}
}
}
`
`

View File

@@ -1,7 +1,19 @@
const express = require("express");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops");
const {
createAssociation,
createShop,
updateShop,
updateCounter,
getFeatureFlags,
getBodyshopFeatureFlags,
createFeatureFlag,
updateFeatureFlag,
deleteFeatureFlag,
getFeatureFlagBodyshops,
updateFeatureFlagBodyshops
} = require("../admin/adminops");
const { updateUser, getUser, createUser, getWelcomeEmail, getResetLink } = require("../firebase/firebase-handler");
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
@@ -12,6 +24,13 @@ router.post("/createassociation", createAssociation);
router.post("/createshop", createShop);
router.post("/updateshop", updateShop);
router.post("/updatecounter", updateCounter);
router.get("/feature-flags", getFeatureFlags);
router.post("/feature-flags", createFeatureFlag);
router.get("/feature-flags/:name/bodyshops", getFeatureFlagBodyshops);
router.put("/feature-flags/:name/bodyshops", updateFeatureFlagBodyshops);
router.put("/feature-flags/:name", updateFeatureFlag);
router.delete("/feature-flags/:name", deleteFeatureFlag);
router.get("/bodyshops/:bodyshopId/feature-flags", getBodyshopFeatureFlags);
router.post("/updateuser", updateUser);
router.post("/getuser", getUser);
router.post("/createuser", createUser);

View File

@@ -0,0 +1,27 @@
const express = require("express");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware");
const {
getBodyshopFeatureFlags,
invalidateBodyshopFeatureFlags
} = require("../feature-flags/feature-flags");
const router = express.Router();
/**
* Returns runtime feature flags for a bodyshop the authenticated user can access.
*/
router.get(
"/bodyshops/:bodyshopId",
validateFirebaseIdTokenMiddleware,
withUserGraphQLClientMiddleware,
getBodyshopFeatureFlags
);
/**
* Receives Hasura event-trigger callbacks that invalidate feature flag runtime caches.
*/
router.post("/cache/invalidate", eventAuthorizationMiddleware, invalidateBodyshopFeatureFlags);
module.exports = router;

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { applyRedisHelpers } = require("./redisHelpers");
/**
* Creates an in-memory Redis-like test double for feature flag cache helper tests.
*/
const createRedis = () => {
const values = new Map();
const expirations = new Map();
return {
del: vi.fn(async (...keys) => {
let deleted = 0;
for (const key of keys) {
if (values.delete(key)) deleted += 1;
}
return deleted;
}),
expire: vi.fn(async (key, ttl) => {
expirations.set(key, ttl);
return 1;
}),
get: vi.fn(async (key) => values.get(key) ?? null),
incr: vi.fn(async (key) => {
const nextValue = Number(values.get(key) || 0) + 1;
values.set(key, String(nextValue));
return nextValue;
}),
set: vi.fn(async (key, value) => {
values.set(key, String(value));
return "OK";
}),
setnx: vi.fn(async (key, value) => {
if (values.has(key)) return 0;
values.set(key, String(value));
return 1;
}),
values,
expirations
};
};
/**
* Applies Redis helpers to the in-memory test double and returns the mounted API.
*/
const createHelpers = () => {
const pubClient = createRedis();
const app = { use: vi.fn() };
const logger = { log: vi.fn() };
const helpers = applyRedisHelpers({ pubClient, app, logger });
return { app, helpers, logger, pubClient };
};
describe("feature flag Redis cache helpers", () => {
it("stores and reads bodyshop feature flags under the current cache version", async () => {
const { helpers, pubClient } = createHelpers();
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
expect(await helpers.getBodyshopFeatureFlagsCacheVersion()).toBe("1");
expect(await helpers.getBodyshopFeatureFlagsFromRedis("shop-1")).toEqual({
flags: {
Demo: {
treatment: "on"
}
}
});
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
expect(pubClient.expirations.get("bodyshop-feature-flags:v1:shop-1")).toBe(3600);
});
it("global invalidation bumps the cache version instead of deleting old versioned keys", async () => {
const { helpers, pubClient } = createHelpers();
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
const nextVersion = await helpers.invalidateAllBodyshopFeatureFlagsInRedis();
expect(nextVersion).toBe(2);
expect(pubClient.del).not.toHaveBeenCalledWith("bodyshop-feature-flags:v1:shop-1");
expect(await helpers.getBodyshopFeatureFlagsFromRedis("shop-1")).toBeNull();
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
});
it("bodyshop invalidation deletes only the current version cache key for that shop", async () => {
const { helpers, pubClient } = createHelpers();
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "on" } } });
await helpers.setBodyshopFeatureFlagsInRedis("shop-2", { flags: { Demo: { treatment: "on" } } });
await helpers.invalidateAllBodyshopFeatureFlagsInRedis();
await helpers.setBodyshopFeatureFlagsInRedis("shop-1", { flags: { Demo: { treatment: "off" } } });
await helpers.setBodyshopFeatureFlagsInRedis("shop-2", { flags: { Demo: { treatment: "on" } } });
await helpers.invalidateBodyshopFeatureFlagsInRedis("shop-1");
expect(pubClient.values.has("bodyshop-feature-flags:v1:shop-1")).toBe(true);
expect(pubClient.values.has("bodyshop-feature-flags:v2:shop-1")).toBe(false);
expect(pubClient.values.has("bodyshop-feature-flags:v2:shop-2")).toBe(true);
});
});

View File

@@ -7,6 +7,8 @@ const client = require("../graphql-client/graphql-client").client;
* @type {number}
*/
const BODYSHOP_CACHE_TTL = 3600; // 1 hour
const FEATURE_FLAGS_CACHE_TTL = 3600; // 1 hour
const FEATURE_FLAGS_CACHE_VERSION_KEY = "bodyshop-feature-flags:version";
/**
* Chatter API token cache TTL in seconds
@@ -20,6 +22,7 @@ const CHATTER_TOKEN_CACHE_TTL = 3600; // 1 hour
* @returns {`bodyshop-cache:${string}`}
*/
const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`;
const getBodyshopFeatureFlagsCacheKey = (bodyshopId, version = "1") => `bodyshop-feature-flags:v${version}:${bodyshopId}`;
/**
* Generate a cache key for a Chatter API token
@@ -418,6 +421,92 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
}
};
/**
* Reads or initializes the global feature flag cache version used by per-shop keys.
*/
const getBodyshopFeatureFlagsCacheVersion = async () => {
try {
const version = await pubClient.get(FEATURE_FLAGS_CACHE_VERSION_KEY);
if (version) return version;
await pubClient.setnx(FEATURE_FLAGS_CACHE_VERSION_KEY, "1");
return "1";
} catch (error) {
logger.log("get-bodyshop-feature-flags-cache-version", "ERROR", "redis", null, {
error: error.message
});
return "1";
}
};
/**
* Reads a bodyshop feature flag payload from the current or supplied cache version.
*/
const getBodyshopFeatureFlagsFromRedis = async (bodyshopId, version) => {
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
try {
const cachedData = await pubClient.get(key);
return cachedData ? JSON.parse(cachedData) : null;
} catch (error) {
logger.log("get-bodyshop-feature-flags-from-redis", "ERROR", "redis", null, {
bodyshopId,
error: error.message
});
return null;
}
};
/**
* Stores a bodyshop feature flag payload under a versioned Redis key.
*/
const setBodyshopFeatureFlagsInRedis = async (bodyshopId, value, version) => {
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
try {
await pubClient.set(key, toRedisJson(value));
await pubClient.expire(key, FEATURE_FLAGS_CACHE_TTL);
} catch (error) {
logger.log("set-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
bodyshopId,
error: error.message
});
}
};
/**
* Deletes one bodyshop's feature flag cache entry for the current or supplied version.
*/
const invalidateBodyshopFeatureFlagsInRedis = async (bodyshopId, version) => {
const cacheVersion = version || (await getBodyshopFeatureFlagsCacheVersion());
const key = getBodyshopFeatureFlagsCacheKey(bodyshopId, cacheVersion);
try {
await pubClient.del(key);
} catch (error) {
logger.log("invalidate-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
bodyshopId,
error: error.message
});
}
};
/**
* Invalidates all bodyshop feature flag caches by incrementing the global version.
*/
const invalidateAllBodyshopFeatureFlagsInRedis = async () => {
try {
return await pubClient.incr(FEATURE_FLAGS_CACHE_VERSION_KEY);
} catch (error) {
logger.log("invalidate-all-bodyshop-feature-flags-in-redis", "ERROR", "redis", null, {
error: error.message
});
return 0;
}
};
/**
* Set provider cache data
* @param ns
@@ -482,6 +571,7 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
const api = {
getUserSocketMappingKey,
getBodyshopCacheKey,
getBodyshopFeatureFlagsCacheKey,
getChatterTokenCacheKey,
setSessionData,
getSessionData,
@@ -493,6 +583,11 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
refreshUserSocketTTL,
getBodyshopFromRedis,
updateOrInvalidateBodyshopFromRedis,
getBodyshopFeatureFlagsCacheVersion,
getBodyshopFeatureFlagsFromRedis,
setBodyshopFeatureFlagsInRedis,
invalidateBodyshopFeatureFlagsInRedis,
invalidateAllBodyshopFeatureFlagsInRedis,
setSessionTransactionData,
getSessionTransactionData,
clearSessionTransactionData,