595 lines
17 KiB
JavaScript
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);
|
|
}
|
|
};
|