Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion src/controllers/modelConfig.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,47 @@ async function deleteUserModelConfiguration(req, res, next) {
return next();
}

export { saveUserModelConfiguration, deleteUserModelConfiguration };
async function bulkUpdateUserModelConfigurations(req, res, next) {
const { models, filter, change } = req.body;
const org_id = req.profile.org.id;

const result = await modelConfigDbService.bulkUpdateModelConfigs({ models, filter, change, org_id });

if (result?.error === "invalidChange") {
return res.status(400).json({
success: false,
message: "Invalid change payload. Use either Mongo update operators or a plain object patch."
});
}

if (result?.error === "keyError") {
return res.status(400).json({
success: false,
message: `Invalid or restricted key '${result.key}' in change payload.`
});
}

if (result?.error === "invalidFilter") {
return res.status(400).json({
success: false,
message: result.key ? `Invalid or restricted key '${result.key}' in filter payload.` : "Invalid filter payload."
});
}

if (result?.error === "documentNotFound") {
return res.status(404).json({
success: false,
message: "No model configurations found for the provided models/filter."
});
}

res.locals = {
success: true,
message: "Bulk model configuration update completed",
result
};
req.statusCode = 200;
return next();
}

export { saveUserModelConfiguration, deleteUserModelConfiguration, bulkUpdateUserModelConfigurations };
50 changes: 49 additions & 1 deletion src/db_services/modelConfig.service.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ModelsConfigModel from "../mongoModel/ModelConfig.model.js";
import { flatten } from "flat";
import { normalizeBulkModelConfigChange, normalizeBulkModelConfigFilter } from "../utils/modelConfigUpdate.utils.js";

async function checkModel(model_name, service) {
//function to check if a model configuration exists
Expand Down Expand Up @@ -108,6 +109,52 @@ async function updateModelConfigs(model_name, service, updates) {
return result.modifiedCount > 0;
}

async function bulkUpdateModelConfigs({ models, filter, change, org_id }) {
const uniqueModels = models ? [...new Map(models.map((model) => [model.model_name, model])).values()] : [];

if (!filter && uniqueModels.length === 0) {
return { error: "documentNotFound" };
}

const normalizedChange = normalizeBulkModelConfigChange(change);
if (normalizedChange.error) {
return normalizedChange;
}

const normalizedFilter = normalizeBulkModelConfigFilter(filter);
if (normalizedFilter.error) {
return normalizedFilter;
}

const query = { ...normalizedFilter.filterQuery };

if (!query.model_name && uniqueModels.length > 0) {
query.model_name = { $in: uniqueModels.map((model) => model.model_name) };
}

if (org_id) {
query.org_id = org_id;
}

const existingModels = await ModelsConfigModel.find(query, { _id: 0, service: 1, model_name: 1 }).lean();

if (existingModels.length === 0) {
return { error: "documentNotFound" };
}

const result = await ModelsConfigModel.updateMany(query, normalizedChange.updateDocument, { strict: false });
const foundModelNames = new Set(existingModels.map((model) => model.model_name));
const notFoundModels = uniqueModels.filter((model) => !foundModelNames.has(model.model_name));

return {
requestedCount: uniqueModels.length,
matchedCount: result.matchedCount,
modifiedCount: result.modifiedCount,
updatedModels: existingModels,
notFoundModels
};
}

export default {
getAllModelConfigs,
saveModelConfig,
Expand All @@ -117,5 +164,6 @@ export default {
checkModelConfigExists,
getModelConfigsByNameAndService,
checkModel,
updateModelConfigs
updateModelConfigs,
bulkUpdateModelConfigs
};
10 changes: 8 additions & 2 deletions src/routes/modelConfig.routes.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import express from "express";
import { middleware } from "../middlewares/middleware.js";
import { saveUserModelConfiguration, deleteUserModelConfiguration } from "../controllers/modelConfig.controller.js";
import {
saveUserModelConfiguration,
deleteUserModelConfiguration,
bulkUpdateUserModelConfigurations
} from "../controllers/modelConfig.controller.js";
import validate from "../middlewares/validate.middleware.js";
import {
saveUserModelConfigurationBodySchema,
deleteUserModelConfigurationQuerySchema
deleteUserModelConfigurationQuerySchema,
bulkUpdateUserModelConfigurationBodySchema
} from "../validation/joi_validation/modelConfig.validation.js";

const router = express.Router();

router.post("/", middleware, validate({ body: saveUserModelConfigurationBodySchema }), saveUserModelConfiguration);
router.post("/bulk-update", middleware, validate({ body: bulkUpdateUserModelConfigurationBodySchema }), bulkUpdateUserModelConfigurations);
router.delete("/", middleware, validate({ query: deleteUserModelConfigurationQuerySchema }), deleteUserModelConfiguration);

export default router;
131 changes: 131 additions & 0 deletions src/utils/modelConfigUpdate.utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { flatten } from "flat";

const ALLOWED_MODEL_UPDATE_OPERATORS = new Set(["$set", "$unset", "$inc", "$push", "$pull", "$addToSet", "$rename"]);
const BLOCKED_MODEL_CONFIG_PATHS = ["_id", "__v", "model_name", "service", "org_id"];
const ALLOWED_MODEL_CONFIG_ROOTS = ["configuration", "validationConfig", "outputConfig", "status", "display_name"];
const BLOCKED_MODEL_FILTER_PATHS = ["_id", "__v", "org_id"];

function isPlainObject(value) {
return Object.prototype.toString.call(value) === "[object Object]";
}

function isBlockedPath(path) {
if (path === "configuration.model" || path.startsWith("configuration.model.")) {
return true;
}

return BLOCKED_MODEL_CONFIG_PATHS.some((blockedPath) => path === blockedPath || path.startsWith(`${blockedPath}.`));
}

function isAllowedPath(path) {
return ALLOWED_MODEL_CONFIG_ROOTS.some((root) => path === root || path.startsWith(`${root}.`));
}

function isBlockedFilterPath(path) {
return BLOCKED_MODEL_FILTER_PATHS.some((blockedPath) => path === blockedPath || path.startsWith(`${blockedPath}.`));
}

function hasBlockedOperatorPath(path) {
return path.startsWith("$") || path.includes(".$");
}

function normalizeBulkModelConfigChange(change) {
if (!isPlainObject(change) || Object.keys(change).length === 0) {
return { error: "invalidChange" };
}

const keys = Object.keys(change);
const hasOperator = keys.some((key) => key.startsWith("$"));

if (hasOperator) {
const mixedPayload = keys.some((key) => !key.startsWith("$"));
if (mixedPayload) {
return { error: "invalidChange" };
}

const normalizedUpdate = {};
let errorKey = "";

for (const [operator, payload] of Object.entries(change)) {
if (!ALLOWED_MODEL_UPDATE_OPERATORS.has(operator) || !isPlainObject(payload)) {
return { error: "invalidChange" };
}

const validOperatorPayload = {};

for (const [key, value] of Object.entries(payload)) {
if (isBlockedPath(key) || !isAllowedPath(key)) {
errorKey = key;
continue;
}

if (operator === "$rename") {
if (typeof value !== "string" || isBlockedPath(value) || !isAllowedPath(value)) {
errorKey = typeof value === "string" ? value : key;
continue;
}
}

validOperatorPayload[key] = value;
}

if (Object.keys(validOperatorPayload).length > 0) {
normalizedUpdate[operator] = validOperatorPayload;
}
}

if (Object.keys(normalizedUpdate).length === 0) {
return { error: "keyError", key: errorKey };
}

return { updateDocument: normalizedUpdate };
}

const flattenedUpdates = flatten(change, { safe: true });
const allowedUpdates = {};
let errorKey = "";

for (const [key, value] of Object.entries(flattenedUpdates)) {
if (isBlockedPath(key) || !isAllowedPath(key)) {
errorKey = key;
continue;
}
allowedUpdates[key] = value;
}

if (Object.keys(allowedUpdates).length === 0) {
return { error: "keyError", key: errorKey };
}

return { updateDocument: { $set: allowedUpdates } };
}

function normalizeBulkModelConfigFilter(filter) {
if (!filter) {
return { filterQuery: {} };
}

if (!isPlainObject(filter) || Object.keys(filter).length === 0) {
return { error: "invalidFilter" };
}

const flattenedFilter = flatten(filter, { safe: true });
const normalizedFilter = {};
let errorKey = "";

for (const [key, value] of Object.entries(flattenedFilter)) {
if (hasBlockedOperatorPath(key) || isBlockedFilterPath(key)) {
errorKey = key;
continue;
}
normalizedFilter[key] = value;
}

if (Object.keys(normalizedFilter).length === 0) {
return { error: "invalidFilter", key: errorKey };
}

return { filterQuery: normalizedFilter };
}

export { normalizeBulkModelConfigChange, normalizeBulkModelConfigFilter };
28 changes: 27 additions & 1 deletion src/validation/joi_validation/modelConfig.validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ const deleteUserModelConfigurationQuerySchema = Joi.object({
})
}).unknown(true);

const bulkUpdateModelFilterSchema = Joi.object().min(1).unknown(true);

const bulkUpdateUserModelConfigurationBodySchema = Joi.object({
models: Joi.array()
.items(
Joi.object({
model_name: Joi.string()
.pattern(/^[^\s]+$/)
.message("model_name must not contain spaces")
.required()
}).required()
)
.min(1)
.optional(),
filter: bulkUpdateModelFilterSchema.optional(),
change: Joi.object().min(1).required()
})
.or("models", "filter")
.unknown(true);

// Legacy schema for backward compatibility
const UserModelConfigSchema = Joi.object({
org_id: Joi.string().required(),
Expand All @@ -49,4 +69,10 @@ const UserModelConfigSchema = Joi.object({
validationConfig: Joi.object().unknown(true).required()
}).unknown(true);

export { modelConfigSchema, UserModelConfigSchema, saveUserModelConfigurationBodySchema, deleteUserModelConfigurationQuerySchema };
export {
modelConfigSchema,
UserModelConfigSchema,
saveUserModelConfigurationBodySchema,
deleteUserModelConfigurationQuerySchema,
bulkUpdateUserModelConfigurationBodySchema
};