728 lines
23 KiB
JavaScript
728 lines
23 KiB
JavaScript
import { ObjectId } from 'mongodb';
|
|
import { auditLogModel } from './database/schemas/management/auditlog.schema.js';
|
|
import exifr from 'exifr';
|
|
import { natsServer } from './database/nats.js';
|
|
import log4js from 'log4js';
|
|
import config from './config.js';
|
|
import crypto from 'crypto';
|
|
import canonicalize from 'canonical-json';
|
|
|
|
const logger = log4js.getLogger('Utils');
|
|
logger.level = config.server.logLevel;
|
|
|
|
function buildWildcardRegexPattern(input) {
|
|
// Escape all regex special chars except * (which we treat as a wildcard)
|
|
const escaped = input.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
// Convert * to "match anything"
|
|
const withWildcards = escaped.replace(/\*/g, '.*');
|
|
// Anchor so that, without *, this is an exact match
|
|
return `^${withWildcards}$`;
|
|
}
|
|
|
|
function parseFilter(property, value) {
|
|
if (typeof value === 'string') {
|
|
var trimmed = value.trim();
|
|
if (trimmed.charAt(3) == ':') {
|
|
trimmed = value.split(':')[1];
|
|
}
|
|
|
|
// Handle booleans
|
|
if (trimmed.toLowerCase() === 'true') return { [property]: true };
|
|
if (trimmed.toLowerCase() === 'false') return { [property]: false };
|
|
|
|
// Handle ObjectId (24-char hex)
|
|
|
|
if (/^[a-f\d]{24}$/i.test(trimmed) && trimmed.length >= 24) {
|
|
return { [property]: new ObjectId(trimmed) };
|
|
}
|
|
|
|
// Handle numbers
|
|
if (!isNaN(trimmed)) {
|
|
return { [property]: parseFloat(trimmed) };
|
|
}
|
|
|
|
// Default to case-insensitive regex for non-numeric strings.
|
|
// Supports * as a wildcard (e.g. "filament*" matches "filament stock").
|
|
const pattern = buildWildcardRegexPattern(trimmed);
|
|
return {
|
|
[property]: {
|
|
$regex: pattern,
|
|
$options: 'i',
|
|
},
|
|
};
|
|
}
|
|
|
|
// Handle actual booleans, numbers, objects, etc.
|
|
return { [property]: value };
|
|
}
|
|
|
|
function convertToCamelCase(obj) {
|
|
const result = {};
|
|
|
|
for (const key in obj) {
|
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
const value = obj[key];
|
|
|
|
// Convert the key to camelCase
|
|
let camelKey = key
|
|
// First handle special cases with spaces, brackets and other characters
|
|
.replace(/\s*\[.*?\]\s*/g, '') // Remove brackets and their contents
|
|
.replace(/\s+/g, ' ') // Normalize spaces
|
|
.trim()
|
|
// Split by common separators (space, underscore, hyphen)
|
|
.split(/[\s_-]/)
|
|
// Convert to camelCase
|
|
.map((word, index) => {
|
|
// Remove any non-alphanumeric characters
|
|
word = word.replace(/[^a-zA-Z0-9]/g, '');
|
|
|
|
// Lowercase first word, uppercase others
|
|
return index === 0
|
|
? word.toLowerCase()
|
|
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
})
|
|
.join('');
|
|
|
|
// Handle values that are objects recursively
|
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
result[camelKey] = convertToCamelCase(value);
|
|
} else {
|
|
result[camelKey] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function extractGCodeConfigBlock(fileContent, useCamelCase = true) {
|
|
const configObject = {};
|
|
|
|
// Extract header information
|
|
const headerBlockRegex = /; HEADER_BLOCK_START([\s\S]*?)(?:; HEADER_BLOCK_END|$)/;
|
|
const headerBlockMatch = fileContent.match(headerBlockRegex);
|
|
if (headerBlockMatch && headerBlockMatch[1]) {
|
|
const headerLines = headerBlockMatch[1].split('\n');
|
|
headerLines.forEach((line) => {
|
|
const keyValueRegex = /^\s*;\s*([^:]+?):\s*(.*?)\s*$/;
|
|
const simpleValueRegex = /^\s*;\s*(.*?)\s*$/;
|
|
|
|
// Try key-value format first
|
|
let match = line.match(keyValueRegex);
|
|
if (match) {
|
|
const key = match[1].trim();
|
|
let value = match[2].trim();
|
|
|
|
// Try to convert value to appropriate type
|
|
if (!isNaN(value) && value !== '') {
|
|
value = Number(value);
|
|
}
|
|
configObject[key] = value;
|
|
} else {
|
|
// Try the simple format like "; generated by OrcaSlicer 2.1.1 on 2025-04-28 at 13:30:11"
|
|
match = line.match(simpleValueRegex);
|
|
if (match && match[1] && !match[1].includes('HEADER_BLOCK')) {
|
|
const text = match[1].trim();
|
|
|
|
// Extract slicer info
|
|
const slicerMatch = text.match(/generated by (.*?) on (.*?) at (.*?)$/);
|
|
if (slicerMatch) {
|
|
configObject['slicer'] = slicerMatch[1].trim();
|
|
configObject['date'] = slicerMatch[2].trim();
|
|
configObject['time'] = slicerMatch[3].trim();
|
|
} else {
|
|
// Just add as a general header entry if it doesn't match any specific pattern
|
|
const key = `header_${Object.keys(configObject).length}`;
|
|
configObject[key] = text;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Extract thumbnail data
|
|
const thumbnailBlockRegex = /; THUMBNAIL_BLOCK_START([\s\S]*?)(?:; THUMBNAIL_BLOCK_END|$)/;
|
|
const thumbnailBlockMatch = fileContent.match(thumbnailBlockRegex);
|
|
if (thumbnailBlockMatch && thumbnailBlockMatch[1]) {
|
|
const thumbnailLines = thumbnailBlockMatch[1].split('\n');
|
|
let base64Data = '';
|
|
let thumbnailInfo = {};
|
|
|
|
thumbnailLines.forEach((line) => {
|
|
// Extract thumbnail dimensions and size from the line "thumbnail begin 640x640 27540"
|
|
const thumbnailHeaderRegex = /^\s*;\s*thumbnail begin (\d+)x(\d+) (\d+)/;
|
|
const match = line.match(thumbnailHeaderRegex);
|
|
|
|
if (match) {
|
|
thumbnailInfo.width = parseInt(match[1], 10);
|
|
thumbnailInfo.height = parseInt(match[2], 10);
|
|
thumbnailInfo.size = parseInt(match[3], 10);
|
|
} else if (line.trim().startsWith('; ') && !line.includes('THUMBNAIL_BLOCK')) {
|
|
// Collect base64 data (remove the leading semicolon and space and thumbnail end)
|
|
const dataLine = line.trim().substring(2);
|
|
if (dataLine && dataLine != 'thumbnail end') {
|
|
base64Data += dataLine;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add thumbnail data to config object
|
|
if (base64Data) {
|
|
configObject.thumbnail = {
|
|
data: base64Data,
|
|
...thumbnailInfo,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Extract CONFIG_BLOCK
|
|
const configBlockRegex = /; CONFIG_BLOCK_START([\s\S]*?)(?:; CONFIG_BLOCK_END|$)/;
|
|
const configBlockMatch = fileContent.match(configBlockRegex);
|
|
if (configBlockMatch && configBlockMatch[1]) {
|
|
// Extract each config line
|
|
const configLines = configBlockMatch[1].split('\n');
|
|
// Process each line
|
|
configLines.forEach((line) => {
|
|
// Check if the line starts with a semicolon and has an equals sign
|
|
const configLineRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/;
|
|
const match = line.match(configLineRegex);
|
|
if (match) {
|
|
const key = match[1].trim();
|
|
let value = match[2].trim();
|
|
// Try to convert value to appropriate type
|
|
if (value === 'true' || value === 'false') {
|
|
value = value === 'true';
|
|
} else if (!isNaN(value) && value !== '') {
|
|
// Check if it's a number (but not a percentage)
|
|
if (!value.includes('%')) {
|
|
value = Number(value);
|
|
}
|
|
}
|
|
configObject[key] = value;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Extract additional variables that appear after EXECUTABLE_BLOCK_END
|
|
const additionalVarsRegex =
|
|
/; EXECUTABLE_BLOCK_(?:START|END)([\s\S]*?)(?:; CONFIG_BLOCK_START|$)/i;
|
|
const additionalVarsMatch = fileContent.match(additionalVarsRegex);
|
|
if (additionalVarsMatch && additionalVarsMatch[1]) {
|
|
const additionalLines = additionalVarsMatch[1].split('\n');
|
|
additionalLines.forEach((line) => {
|
|
// Match both standard format and the special case for "total filament cost"
|
|
const varRegex =
|
|
/^\s*;\s*((?:filament used|filament cost|total filament used|total filament cost|total layers count|estimated printing time)[^=]*?)\s*=\s*(.*?)\s*$/;
|
|
const match = line.match(varRegex);
|
|
if (match) {
|
|
const key = match[1].replace(/\[([^\]]+)\]/g, '$1').trim();
|
|
let value = match[2].trim();
|
|
// Clean up values - remove units in brackets and handle special cases
|
|
if (key.includes('filament used')) {
|
|
// Extract just the numeric value, ignoring units in brackets
|
|
const numMatch = value.match(/(\d+\.\d+)/);
|
|
if (numMatch) {
|
|
value = parseFloat(numMatch[1]);
|
|
}
|
|
} else if (key.includes('filament cost')) {
|
|
// Extract just the numeric value
|
|
const numMatch = value.match(/(\d+\.\d+)/);
|
|
if (numMatch) {
|
|
value = parseFloat(numMatch[1]);
|
|
}
|
|
} else if (key.includes('total layers count')) {
|
|
value = parseInt(value, 10);
|
|
} else if (key.includes('estimated printing time')) {
|
|
// Keep as string but trim any additional whitespace
|
|
value = value.trim();
|
|
}
|
|
configObject[key] = value;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Also extract extrusion width settings
|
|
const extrusionWidthRegex = /;\s*(.*?)\s*extrusion width\s*=\s*(.*?)mm/g;
|
|
let extrusionMatch;
|
|
while ((extrusionMatch = extrusionWidthRegex.exec(fileContent)) !== null) {
|
|
const settingName = extrusionMatch[1].trim();
|
|
const settingValue = parseFloat(extrusionMatch[2].trim());
|
|
configObject[`${settingName} extrusion width`] = settingValue;
|
|
}
|
|
|
|
// Extract additional parameters after CONFIG_BLOCK_END if they exist
|
|
const postConfigParams = /; CONFIG_BLOCK_END\s*\n([\s\S]*?)$/;
|
|
const postConfigMatch = fileContent.match(postConfigParams);
|
|
if (postConfigMatch && postConfigMatch[1]) {
|
|
const postConfigLines = postConfigMatch[1].split('\n');
|
|
postConfigLines.forEach((line) => {
|
|
// Match lines with format "; parameter_name = value"
|
|
const paramRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/;
|
|
const match = line.match(paramRegex);
|
|
if (match) {
|
|
const key = match[1].trim();
|
|
let value = match[2].trim();
|
|
|
|
// Try to convert value to appropriate type
|
|
if (value === 'true' || value === 'false') {
|
|
value = value === 'true';
|
|
} else if (!isNaN(value) && value !== '') {
|
|
// Check if it's a number (but not a percentage)
|
|
if (!value.includes('%')) {
|
|
value = Number(value);
|
|
}
|
|
}
|
|
|
|
// Add to config object if not already present
|
|
if (!configObject[key]) {
|
|
configObject[key] = value;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Apply camelCase conversion if requested
|
|
return useCamelCase ? convertToCamelCase(configObject) : configObject;
|
|
}
|
|
|
|
function getChangedValues(oldObj, newObj, old = false) {
|
|
const changes = {};
|
|
|
|
const combinedObj = { ...oldObj, ...newObj };
|
|
|
|
// Check all keys in the new object
|
|
for (const key in combinedObj) {
|
|
// Skip if the key is _id or timestamps
|
|
if (key === 'createdAt' || key === 'updatedAt' || key === '_id') continue;
|
|
|
|
const oldVal = oldObj ? oldObj[key] : undefined;
|
|
const newVal = newObj ? newObj[key] : undefined;
|
|
|
|
// If both values are objects (but not arrays or null), recurse
|
|
if (
|
|
oldVal &&
|
|
newVal &&
|
|
typeof oldVal === 'object' &&
|
|
typeof newVal === 'object' &&
|
|
!Array.isArray(oldVal) &&
|
|
!Array.isArray(newVal) &&
|
|
oldVal !== null &&
|
|
newVal !== null
|
|
) {
|
|
if (oldVal?._id || newVal?._id) {
|
|
if (JSON.stringify(oldVal?._id) !== JSON.stringify(newVal?._id)) {
|
|
changes[key] = old ? oldVal : newVal;
|
|
}
|
|
} else {
|
|
const nestedChanges = getChangedValues(oldVal, newVal, old);
|
|
if (Object.keys(nestedChanges).length > 0) {
|
|
changes[key] = nestedChanges;
|
|
}
|
|
}
|
|
} else {
|
|
// Check if both values are numbers (or can be converted to numbers)
|
|
const oldIsNumber =
|
|
typeof oldVal === 'number' ||
|
|
(oldVal !== null && oldVal !== undefined && !isNaN(Number(oldVal)) && oldVal !== '');
|
|
const newIsNumber =
|
|
typeof newVal === 'number' ||
|
|
(newVal !== null && newVal !== undefined && !isNaN(Number(newVal)) && newVal !== '');
|
|
|
|
let valuesDiffer;
|
|
if (oldIsNumber && newIsNumber) {
|
|
// Compare numbers directly (this normalizes 7.50 to 7.5)
|
|
valuesDiffer = Number(oldVal) !== Number(newVal);
|
|
} else {
|
|
// Use JSON.stringify for non-number comparisons
|
|
valuesDiffer = JSON.stringify(oldVal) !== JSON.stringify(newVal);
|
|
}
|
|
|
|
if (valuesDiffer) {
|
|
// If the old value is different from the new value, include it
|
|
changes[key] = old ? oldVal : newVal;
|
|
}
|
|
}
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
async function newAuditLog(newValue, parentId, parentType, user) {
|
|
// Filter out createdAt and updatedAt from newValue
|
|
const filteredNewValue = { ...newValue };
|
|
delete filteredNewValue.createdAt;
|
|
delete filteredNewValue.updatedAt;
|
|
const auditLog = new auditLogModel({
|
|
changes: {
|
|
new: filteredNewValue,
|
|
},
|
|
parent: parentId,
|
|
parentType,
|
|
owner: user._id,
|
|
ownerType: 'user',
|
|
operation: 'new',
|
|
});
|
|
|
|
await auditLog.save();
|
|
|
|
await distributeNew(auditLog._id, 'auditLog');
|
|
}
|
|
|
|
async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
|
|
// Get only the changed values
|
|
const changedOldValues = getChangedValues(oldValue, newValue, true);
|
|
const changedNewValues = getChangedValues(oldValue, newValue, false);
|
|
|
|
// If no values changed, don't create an audit log
|
|
if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) {
|
|
return;
|
|
}
|
|
|
|
const auditLog = new auditLogModel({
|
|
changes: {
|
|
old: changedOldValues,
|
|
new: changedNewValues,
|
|
},
|
|
parent: parentId,
|
|
parentType,
|
|
owner: user._id,
|
|
ownerType: 'user',
|
|
operation: 'edit',
|
|
});
|
|
|
|
await auditLog.save();
|
|
|
|
await distributeNew(auditLog._id, 'auditLog');
|
|
}
|
|
|
|
async function deleteAuditLog(deleteValue, parentId, parentType, user) {
|
|
const auditLog = new auditLogModel({
|
|
changes: {
|
|
old: deleteValue,
|
|
},
|
|
parent: parentId,
|
|
parentType,
|
|
owner: user._id,
|
|
ownerType: 'user',
|
|
operation: 'delete',
|
|
});
|
|
|
|
await auditLog.save();
|
|
|
|
await distributeNew(auditLog._id, 'auditLog');
|
|
}
|
|
|
|
async function getAuditLogs(idOrIds) {
|
|
if (Array.isArray(idOrIds)) {
|
|
return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner');
|
|
} else {
|
|
return auditLogModel.find({ parent: idOrIds }).populate('owner');
|
|
}
|
|
}
|
|
|
|
async function distributeUpdate(value, id, type) {
|
|
await natsServer.publish(`${type}s.${id}.object`, value);
|
|
}
|
|
|
|
async function distributeStats(value, type) {
|
|
await natsServer.publish(`${type}s.stats`, value);
|
|
}
|
|
|
|
async function distributeNew(value, type) {
|
|
await natsServer.publish(`${type}s.new`, value);
|
|
}
|
|
|
|
async function distributeDelete(value, type) {
|
|
await natsServer.publish(`${type}s.delete`, value);
|
|
}
|
|
|
|
async function distributeChildUpdate(oldValue, newValue, id, model) {
|
|
const oldPopulatedObjects = populateObjects(oldValue, model) || [];
|
|
const oldPopulatedObjectIds = oldPopulatedObjects.map((populate) => populate._id.toString());
|
|
const newPopulatedObjects = populateObjects(newValue, model) || [];
|
|
const newPopulatedObjectIds = newPopulatedObjects.map((populate) => populate._id.toString());
|
|
|
|
for (const populated of oldPopulatedObjects) {
|
|
if (!newPopulatedObjectIds.includes(populated._id.toString())) {
|
|
logger.debug(
|
|
`Distributing child update for ${populated.ref}s.${populated._id}.events.childUpdate`
|
|
);
|
|
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childUpdate`, {
|
|
type: 'childUpdate',
|
|
data: { parentId: id, parentType: model.modelName },
|
|
});
|
|
}
|
|
}
|
|
for (const populated of newPopulatedObjects) {
|
|
if (!oldPopulatedObjectIds.includes(populated._id.toString())) {
|
|
logger.debug(
|
|
`Distributing child update for ${populated.ref}s.${populated._id}.events.childUpdate`
|
|
);
|
|
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childUpdate`, {
|
|
type: 'childUpdate',
|
|
data: { parentId: id, parentType: model.modelName },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function distributeChildDelete(value, id, model) {
|
|
const populatedObjects = populateObjects(value, model) || [];
|
|
for (const populated of populatedObjects) {
|
|
logger.debug(
|
|
`Distributing child delete for ${populated.ref}s.${populated._id}.events.childDelete`
|
|
);
|
|
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childDelete`, {
|
|
type: 'childDelete',
|
|
data: { parentId: id, parentType: model.modelName },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function distributeChildNew(value, id, model) {
|
|
const populatedObjects = populateObjects(value, model) || [];
|
|
for (const populated of populatedObjects) {
|
|
logger.debug(`Distributing child new for ${populated.ref}s.${populated._id}.events.childNew`);
|
|
await natsServer.publish(`${populated.ref}s.${populated._id}.events.childNew`, {
|
|
type: 'childNew',
|
|
data: { parentId: id, parentType: model.modelName },
|
|
});
|
|
}
|
|
}
|
|
|
|
function flatternObjectIds(object) {
|
|
if (!object || typeof object !== 'object') {
|
|
return object;
|
|
}
|
|
|
|
const result = {};
|
|
|
|
for (const [key, value] of Object.entries(object)) {
|
|
if (value && typeof value === 'object' && value._id) {
|
|
// If the value is an object with _id, convert to just the _id
|
|
result[key] = value._id;
|
|
} else {
|
|
// Keep primitive values as is
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function expandObjectIds(input) {
|
|
// Helper to check if a value is an ObjectId or a 24-char hex string
|
|
function isObjectId(val) {
|
|
// Check for MongoDB ObjectId instance
|
|
if (val instanceof ObjectId) return true;
|
|
// Check for exactly 24 hex characters (no special characters)
|
|
if (typeof val === 'string' && /^[a-fA-F\d]{24}$/.test(val)) return true;
|
|
return false;
|
|
}
|
|
|
|
// Recursive function
|
|
function expand(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map(expand);
|
|
} else if (value && typeof value === 'object' && !(value instanceof ObjectId)) {
|
|
var result = {};
|
|
for (const [key, val] of Object.entries(value)) {
|
|
if (key === '_id') {
|
|
// Do not expand keys that are already named _id
|
|
result[key] = val;
|
|
} else if (isObjectId(val)) {
|
|
result[key] = { _id: val };
|
|
} else if (Array.isArray(val)) {
|
|
result[key] = val.map(expand);
|
|
} else if (val instanceof Date) {
|
|
result[key] = val;
|
|
} else if (val && typeof val === 'object') {
|
|
result[key] = expand(val);
|
|
} else {
|
|
result[key] = val;
|
|
}
|
|
}
|
|
return result;
|
|
} else if (isObjectId(value)) {
|
|
return { _id: value };
|
|
} else {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return expand(input);
|
|
}
|
|
|
|
// Returns a filter object based on allowed filters and req.query
|
|
function getFilter(query, allowedFilters, parse = true) {
|
|
let filter = {};
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (allowedFilters.includes(key)) {
|
|
console.log('key', key);
|
|
console.log('value', value);
|
|
const parsedFilter = parse ? parseFilter(key, value) : { [key]: value };
|
|
filter = { ...filter, ...parsedFilter };
|
|
}
|
|
}
|
|
return filter;
|
|
}
|
|
|
|
// Converts a properties argument (string or array) to an array of strings
|
|
function convertPropertiesString(properties) {
|
|
if (typeof properties === 'string') {
|
|
return properties.split(',');
|
|
} else if (!Array.isArray(properties)) {
|
|
return [];
|
|
}
|
|
return properties;
|
|
}
|
|
|
|
async function getFileMeta(file) {
|
|
try {
|
|
if (!file) return {};
|
|
|
|
const originalName = file.originalname || '';
|
|
const lowerName = originalName.toLowerCase();
|
|
|
|
if (lowerName.endsWith('.g') || lowerName.endsWith('.gcode')) {
|
|
const content = file.buffer ? file.buffer.toString('utf8') : '';
|
|
if (!content) return {};
|
|
return extractGCodeConfigBlock(content);
|
|
}
|
|
|
|
// Image EXIF metadata
|
|
if (file.mimetype && file.mimetype.startsWith('image/') && file.buffer) {
|
|
try {
|
|
const exif = await exifr.parse(file.buffer);
|
|
return exif || {};
|
|
} catch (_) {
|
|
// Ignore EXIF parse errors and fall through
|
|
}
|
|
}
|
|
|
|
return {};
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function modelHasRef(model, refName) {
|
|
if (!model || !model.schema) {
|
|
return false;
|
|
}
|
|
|
|
let hasRef = false;
|
|
model.schema.eachPath((pathName, schemaType) => {
|
|
const directRef = schemaType?.options?.ref;
|
|
const arrayRef = schemaType?.caster?.options?.ref;
|
|
const ref = directRef || arrayRef;
|
|
if (ref === refName) {
|
|
hasRef = true;
|
|
}
|
|
});
|
|
|
|
return hasRef;
|
|
}
|
|
|
|
function getFieldsByRef(model, refName) {
|
|
if (!model || !model.schema) {
|
|
return [];
|
|
}
|
|
|
|
const fields = [];
|
|
model.schema.eachPath((pathName, schemaType) => {
|
|
const directRef = schemaType?.options?.ref;
|
|
const arrayRef = schemaType?.caster?.options?.ref;
|
|
const ref = directRef || arrayRef;
|
|
if (ref === refName) {
|
|
fields.push(pathName);
|
|
}
|
|
});
|
|
|
|
return fields;
|
|
}
|
|
|
|
// Build a nested populate specification by walking the schema graph,
|
|
// instead of recursing over already-populated documents.
|
|
function buildDeepPopulateSpec(object, model, populated = new Set()) {
|
|
// prevent infinite recursion across cyclic model relationships
|
|
if (populated.has(model.modelName)) return [];
|
|
populated.add(model.modelName);
|
|
|
|
const schema = model.schema;
|
|
const populateSpec = [];
|
|
|
|
schema.eachPath((pathname, schemaType) => {
|
|
const directRef = schemaType.options?.ref;
|
|
const arrayRef = schemaType.caster?.options?.ref;
|
|
const ref = directRef || arrayRef;
|
|
if (!ref) return;
|
|
|
|
const refModel = model.db.model(ref);
|
|
const childPopulate = buildDeepPopulateSpec(object, refModel, populated);
|
|
|
|
const id = object[pathname]?._id || object[pathname];
|
|
|
|
if (id == null || !id) return;
|
|
|
|
if (childPopulate.length > 0) {
|
|
populateSpec.push({ path: pathname, populate: childPopulate, ref: ref, _id: id });
|
|
} else {
|
|
populateSpec.push({ path: pathname, ref: ref, _id: id });
|
|
}
|
|
});
|
|
|
|
return populateSpec;
|
|
}
|
|
|
|
function populateObjects(object, model, populated = new Set()) {
|
|
const populateSpec = buildDeepPopulateSpec(object, model, populated);
|
|
|
|
return populateSpec;
|
|
}
|
|
|
|
function jsonToCacheKey(obj) {
|
|
const normalized = canonicalize(obj);
|
|
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
|
|
return hash;
|
|
}
|
|
|
|
export function getQueryToCacheKey({ model, id, populate }) {
|
|
const populateKey = [];
|
|
if (populate) {
|
|
const populateArray = Array.isArray(populate) ? populate : [populate];
|
|
for (const pop of populateArray) {
|
|
if (typeof pop === 'string') {
|
|
populateKey.push(pop);
|
|
} else if (typeof pop === 'object' && pop.path) {
|
|
populateKey.push(pop.path);
|
|
}
|
|
}
|
|
}
|
|
return `${model}:${id?.toString()}-${populateKey.join(',')}`;
|
|
}
|
|
|
|
export {
|
|
parseFilter,
|
|
convertToCamelCase,
|
|
newAuditLog,
|
|
editAuditLog,
|
|
deleteAuditLog,
|
|
getAuditLogs,
|
|
flatternObjectIds,
|
|
expandObjectIds,
|
|
distributeUpdate,
|
|
distributeStats,
|
|
distributeNew,
|
|
distributeDelete,
|
|
distributeChildUpdate,
|
|
distributeChildDelete,
|
|
distributeChildNew,
|
|
getFilter, // <-- add here
|
|
convertPropertiesString,
|
|
getFileMeta,
|
|
modelHasRef,
|
|
getFieldsByRef,
|
|
jsonToCacheKey,
|
|
};
|