Files
bodyshop/server/feature-flags/feature-flags.js

145 lines
4.8 KiB
JavaScript

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