All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
This update introduces functions to manage boolean fields in the filtering process, including parsing boolean operands and building equality/comparison conditions. The `buildCondition`, `parseLeafCondition`, and `parseFilter` functions have been modified to accommodate boolean field checks, enhancing the overall filtering capabilities across the application.
1386 lines
44 KiB
JavaScript
1386 lines
44 KiB
JavaScript
import { mongoose } from 'mongoose';
|
|
import { auditLogModel } from './database/schemas/management/auditlog.schema.js';
|
|
import { notificationModel } from './database/schemas/misc/notification.schema.js';
|
|
import { userNotifierModel } from './database/schemas/misc/usernotifier.schema.js';
|
|
import { userModel } from './database/schemas/management/user.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';
|
|
import { getModelByName } from './services/misc/model.js';
|
|
import { models } from './database/schemas/models.js';
|
|
import { createEmailRenderAuthCode } from './services/misc/emailRenderAuth.js';
|
|
import { Worker } from 'worker_threads';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { diffJson } from 'diff';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const logger = log4js.getLogger('Utils');
|
|
logger.level = config.server.logLevel;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Filter expression parsing (Microsoft Dynamics NAV / Business Central style)
|
|
//
|
|
// Supported syntax (all compiled down to MongoDB query operators):
|
|
// = equal to 377 -> { field: 377 }
|
|
// <> not equal to <>0 -> { field: { $ne: 0 } }
|
|
// > greater than >1200 -> { field: { $gt: 1200 } }
|
|
// >= greater than or equal >=1200 -> { field: { $gte: 1200 } }
|
|
// < less than <1200 -> { field: { $lt: 1200 } }
|
|
// <= less than or equal <=1200 -> { field: { $lte: 1200 } }
|
|
// .. interval 1100..2100 -> { field: { $gte: 1100, $lte: 2100 } }
|
|
// open-ended intervals ..2500 / 23..
|
|
// | either / or 1200|1300 -> { field: { $in: [1200, 1300] } }
|
|
// & and <2000&>1000 -> { field: { $gt: 1000, $lt: 2000 } }
|
|
// ( ) grouping / precedence 30|(>=10&<=20)
|
|
// * any number of characters Co* / *Co / *Co*
|
|
// ? a single character Hans?n
|
|
// @ ignore case @location (text matching is case-insensitive)
|
|
//
|
|
// Date-typed fields (detected by name, e.g. *At / *Date / *Time) interpret bare
|
|
// values as calendar days/datetimes, e.g. "22" => the whole of day 22 of the
|
|
// current month/year, "22..23" => start of 22 through end of 23.
|
|
//
|
|
// Note: BC's space-delimited date shorthand is locale dependent; here numeric
|
|
// date operands are read day-first ("D M Y H Min S") with missing components
|
|
// filled from the current date and the interval boundary.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildWildcardRegexPattern(input) {
|
|
// Escape all regex special chars except * and ? (which we treat as wildcards)
|
|
const escaped = String(input).replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
// * matches any run of characters, ? matches exactly one character
|
|
const withWildcards = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
// Anchor so that, without wildcards, this is an exact match
|
|
return `^${withWildcards}$`;
|
|
}
|
|
|
|
function looksLikeDateField(property) {
|
|
const last = String(property).split('.').pop();
|
|
return /[a-z](?:At|Date|Time)$/.test(last) || /^(?:date|time|datetime)$/i.test(last);
|
|
}
|
|
|
|
const NO_MATCH_CONDITION = { op: { $in: [] } };
|
|
|
|
function isBooleanField(property, model = null) {
|
|
const schema = model?.schema;
|
|
if (schema) {
|
|
const path = schema.path(property);
|
|
return path?.instance === 'Boolean';
|
|
}
|
|
|
|
let found = false;
|
|
for (const entry of Object.values(models)) {
|
|
const path = entry.model?.schema?.path(property);
|
|
if (!path) continue;
|
|
found = true;
|
|
if (path.instance !== 'Boolean') return false;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function parseBooleanOperand(value) {
|
|
const lower = String(value).trim().toLowerCase();
|
|
if (lower === 'yes') return true;
|
|
if (lower === 'no') return false;
|
|
return undefined;
|
|
}
|
|
|
|
function buildBooleanEquality(value) {
|
|
const bool = parseBooleanOperand(value);
|
|
if (bool === undefined) return NO_MATCH_CONDITION;
|
|
return { value: bool };
|
|
}
|
|
|
|
function buildBooleanComparison(name, value) {
|
|
if (name === 'eq') return buildBooleanEquality(value);
|
|
|
|
if (name === 'ne') {
|
|
const bool = parseBooleanOperand(value);
|
|
if (bool === undefined) return NO_MATCH_CONDITION;
|
|
return { op: { $ne: bool } };
|
|
}
|
|
|
|
return NO_MATCH_CONDITION;
|
|
}
|
|
|
|
function isObjectIdString(value) {
|
|
return /^[a-f\d]{24}$/i.test(value);
|
|
}
|
|
|
|
function isNumeric(value) {
|
|
return value.trim() !== '' && !isNaN(value);
|
|
}
|
|
|
|
function startOfDay(date) {
|
|
const d = new Date(date);
|
|
d.setHours(0, 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
function endOfDay(date) {
|
|
const d = new Date(date);
|
|
d.setHours(23, 59, 59, 999);
|
|
return d;
|
|
}
|
|
|
|
function normalizeYear(year) {
|
|
if (year >= 100) return year;
|
|
return year < 70 ? 2000 + year : 1900 + year;
|
|
}
|
|
|
|
// Parses a single date operand to a Date, filling missing parts from the
|
|
// current date and the supplied interval boundary ('start' | 'end').
|
|
function parseDateOperand(value, boundary = 'start') {
|
|
const text = String(value).trim();
|
|
if (!text) return null;
|
|
const end = boundary === 'end';
|
|
|
|
// Values with explicit separators (ISO and similar) are parsed directly.
|
|
if (/[-/T]/.test(text) || /\d:\d/.test(text)) {
|
|
const parsed = new Date(text);
|
|
if (isNaN(parsed.getTime())) return null;
|
|
if (!/[T:]/.test(text)) {
|
|
return end ? endOfDay(parsed) : startOfDay(parsed);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
// Numeric, day-first form: "D [M] [Y] [H] [Min] [S]".
|
|
const parts = text.split(/\s+/);
|
|
if (!parts.every((part) => /^\d+$/.test(part))) return null;
|
|
const nums = parts.map(Number);
|
|
const now = new Date();
|
|
const day = nums[0];
|
|
const month = nums.length >= 2 ? nums[1] : now.getMonth() + 1;
|
|
const year = nums.length >= 3 ? normalizeYear(nums[2]) : now.getFullYear();
|
|
const hour = nums.length >= 4 ? nums[3] : end ? 23 : 0;
|
|
const minute = nums.length >= 5 ? nums[4] : end ? 59 : 0;
|
|
const second = nums.length >= 6 ? nums[5] : end ? 59 : 0;
|
|
const ms = end ? 999 : 0;
|
|
const date = new Date(year, month - 1, day, hour, minute, second, ms);
|
|
return isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
// Coerces a literal to its strongest matching scalar type.
|
|
function coerceScalar(value) {
|
|
const lower = value.toLowerCase();
|
|
if (lower === 'true') return true;
|
|
if (lower === 'false') return false;
|
|
if (isObjectIdString(value)) return new mongoose.Types.ObjectId(value);
|
|
if (isNumeric(value)) return Number(value);
|
|
return value;
|
|
}
|
|
|
|
// Coerces an interval/comparison boundary, preferring a date for date fields.
|
|
function coerceBoundary(value, isDateField, boundary) {
|
|
if (isDateField) {
|
|
const date = parseDateOperand(value, boundary);
|
|
if (date) return date;
|
|
}
|
|
return coerceScalar(value);
|
|
}
|
|
|
|
// Splits on a separator while respecting parentheses depth.
|
|
function splitTopLevel(str, separator) {
|
|
const parts = [];
|
|
let depth = 0;
|
|
let current = '';
|
|
for (const ch of str) {
|
|
if (ch === '(') depth++;
|
|
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
|
|
if (ch === separator && depth === 0) {
|
|
parts.push(current);
|
|
current = '';
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
parts.push(current);
|
|
return parts;
|
|
}
|
|
|
|
// True when a single outer pair of parentheses wraps the whole token.
|
|
function isWrappedInParens(str) {
|
|
if (!str.startsWith('(') || !str.endsWith(')')) return false;
|
|
let depth = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
if (str[i] === '(') depth++;
|
|
else if (str[i] === ')') {
|
|
depth--;
|
|
if (depth === 0 && i < str.length - 1) return false;
|
|
}
|
|
}
|
|
return depth === 0;
|
|
}
|
|
|
|
// Parses a filter expression into an AST of or / and / leaf nodes.
|
|
function parseExpression(str) {
|
|
const orParts = splitTopLevel(str, '|');
|
|
if (orParts.length > 1) {
|
|
return { type: 'or', items: orParts.map(parseExpression) };
|
|
}
|
|
const andParts = splitTopLevel(str, '&');
|
|
if (andParts.length > 1) {
|
|
return { type: 'and', items: andParts.map(parseExpression) };
|
|
}
|
|
const trimmed = str.trim();
|
|
if (isWrappedInParens(trimmed)) {
|
|
return parseExpression(trimmed.slice(1, -1));
|
|
}
|
|
return { type: 'leaf', token: trimmed };
|
|
}
|
|
|
|
// Strips a leading @ (ignore-case marker); text matching is already case-insensitive.
|
|
function stripIgnoreCase(str) {
|
|
return str.startsWith('@') ? str.slice(1) : str;
|
|
}
|
|
|
|
// Builds an equality condition (value, date range, or wildcard regex).
|
|
function buildEquality(value, isDateField, isBooleanField = false) {
|
|
if (isBooleanField) return buildBooleanEquality(value);
|
|
if (isDateField) {
|
|
const start = parseDateOperand(value, 'start');
|
|
const end = parseDateOperand(value, 'end');
|
|
if (start && end) return { op: { $gte: start, $lte: end } };
|
|
}
|
|
const lower = value.toLowerCase();
|
|
if (lower === 'true') return { value: true };
|
|
if (lower === 'false') return { value: false };
|
|
if (isObjectIdString(value)) return { value: new mongoose.Types.ObjectId(value) };
|
|
if (isNumeric(value)) return { value: Number(value) };
|
|
return { op: { $regex: buildWildcardRegexPattern(value), $options: 'i' } };
|
|
}
|
|
|
|
// Builds a comparison condition for a single operator.
|
|
function buildComparison(name, value, isDateField, isBooleanField = false) {
|
|
if (isBooleanField) return buildBooleanComparison(name, value);
|
|
if (name === 'eq') return buildEquality(value, isDateField);
|
|
|
|
if (name === 'ne') {
|
|
if (isDateField) {
|
|
const start = parseDateOperand(value, 'start');
|
|
const end = parseDateOperand(value, 'end');
|
|
if (start && end) return { op: { $not: { $gte: start, $lte: end } } };
|
|
}
|
|
if (/[*?]/.test(value)) {
|
|
return { op: { $not: { $regex: buildWildcardRegexPattern(value), $options: 'i' } } };
|
|
}
|
|
return { op: { $ne: coerceScalar(value) } };
|
|
}
|
|
|
|
// For dates, < and >= align to the start of the day, > and <= to the end.
|
|
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start';
|
|
return { op: { [`$${name}`]: coerceBoundary(value, isDateField, boundary) } };
|
|
}
|
|
|
|
// Parses a leaf token (range, comparison, or plain value) into a condition.
|
|
function parseLeafCondition(rawToken, isDateField, isBooleanField = false) {
|
|
const token = rawToken.trim();
|
|
if (token === '') {
|
|
if (isBooleanField) return NO_MATCH_CONDITION;
|
|
return { op: { $regex: '^$', $options: 'i' } };
|
|
}
|
|
|
|
// Interval: a..b, ..b, a..
|
|
const rangeIdx = token.indexOf('..');
|
|
if (rangeIdx !== -1) {
|
|
if (isBooleanField) {
|
|
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
|
|
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
|
|
if (lo !== '' || hi !== '') return NO_MATCH_CONDITION;
|
|
}
|
|
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
|
|
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
|
|
const op = {};
|
|
if (lo !== '') op.$gte = coerceBoundary(lo, isDateField, 'start');
|
|
if (hi !== '') op.$lte = coerceBoundary(hi, isDateField, 'end');
|
|
// A bare ".." places no constraint on the field.
|
|
if (Object.keys(op).length === 0) return { query: {} };
|
|
return { op };
|
|
}
|
|
|
|
// Comparison operators, longest symbols first.
|
|
const operators = [
|
|
['<>', 'ne'],
|
|
['>=', 'gte'],
|
|
['<=', 'lte'],
|
|
['>', 'gt'],
|
|
['<', 'lt'],
|
|
['=', 'eq'],
|
|
];
|
|
for (const [symbol, name] of operators) {
|
|
if (token.startsWith(symbol)) {
|
|
return buildComparison(
|
|
name,
|
|
stripIgnoreCase(token.slice(symbol.length).trim()),
|
|
isDateField,
|
|
isBooleanField
|
|
);
|
|
}
|
|
}
|
|
|
|
return buildEquality(stripIgnoreCase(token), isDateField, isBooleanField);
|
|
}
|
|
|
|
// Converts a condition descriptor into a MongoDB query object for `property`.
|
|
function conditionToQuery(cond, property) {
|
|
if (cond.query) return cond.query;
|
|
if (cond.op) return { [property]: cond.op };
|
|
return { [property]: cond.value };
|
|
}
|
|
|
|
function isOnlyRegex(op) {
|
|
return Object.keys(op).every((key) => key === '$regex' || key === '$options');
|
|
}
|
|
|
|
// Combines AND-ed conditions, merging operator objects on the same field where possible.
|
|
function combineAnd(children, property) {
|
|
if (children.some((child) => child.query)) {
|
|
return { query: { $and: children.map((child) => conditionToQuery(child, property)) } };
|
|
}
|
|
const merged = {};
|
|
for (const child of children) {
|
|
if (child.value !== undefined) {
|
|
merged.$eq = child.value;
|
|
} else if (child.op) {
|
|
Object.assign(merged, child.op);
|
|
}
|
|
}
|
|
return { op: merged };
|
|
}
|
|
|
|
// Combines OR-ed conditions, collapsing to $in or a single regex when possible.
|
|
function combineOr(children, property) {
|
|
if (children.every((child) => child.value !== undefined)) {
|
|
return { op: { $in: children.map((child) => child.value) } };
|
|
}
|
|
if (children.every((child) => child.op && isOnlyRegex(child.op))) {
|
|
const pattern = children.map((child) => child.op.$regex).join('|');
|
|
return { op: { $regex: pattern, $options: 'i' } };
|
|
}
|
|
return { query: { $or: children.map((child) => conditionToQuery(child, property)) } };
|
|
}
|
|
|
|
function buildCondition(node, property, isDateField, isBooleanField = false) {
|
|
if (node.type === 'leaf') {
|
|
return parseLeafCondition(node.token, isDateField, isBooleanField);
|
|
}
|
|
const children = node.items.map((item) =>
|
|
buildCondition(item, property, isDateField, isBooleanField)
|
|
);
|
|
return node.type === 'and' ? combineAnd(children, property) : combineOr(children, property);
|
|
}
|
|
|
|
function parseFilter(property, value, model = null) {
|
|
// Normalize state filter to state.type for schemas with state: { type }
|
|
if (property === 'state') {
|
|
property = 'state.type';
|
|
}
|
|
|
|
const isBoolField = isBooleanField(property, model);
|
|
|
|
if (value?._id !== undefined && value?._id !== null) {
|
|
return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } };
|
|
}
|
|
|
|
// Non-string values pass through; boolean fields only accept actual booleans.
|
|
if (typeof value !== 'string') {
|
|
if (isBoolField && typeof value !== 'boolean') {
|
|
return { [property]: { $in: [] } };
|
|
}
|
|
return { [property]: value };
|
|
}
|
|
|
|
let trimmed = value.trim();
|
|
if (trimmed.charAt(3) === ':') {
|
|
const afterColon = value.split(':')[1];
|
|
trimmed = afterColon != null ? afterColon.trim() : '';
|
|
}
|
|
|
|
const isDateField = looksLikeDateField(property);
|
|
const tree = parseExpression(trimmed);
|
|
const condition = buildCondition(tree, property, isDateField, isBoolField);
|
|
return conditionToQuery(condition, property);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const AUDIT_IGNORED_KEYS = ['createdAt', 'updatedAt', '_id'];
|
|
|
|
function isDiffableObject(value) {
|
|
return value && typeof value === 'object' && !Array.isArray(value) && value !== null;
|
|
}
|
|
|
|
function isNumericValue(value) {
|
|
return (
|
|
typeof value === 'number' ||
|
|
(value !== null && value !== undefined && !isNaN(Number(value)) && value !== '')
|
|
);
|
|
}
|
|
|
|
function normalizeDiffValue(value) {
|
|
return isNumericValue(value) ? Number(value) : value;
|
|
}
|
|
|
|
function valuesDiffer(oldVal, newVal) {
|
|
return diffJson(
|
|
{ value: normalizeDiffValue(oldVal) },
|
|
{ value: normalizeDiffValue(newVal) }
|
|
).some((part) => part.added || part.removed);
|
|
}
|
|
|
|
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 (AUDIT_IGNORED_KEYS.includes(key)) 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 (isDiffableObject(oldVal) && isDiffableObject(newVal)) {
|
|
if (oldVal?._id || newVal?._id) {
|
|
if (valuesDiffer(oldVal?._id, newVal?._id)) {
|
|
changes[key] = old ? oldVal : newVal;
|
|
}
|
|
} else {
|
|
const nestedChanges = getChangedValues(oldVal, newVal, old);
|
|
if (Object.keys(nestedChanges).length > 0) {
|
|
changes[key] = nestedChanges;
|
|
}
|
|
}
|
|
} else {
|
|
if (valuesDiffer(oldVal, newVal)) {
|
|
// If the old value is different from the new value, include it
|
|
changes[key] = old ? oldVal : newVal;
|
|
}
|
|
}
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier'];
|
|
const SENSITIVE_KEYS = ['secret'];
|
|
|
|
const DISTRIBUTE_KEYS = {
|
|
_id: true,
|
|
_reference: true,
|
|
name: true,
|
|
tags: true,
|
|
state: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
};
|
|
|
|
function omitSensitive(obj) {
|
|
if (obj == null || typeof obj !== 'object') return obj;
|
|
if (Array.isArray(obj)) return obj.map(omitSensitive);
|
|
const result = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (SENSITIVE_KEYS.includes(key)) continue;
|
|
result[key] = omitSensitive(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function newAuditLog(newValue, parentId, parentType, user) {
|
|
if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return;
|
|
|
|
// Filter out createdAt, updatedAt, and sensitive fields from newValue
|
|
const filteredNewValue = omitSensitive({ ...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, 'auditLog');
|
|
}
|
|
|
|
async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
|
|
if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return;
|
|
|
|
// Get only the changed values
|
|
const changedOldValues = omitSensitive(getChangedValues(oldValue, newValue, true));
|
|
const changedNewValues = omitSensitive(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, 'auditLog');
|
|
}
|
|
|
|
async function editNotification(oldValue, newValue, parentId, parentType, user) {
|
|
const model = getModelByName(parentType);
|
|
const objectName = oldValue?.name ?? newValue?.name ?? model?.label ?? parentType;
|
|
const changedOldValues = omitSensitive(getChangedValues(oldValue, newValue, true));
|
|
const changedNewValues = omitSensitive(getChangedValues(oldValue, newValue, false));
|
|
|
|
if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) {
|
|
return;
|
|
}
|
|
|
|
await notfiyObjectUserNotifiers(
|
|
parentId,
|
|
parentType,
|
|
`${objectName} edited by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
|
`The ${parentType} ${parentId} has been updated.`,
|
|
'editObject',
|
|
{
|
|
old: changedOldValues,
|
|
new: changedNewValues,
|
|
objectType: parentType,
|
|
object: { _id: String(parentId ?? '') },
|
|
user: {
|
|
_id: String(user?._id ?? ''),
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
async function deleteAuditLog(deleteValue, parentId, parentType, user) {
|
|
if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return;
|
|
|
|
const auditLog = new auditLogModel({
|
|
changes: {
|
|
old: omitSensitive(deleteValue),
|
|
},
|
|
parent: parentId,
|
|
parentType,
|
|
owner: user._id,
|
|
ownerType: 'user',
|
|
operation: 'delete',
|
|
});
|
|
|
|
await auditLog.save();
|
|
|
|
await distributeNew(auditLog, 'auditLog');
|
|
}
|
|
|
|
async function deleteNotification(object, parentId, parentType, user) {
|
|
const model = getModelByName(parentType);
|
|
const objectName = object?.name || model?.label || parentType;
|
|
await notfiyObjectUserNotifiers(
|
|
parentId,
|
|
parentType,
|
|
`${objectName} deleted by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
|
`The ${parentType} ${parentId} has been deleted.`,
|
|
'deleteObject',
|
|
{
|
|
object: omitSensitive(object),
|
|
objectType: parentType,
|
|
object: { _id: parentId },
|
|
user: { _id: user._id, firstName: user.firstName, lastName: user.lastName },
|
|
}
|
|
);
|
|
}
|
|
|
|
async function newNoteNotification(note, user) {
|
|
const model = getModelByName(note.parentType);
|
|
const objectName = model?.label ?? note.parentType;
|
|
await notfiyObjectUserNotifiers(
|
|
note.parent,
|
|
note.parentType,
|
|
`New note added to ${objectName.toLowerCase()} by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
|
|
`A new note has been created.`,
|
|
'newNote',
|
|
{
|
|
objectType: note.parentType,
|
|
object: { _id: String(note.parent ?? '') },
|
|
note: note,
|
|
user: {
|
|
_id: String(user?._id ?? ''),
|
|
firstName: user?.firstName,
|
|
lastName: user?.lastName,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
async function subscribeAuditLog(parentId, parentType, user) {
|
|
const auditLog = new auditLogModel({
|
|
parent: parentId,
|
|
parentType,
|
|
owner: user._id,
|
|
ownerType: 'user',
|
|
operation: 'subscribe',
|
|
});
|
|
await auditLog.save();
|
|
await distributeNew(auditLog, 'auditLog');
|
|
return auditLog;
|
|
}
|
|
|
|
async function unsubscribeAuditLog(parentId, parentType, user) {
|
|
const auditLog = new auditLogModel({
|
|
parent: parentId,
|
|
parentType,
|
|
owner: user._id,
|
|
ownerType: 'user',
|
|
operation: 'unsubscribe',
|
|
});
|
|
await auditLog.save();
|
|
await distributeNew(auditLog, 'auditLog');
|
|
return 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');
|
|
}
|
|
}
|
|
|
|
function filterDistributeKeys(value, keys = DISTRIBUTE_KEYS) {
|
|
if (value == null) return value;
|
|
|
|
const obj = value?.toObject ? value.toObject() : value;
|
|
if (typeof obj !== 'object' || Array.isArray(obj)) return obj;
|
|
|
|
const hasKeys = keys && Object.keys(keys).length > 0;
|
|
if (!hasKeys) return { ...obj };
|
|
|
|
const result = {};
|
|
for (const [key, val] of Object.entries(obj)) {
|
|
if (keys[key] === true) {
|
|
result[key] = val;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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`, filterDistributeKeys(value));
|
|
}
|
|
|
|
async function distributeNew(value, type) {
|
|
await natsServer.publish(`${type}s.new`, filterDistributeKeys(value));
|
|
}
|
|
|
|
async function distributeDelete(value, type) {
|
|
await natsServer.publish(`${type}s.delete`, filterDistributeKeys(value));
|
|
}
|
|
|
|
function getReferenceId(value) {
|
|
if (value instanceof mongoose.Types.ObjectId) {
|
|
return value;
|
|
}
|
|
if (value && typeof value === 'object' && value._id) {
|
|
return getReferenceId(value._id);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function getReferenceIdString(value) {
|
|
const id = getReferenceId(value);
|
|
return id == null ? null : id.toString();
|
|
}
|
|
|
|
async function distributeChildUpdate(oldValue, newValue, id, model) {
|
|
const oldPopulatedObjects = populateObjects(oldValue, model) || [];
|
|
const oldPopulatedObjectIds = oldPopulatedObjects.map((populate) =>
|
|
getReferenceIdString(populate._id)
|
|
);
|
|
const newPopulatedObjects = populateObjects(newValue, model) || [];
|
|
const newPopulatedObjectIds = newPopulatedObjects.map((populate) =>
|
|
getReferenceIdString(populate._id)
|
|
);
|
|
|
|
for (const populated of oldPopulatedObjects) {
|
|
const populatedId = getReferenceIdString(populated._id);
|
|
if (!populatedId) continue;
|
|
if (!newPopulatedObjectIds.includes(populatedId)) {
|
|
logger.debug(
|
|
`Distributing child update for ${populated.ref}s.${populatedId}.events.childUpdate`
|
|
);
|
|
await natsServer.publish(`${populated.ref}s.${populatedId}.events.childUpdate`, {
|
|
type: 'childUpdate',
|
|
data: { parentId: id, parentType: model.modelName },
|
|
});
|
|
}
|
|
}
|
|
for (const populated of newPopulatedObjects) {
|
|
const populatedId = getReferenceIdString(populated._id);
|
|
if (!populatedId) continue;
|
|
if (!oldPopulatedObjectIds.includes(populatedId)) {
|
|
logger.debug(
|
|
`Distributing child update for ${populated.ref}s.${populatedId}.events.childUpdate`
|
|
);
|
|
await natsServer.publish(`${populated.ref}s.${populatedId}.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) {
|
|
const populatedId = getReferenceIdString(populated._id);
|
|
if (!populatedId) continue;
|
|
logger.debug(
|
|
`Distributing child delete for ${populated.ref}s.${populatedId}.events.childDelete`
|
|
);
|
|
await natsServer.publish(
|
|
`${populated.ref}s.${populatedId}.events.childDelete`,
|
|
filterDistributeKeys({
|
|
type: 'childDelete',
|
|
data: { parentId: id, parentType: model.modelName },
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
async function distributeChildNew(value, id, model) {
|
|
const populatedObjects = populateObjects(value, model) || [];
|
|
for (const populated of populatedObjects) {
|
|
const populatedId = getReferenceIdString(populated._id);
|
|
if (!populatedId) continue;
|
|
logger.debug(`Distributing child new for ${populated.ref}s.${populatedId}.events.childNew`);
|
|
await natsServer.publish(
|
|
`${populated.ref}s.${populatedId}.events.childNew`,
|
|
filterDistributeKeys({
|
|
type: 'childNew',
|
|
data: { parentId: id, parentType: model.modelName },
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
async function notfiyObjectUserNotifiers(id, objectType, title, message, type = 'info', metadata) {
|
|
const userNotifiers = await userNotifierModel.find({ object: id, objectType: objectType });
|
|
for (const userNotifier of userNotifiers) {
|
|
await createNotification(userNotifier.user._id, title, message, type, metadata);
|
|
if (userNotifier.email == true) {
|
|
await sendEmailNotification(userNotifier.user, title, message, type, metadata);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function createNotification(user, title, message, type = 'info', metadata) {
|
|
const notification = new notificationModel({
|
|
user,
|
|
title,
|
|
message,
|
|
type,
|
|
metadata: omitSensitive(metadata ?? {}),
|
|
});
|
|
await notification.save();
|
|
const value = notification.toJSON ? notification.toJSON() : notification;
|
|
await natsServer.publish(`notifications.${user._id}`, value);
|
|
return notification;
|
|
}
|
|
|
|
let mailWorker = null;
|
|
|
|
function getMailWorker() {
|
|
if (!mailWorker) {
|
|
const workerPath = path.join(__dirname, 'mailworker.js');
|
|
mailWorker = new Worker(workerPath);
|
|
mailWorker.on('error', (err) => logger.error('MailWorker error:', err));
|
|
mailWorker.on('exit', (code) => {
|
|
if (code !== 0) logger.warn('MailWorker exited with code', code);
|
|
mailWorker = null;
|
|
});
|
|
}
|
|
return mailWorker;
|
|
}
|
|
|
|
/**
|
|
* Sends an email notification asynchronously via a worker thread.
|
|
* Renders a React template on the urlClient, captures HTML with Puppeteer, and emails the user.
|
|
* Accepts the same input as createNotification: user, title, message, type, metadata.
|
|
* Returns immediately; does not wait for the email to be sent.
|
|
* @param {ObjectId|Object} user - User ID or user object (must have email)
|
|
* @param {string} title - Notification title
|
|
* @param {string} message - Notification message
|
|
* @param {string} type - Notification type (info, editObject, deleteObject, error, success)
|
|
* @param {Object} metadata - Optional metadata object
|
|
*/
|
|
async function sendEmailNotification(user, title, message, type = 'info', metadata) {
|
|
let userDoc = user;
|
|
if (user && (mongoose.Types.ObjectId.isValid(user) || user._id)) {
|
|
const userId = user._id || user;
|
|
userDoc = await userModel.findById(userId).lean();
|
|
}
|
|
if (!userDoc?.email) {
|
|
logger.warn('sendEmailNotification: no email for user', user);
|
|
return null;
|
|
}
|
|
|
|
const smtpConfig = config.smtp;
|
|
if (!smtpConfig?.host) {
|
|
logger.warn('sendEmailNotification: SMTP not configured, skipping email');
|
|
return null;
|
|
}
|
|
|
|
const urlClient = config.app?.urlClient || 'http://localhost:3000';
|
|
const authCode = createEmailRenderAuthCode(userDoc);
|
|
|
|
const payload = {
|
|
email: userDoc.email,
|
|
title,
|
|
message,
|
|
type,
|
|
metadata: omitSensitive(metadata || {}),
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
authCode,
|
|
smtpConfig: {
|
|
host: smtpConfig.host,
|
|
port: smtpConfig.port || 587,
|
|
secure: smtpConfig.secure || false,
|
|
auth: smtpConfig.auth?.user ? smtpConfig.auth : undefined,
|
|
from: smtpConfig.from || 'FarmControl <noreply@farmcontrol.app>',
|
|
},
|
|
urlClient,
|
|
};
|
|
|
|
try {
|
|
getMailWorker().postMessage(payload);
|
|
} catch (err) {
|
|
logger.error('sendEmailNotification: failed to post to worker', err.message);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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) {
|
|
const excludedFields = ['createdAt', 'updatedAt', 'name', '_id'];
|
|
// 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 mongoose.Types.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 mongoose.Types.ObjectId)) {
|
|
var result = {};
|
|
for (const [key, val] of Object.entries(value)) {
|
|
if (excludedFields.includes(key)) {
|
|
// Do not expand keys that are excluded
|
|
result[key] = val == null ? val : val.toString();
|
|
} 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);
|
|
}
|
|
|
|
// Merges per-field filter clauses, falling back to $and when clauses use
|
|
// logical operators or target the same field (so nothing is silently lost).
|
|
function mergeFilterClauses(clauses) {
|
|
const valid = clauses.filter((clause) => clause && Object.keys(clause).length > 0);
|
|
if (valid.length === 0) return {};
|
|
if (valid.length === 1) return valid[0];
|
|
|
|
const seen = new Set();
|
|
let needsAnd = false;
|
|
for (const clause of valid) {
|
|
for (const key of Object.keys(clause)) {
|
|
if (key === '$or' || key === '$and' || seen.has(key)) {
|
|
needsAnd = true;
|
|
}
|
|
seen.add(key);
|
|
}
|
|
}
|
|
|
|
return needsAnd ? { $and: valid } : Object.assign({}, ...valid);
|
|
}
|
|
|
|
// Returns a filter object based on allowed filters and req.query
|
|
function getFilter(query, allowedFilters, parse = true, model = null) {
|
|
const clauses = [];
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (allowedFilters.includes(key)) {
|
|
clauses.push(parse ? parseFilter(key, value, model) : { [key]: value });
|
|
}
|
|
}
|
|
return mergeFilterClauses(clauses);
|
|
}
|
|
|
|
// 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 refName = typeof ref === 'function' ? ref.call(object) : ref;
|
|
if (!refName) return;
|
|
|
|
const refModel = model.db.model(refName);
|
|
const childPopulate = buildDeepPopulateSpec(object, refModel, populated);
|
|
|
|
const values = Array.isArray(object[pathname]) ? object[pathname] : [object[pathname]];
|
|
const ids = values.map(getReferenceId).filter(Boolean);
|
|
|
|
for (const id of ids) {
|
|
if (childPopulate.length > 0) {
|
|
populateSpec.push({ path: pathname, populate: childPopulate, ref: refName, _id: id });
|
|
} else {
|
|
populateSpec.push({ path: pathname, ref: refName, _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,
|
|
editNotification,
|
|
deleteNotification,
|
|
editAuditLog,
|
|
deleteAuditLog,
|
|
getAuditLogs,
|
|
flatternObjectIds,
|
|
expandObjectIds,
|
|
newNoteNotification,
|
|
filterDistributeKeys,
|
|
DISTRIBUTE_KEYS,
|
|
distributeUpdate,
|
|
distributeStats,
|
|
distributeNew,
|
|
distributeDelete,
|
|
distributeChildUpdate,
|
|
distributeChildDelete,
|
|
distributeChildNew,
|
|
notfiyObjectUserNotifiers,
|
|
createNotification,
|
|
sendEmailNotification,
|
|
getFilter, // <-- add here
|
|
convertPropertiesString,
|
|
getFileMeta,
|
|
modelHasRef,
|
|
getFieldsByRef,
|
|
jsonToCacheKey,
|
|
subscribeAuditLog,
|
|
unsubscribeAuditLog,
|
|
};
|