Files
bodyshop/server/admin/adminops.js

595 lines
17 KiB
JavaScript

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, {
request: req.body,
ioadmin: true
});
const { shopid, authlevel, useremail } = req.body;
const result = await client.request(
`mutation INSERT_ASSOCIATION($assoc: associations_insert_input!){
insert_associations_one(object:$assoc){
id
authlevel
useremail
active
}
}`,
{
assoc: { shopid, authlevel, useremail, active: false }
}
);
res.json(result);
};
exports.createShop = async (req, res) => {
logger.log("admin-create-shop", "debug", req.user.email, null, {
request: req.body,
ioadmin: true
});
const { bodyshop, ronum } = req.body;
try {
const result = await client.request(
`mutation INSERT_BODYSHOPS($bs: bodyshops_insert_input!) {
insert_bodyshops_one(object: $bs) {
id
vendors {
id
}
}
}`,
{
bs: {
...bodyshop,
counters: {
data: [
{ countertype: "ronum", count: ronum },
{ countertype: "ihbnum", count: 1 },
{ countertype: "paymentnum", count: 1 }
]
},
vendors: {
data: [{ name: "In-House" }]
}
}
}
);
const bodyshopId = result.insert_bodyshops_one.id;
const vendorId = result.insert_bodyshops_one.vendors[0].id;
if (!bodyshopId || !vendorId) {
throw new Error("Failed to create bodyshop or vendor");
}
const updateBodyshop = await client.request(
`mutation UPDATE_BODYSHOP($id: uuid!, $inhousevendorid: uuid!) {
update_bodyshops_by_pk(pk_columns: { id: $id }, _set: { inhousevendorid: $inhousevendorid }) {
id
}
}`,
{
id: bodyshopId,
inhousevendorid: vendorId
}
);
res.status(200).json(updateBodyshop);
} catch (error) {
logger.log("admin-create-shop-error", "error", req.user.email, null, {
message: error.message,
stack: error.stack,
request: req.body,
ioadmin: true
});
res.status(500).json(error);
}
};
exports.updateCounter = async (req, res) => {
logger.log("admin-update-counter", "debug", req.user.email, null, {
request: req.body,
ioadmin: true
});
const { id, counter } = req.body;
try {
const result = await client.request(
`mutation UPDATE_COUNTER($id: uuid!, $counter: counters_set_input!) {
update_counters_by_pk(pk_columns: { id: $id }, _set: $counter) {
id
countertype
count
prefix
}
}`,
{
id,
counter
}
);
res.json(result);
} catch (error) {
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 } = req.body;
const { bodyshop, featureFlags } = splitBodyshopUpdate(req.body.bodyshop);
try {
const result = await client.request(
`mutation UPDATE_BODYSHOP($id: uuid!, $bodyshop: bodyshops_set_input!) {
update_bodyshops_by_pk(pk_columns: { id: $id }, _set: $bodyshop) {
id
}
}`,
{
id,
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);
}
};