farmcontrol-api/src/utils.js
Tom Butcher d157fcdc9e
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Implement schema utility functions and enhance email message handling
This commit introduces several utility functions in the `utils.js` file, including `getSchemaTypeRef`, `forEachSchemaPath`, and `collectIdsAtPath`, to streamline schema reference handling. Additionally, it refactors the `modelHasRef` and `getFieldsByRef` functions to utilize these new utilities, improving code readability and maintainability. The email message schema is updated to support multiple recipients with enhanced attachment handling, ensuring better management of email content and references. Tests are added to validate the new functionality, reinforcing the integrity of email message processing.
2026-09-15 01:57:06 +01:00

2354 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, getModelByPrefix } 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';
import { resolveAuditOwner, actorDisplayName } from './auditOwner.js';
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)
// '' empty (null or missing) '' / ='' / <>''
//
// Date-typed fields (schema Date) 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.
//
// Relative date operands:
// TODAY current calendar day
// TODAY.. from start of today onwards
// -7D.. from 7 days ago onwards (last 7 days)
// -30D.. from 30 days ago onwards
// -1W previous calendar week (Monday–Sunday)
// -1M previous calendar month (as a whole day-range)
// -1M..-CM previous calendar month (from its start up to current month)
// -1Y previous calendar year
// -nD / +nD n days before / after today
// -nW / +nW n calendar weeks before / after the current week
// -nM / +nM n calendar months before / after the current month
// -nY / +nY n calendar years before / after the current year
// -nH / +nH n hours before / after the current hour
// -nm / +nm n minutes before / after the current minute (lowercase m)
// CW / CM / CY / CH / Cm current week / month / year / hour / minute
// -CW / -CM / -CY / -CH / -Cm
// current-unit boundary (start of this unit / end of last unit)
//
// Unit letters: D day, W week, M month, Y year, H hour, m minute.
// M (month) and m (minute) are case-sensitive; other units are not.
//
// An invalid date operand (anything other than a date, relative date, or
// filter operator) omits that field from the query instead of sending it to
// MongoDB.
//
// 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}$`;
}
const NO_MATCH_CONDITION = { op: { $in: [] } };
const EMPTY_OPERAND = "''";
function isEmptyOperand(value) {
return String(value).trim() === EMPTY_OPERAND;
}
// MongoDB null equality matches both null and missing (undefined) fields.
function emptyCondition(operator = 'eq') {
if (operator === 'ne') return { op: { $ne: null } };
return { value: null };
}
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 isNumberSchemaPath(path) {
if (!path) return false;
if (path.instance === 'Number') return true;
if (path.instance === 'Array') {
return getEmbeddedSchemaType(path)?.instance === 'Number';
}
return false;
}
function isNumberField(property, model = null) {
if (model?.schema) {
return isNumberSchemaPath(model.schema.path(property));
}
for (const entry of Object.values(models)) {
if (isNumberSchemaPath(entry.model?.schema?.path(property))) return true;
}
return false;
}
function isDateTimeSchemaPath(path) {
if (!path) return false;
if (path.instance === 'Date') return true;
if (path.instance === 'Array') {
return getEmbeddedSchemaType(path)?.instance === 'Date';
}
return false;
}
function isDateTimeField(property, model = null) {
if (model?.schema) {
return isDateTimeSchemaPath(model.schema.path(property));
}
for (const entry of Object.values(models)) {
if (isDateTimeSchemaPath(entry.model?.schema?.path(property))) return true;
}
return false;
}
function buildNumberRegexCondition(property, pattern, negated = false) {
const expr = {
$regexMatch: {
input: { $toString: `$${property}` },
regex: pattern,
options: 'i',
},
};
return { query: { $expr: negated ? { $not: expr } : expr } };
}
function isObjectIdPath(property) {
return property === '_id' || property.endsWith('._id');
}
function getBaseProperty(property) {
if (property === '_id') return '_id';
if (property.endsWith('._id')) return property.slice(0, -4);
return property;
}
function getEmbeddedSchemaType(path) {
if (!path) return null;
return path.embeddedSchemaType ?? path.$embeddedSchemaType ?? path.caster ?? null;
}
function getObjectIdSchemaTypeFromPath(path) {
if (!path) return null;
const schemaType = path.instance === 'Array' ? getEmbeddedSchemaType(path) : path;
if (!schemaType) return null;
if (schemaType.instance === 'ObjectId' || schemaType.instance === 'ObjectID') {
return schemaType;
}
return null;
}
function getSchemaPathFromModels(property, model = null) {
if (model?.schema?.path(property)) return model.schema.path(property);
for (const entry of Object.values(models)) {
const path = entry.model?.schema?.path(property);
if (path) return path;
}
return null;
}
function getFilterFieldKind(property, model = null) {
const baseProperty = getBaseProperty(property);
const inspectPath = (path) => {
if (!path) return null;
const objectIdType = getObjectIdSchemaTypeFromPath(path);
if (isObjectIdPath(property) || objectIdType) {
const ref = objectIdType?.options?.ref ?? path.options?.ref;
if (property.endsWith('._id') && property !== '_id' && ref) {
return { kind: 'objectId', property: baseProperty };
}
if (ref) {
return { kind: 'objectRef', property: baseProperty, ref };
}
return { kind: 'objectId', property: baseProperty };
}
return { kind: 'default', property: baseProperty };
};
if (model) {
return (
inspectPath(model.schema?.path(baseProperty)) || {
kind: 'default',
property: baseProperty,
}
);
}
const path = getSchemaPathFromModels(baseProperty);
const kind = inspectPath(path);
if (kind) return kind;
return { kind: 'default', property: baseProperty };
}
function parsePrefixedValue(value) {
const trimmed = String(value).trim();
if (trimmed.length > 4 && trimmed.charAt(3) === ':') {
return {
prefix: trimmed.slice(0, 3).toUpperCase(),
suffix: trimmed.slice(4).trim(),
hadPrefix: true,
};
}
return { prefix: null, suffix: trimmed, hadPrefix: false };
}
const FILTER_OPERATOR_PREFIXES = ['<>', '>=', '<=', '>', '<', '='];
function splitLeadingOperator(str) {
const text = String(str).trim();
for (const op of FILTER_OPERATOR_PREFIXES) {
if (text.startsWith(op)) {
return { operator: op, operand: text.slice(op.length).trim() };
}
}
return { operator: '', operand: text };
}
// Strips a model prefix (e.g. GCF:) from an expression, including after a leading operator.
function stripModelPrefixFromExpression(expression) {
const { operator, operand } = splitLeadingOperator(expression);
const { suffix, hadPrefix } = parsePrefixedValue(operand);
if (!hadPrefix) return expression;
return operator + suffix;
}
function buildRegexOp(pattern, useOptions = true) {
const op = { $regex: pattern };
if (useOptions) op.$options = 'i';
return op;
}
function getSchemaRefName(property, model = null) {
const baseProperty = getBaseProperty(property);
const path = model?.schema?.path(baseProperty) ?? getSchemaPathFromModels(baseProperty);
const embeddedType = getEmbeddedSchemaType(path);
return path?.options?.ref ?? embeddedType?.options?.ref ?? null;
}
function getRefModelEntryFromSchemaRef(refName) {
if (!refName) return null;
let refModel;
try {
refModel = mongoose.model(refName);
} catch {
refModel = getModelByName(refName)?.model ?? null;
}
if (!refModel) return null;
const registryEntry = getModelByName(refName);
return {
model: refModel,
idField: registryEntry?.idField ?? '_id',
referenceField: registryEntry?.referenceField ?? '_reference',
};
}
function getRefModelEntryFromPrefix(prefix) {
const entry = getModelByPrefix(prefix);
if (!entry?.model) return null;
return {
model: entry.model,
idField: entry.idField ?? '_id',
referenceField: entry.referenceField ?? '_reference',
};
}
function stripPrefixFromOperand(operand) {
return stripModelPrefixFromExpression(operand);
}
function getRefModelEntryForToken(token, fallbackRefName) {
const { operand } = splitLeadingOperator(token);
const { prefix, hadPrefix } = parsePrefixedValue(operand);
if (hadPrefix) {
const entry = getRefModelEntryFromPrefix(prefix);
if (entry) return entry;
}
return getRefModelEntryFromSchemaRef(fallbackRefName);
}
function parseBooleanOperand(value) {
const lower = String(value).trim().toLowerCase();
if (lower === 'yes') return true;
if (lower === 'no') return false;
if (lower === 'true') return true;
if (lower === 'false') return false;
if (lower === '1') return true;
if (lower === '0') return false;
if (lower === 'on') return true;
if (lower === 'off') return false;
if (lower === 'y') return true;
if (lower === 'n') return false;
if (typeof value === 'boolean') return value;
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 startOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
}
function endOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
}
function shiftCalendarMonths(date, n) {
return new Date(date.getFullYear(), date.getMonth() + n, 1);
}
function startOfWeek(date) {
const d = startOfDay(date);
const day = d.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + mondayOffset);
return d;
}
function endOfWeek(date) {
const start = startOfWeek(date);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return endOfDay(end);
}
function shiftWeeks(date, n) {
const d = new Date(date);
d.setDate(d.getDate() + n * 7);
return d;
}
function startOfYear(date) {
return new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0);
}
function endOfYear(date) {
return new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999);
}
function shiftYears(date, n) {
return new Date(date.getFullYear() + n, 0, 1);
}
function startOfHour(date) {
const d = new Date(date);
d.setMinutes(0, 0, 0);
return d;
}
function endOfHour(date) {
const d = new Date(date);
d.setMinutes(59, 59, 999);
return d;
}
function startOfMinute(date) {
const d = new Date(date);
d.setSeconds(0, 0);
return d;
}
function endOfMinute(date) {
const d = new Date(date);
d.setSeconds(59, 999);
return d;
}
function normalizeYear(year) {
if (year >= 100) return year;
return year < 70 ? 2000 + year : 1900 + year;
}
const RELATIVE_OFFSET_RE = /^([+-])?(\d+)([DdWwYyHhMm])$/;
const RELATIVE_CURRENT_RE = /^([+-])?C([DdWwYyHhMm])$/;
function normalizeRelativeUnit(letter) {
if (letter === 'M' || letter === 'm') return letter;
return letter.toUpperCase();
}
function resolveRelativeUnit(now, unit, amount, boundary, isCurrent, minusCurrent) {
const end = boundary === 'end';
if (unit === 'D') {
if (isCurrent) return end ? endOfDay(now) : startOfDay(now);
const date = new Date(now);
date.setDate(date.getDate() + amount);
return end ? endOfDay(date) : startOfDay(date);
}
if (unit === 'W') {
if (isCurrent && minusCurrent) {
return end ? endOfWeek(shiftWeeks(now, -1)) : startOfWeek(now);
}
const target = isCurrent ? now : shiftWeeks(now, amount);
return end ? endOfWeek(target) : startOfWeek(target);
}
if (unit === 'M') {
if (isCurrent) {
if (minusCurrent) {
return end ? endOfMonth(shiftCalendarMonths(now, -1)) : startOfMonth(now);
}
return end ? endOfMonth(now) : startOfMonth(now);
}
const month = shiftCalendarMonths(now, amount);
return end ? endOfMonth(month) : startOfMonth(month);
}
if (unit === 'Y') {
if (isCurrent) {
if (minusCurrent) {
return end ? endOfYear(shiftYears(now, -1)) : startOfYear(now);
}
return end ? endOfYear(now) : startOfYear(now);
}
const year = shiftYears(now, amount);
return end ? endOfYear(year) : startOfYear(year);
}
if (unit === 'H') {
if (isCurrent) {
if (minusCurrent) {
const prev = new Date(now);
prev.setHours(prev.getHours() - 1);
return end ? endOfHour(prev) : startOfHour(now);
}
return end ? endOfHour(now) : startOfHour(now);
}
const date = new Date(now);
date.setHours(date.getHours() + amount);
return end ? endOfHour(date) : startOfHour(date);
}
if (unit === 'm') {
if (isCurrent) {
if (minusCurrent) {
const prev = new Date(now);
prev.setMinutes(prev.getMinutes() - 1);
return end ? endOfMinute(prev) : startOfMinute(now);
}
return end ? endOfMinute(now) : startOfMinute(now);
}
const date = new Date(now);
date.setMinutes(date.getMinutes() + amount);
return end ? endOfMinute(date) : startOfMinute(date);
}
return null;
}
function parseRelativeDateOperand(text, boundary = 'start') {
const raw = String(text).trim();
if (!raw) return null;
if (/^TODAY$/i.test(raw)) {
const now = new Date();
return boundary === 'end' ? endOfDay(now) : startOfDay(now);
}
const offset = raw.match(RELATIVE_OFFSET_RE);
if (offset) {
const amount = (offset[1] === '-' ? -1 : 1) * Number(offset[2]);
return resolveRelativeUnit(
new Date(),
normalizeRelativeUnit(offset[3]),
amount,
boundary,
false,
false
);
}
const current = raw.match(RELATIVE_CURRENT_RE);
if (current) {
return resolveRelativeUnit(
new Date(),
normalizeRelativeUnit(current[2]),
0,
boundary,
true,
current[1] === '-'
);
}
return null;
}
function dateComponentsMatch(date, year, month, day, hour, minute, second) {
return (
date.getFullYear() === year &&
date.getMonth() === month - 1 &&
date.getDate() === day &&
date.getHours() === hour &&
date.getMinutes() === minute &&
date.getSeconds() === second
);
}
// 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';
const relative = parseRelativeDateOperand(text, boundary);
if (relative) return relative;
// 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);
if (isNaN(date.getTime())) return null;
if (!dateComponentsMatch(date, year, month, day, hour, minute, second)) return null;
return date;
}
function isValidDateOperand(value) {
return parseDateOperand(value, 'start') != null;
}
function isValidDateFilterLeaf(rawToken) {
const token = rawToken.trim();
if (token === '') return true;
if (isEmptyOperand(token)) return true;
const rangeIdx = token.indexOf('..');
if (rangeIdx !== -1) {
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
if (lo !== '' && !isValidDateOperand(lo)) return false;
if (hi !== '' && !isValidDateOperand(hi)) return false;
return true;
}
const operators = ['<>', '>=', '<=', '>', '<', '='];
for (const symbol of operators) {
if (token.startsWith(symbol)) {
const operand = stripIgnoreCase(token.slice(symbol.length).trim());
if ((symbol === '=' || symbol === '<>') && isEmptyOperand(operand)) return true;
return operand !== '' && isValidDateOperand(operand);
}
}
return isValidDateOperand(stripIgnoreCase(token));
}
function isValidDateFilterNode(node) {
if (node.type === 'leaf') return isValidDateFilterLeaf(node.token);
return node.items.every(isValidDateFilterNode);
}
function isValidDateFilterExpression(expression) {
return isValidDateFilterNode(parseExpression(String(expression)));
}
// 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) {
return parseDateOperand(value, boundary);
}
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,
isObjectIdField = false,
isNumberField = false,
property = ''
) {
if (isEmptyOperand(value)) return emptyCondition('eq');
if (isBooleanField) return buildBooleanEquality(value);
if (isObjectIdField) {
const { suffix } = parsePrefixedValue(value);
if (/[*?]/.test(suffix)) return NO_MATCH_CONDITION;
if (isObjectIdString(suffix)) return { value: new mongoose.Types.ObjectId(suffix) };
return NO_MATCH_CONDITION;
}
if (isDateField) {
const start = parseDateOperand(value, 'start');
const end = parseDateOperand(value, 'end');
if (start && end) return { op: { $gte: start, $lte: end } };
return NO_MATCH_CONDITION;
}
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) };
if (isNumberField) {
if (/[*?]/.test(value)) {
return buildNumberRegexCondition(property, buildWildcardRegexPattern(value));
}
return NO_MATCH_CONDITION;
}
return { op: buildRegexOp(buildWildcardRegexPattern(value)) };
}
// Builds a comparison condition for a single operator.
function buildComparison(
name,
value,
isDateField,
isBooleanField = false,
isObjectIdField = false,
isNumberField = false,
property = ''
) {
if (isEmptyOperand(value)) {
if (name === 'eq' || name === 'ne') return emptyCondition(name);
return NO_MATCH_CONDITION;
}
if (isBooleanField) return buildBooleanComparison(name, value);
if (isObjectIdField) {
const { suffix } = parsePrefixedValue(value);
if (/[*?]/.test(suffix)) return NO_MATCH_CONDITION;
if (name === 'eq') return buildEquality(suffix, false, false, true);
if (name === 'ne' && isObjectIdString(suffix)) {
return { op: { $ne: new mongoose.Types.ObjectId(suffix) } };
}
return NO_MATCH_CONDITION;
}
if (name === 'eq') {
return buildEquality(value, isDateField, false, false, isNumberField, property);
}
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 } } };
return NO_MATCH_CONDITION;
}
if (/[*?]/.test(value)) {
if (isNumberField) {
return buildNumberRegexCondition(property, buildWildcardRegexPattern(value), true);
}
return { op: { $not: buildRegexOp(buildWildcardRegexPattern(value)) } };
}
if (isNumberField && !isNumeric(value)) return NO_MATCH_CONDITION;
return { op: { $ne: coerceScalar(value) } };
}
if (isNumberField && !isNumeric(value)) return NO_MATCH_CONDITION;
// For dates, < and >= align to the start of the day, > and <= to the end.
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start';
const bound = coerceBoundary(value, isDateField, boundary);
if (isDateField && !bound) return NO_MATCH_CONDITION;
return { op: { [`$${name}`]: bound } };
}
function parseLeafComparisonOperators(
token,
isDateField,
isBooleanField,
isObjectIdField,
isNumberField,
property
) {
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,
isObjectIdField,
isNumberField,
property
);
}
}
return null;
}
// Parses a leaf token (range, comparison, or plain value) into a condition.
function parseLeafCondition(
rawToken,
isDateField,
isBooleanField = false,
isObjectIdField = false,
isNumberField = false,
property = ''
) {
const token = rawToken.trim();
if (token === '') {
if (isBooleanField || isObjectIdField || isNumberField) return NO_MATCH_CONDITION;
return { op: buildRegexOp('^$') };
}
if (isEmptyOperand(token)) return emptyCondition('eq');
// Interval: a..b, ..b, a..
const rangeIdx = token.indexOf('..');
if (rangeIdx !== -1) {
if (isBooleanField || isObjectIdField) return NO_MATCH_CONDITION;
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
if (isNumberField) {
if ((lo !== '' && !isNumeric(lo)) || (hi !== '' && !isNumeric(hi))) {
return NO_MATCH_CONDITION;
}
}
if (isDateField) {
if (lo !== '' && !parseDateOperand(lo, 'start')) return NO_MATCH_CONDITION;
if (hi !== '' && !parseDateOperand(hi, 'end')) return NO_MATCH_CONDITION;
}
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 };
}
const comparison = parseLeafComparisonOperators(
token,
isDateField,
isBooleanField,
isObjectIdField,
isNumberField,
property
);
if (comparison) return comparison;
return buildEquality(
stripIgnoreCase(token),
isDateField,
isBooleanField,
isObjectIdField,
isNumberField,
property
);
}
// 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, isObjectIdField = false) {
if (children.every((child) => child.value !== undefined)) {
return { op: { $in: children.map((child) => child.value) } };
}
if (!isObjectIdField && children.every((child) => child.op && isOnlyRegex(child.op))) {
const pattern = children.map((child) => child.op.$regex).join('|');
const op = { $regex: pattern };
if (children[0].op.$options) op.$options = children[0].op.$options;
return { op };
}
return { query: { $or: children.map((child) => conditionToQuery(child, property)) } };
}
function buildCondition(
node,
property,
isDateField,
isBooleanField = false,
isObjectIdField = false,
isNumberField = false
) {
if (node.type === 'leaf') {
return parseLeafCondition(
node.token,
isDateField,
isBooleanField,
isObjectIdField,
isNumberField,
property
);
}
const children = node.items.map((item) =>
buildCondition(item, property, isDateField, isBooleanField, isObjectIdField, isNumberField)
);
return node.type === 'and'
? combineAnd(children, property)
: combineOr(children, property, isObjectIdField);
}
function idsToCondition(ids) {
if (!ids || ids.length === 0) return NO_MATCH_CONDITION;
if (ids.length === 1) return { value: ids[0] };
return { op: { $in: ids } };
}
function extractObjectIdFromOperand(operand) {
const { suffix } = parsePrefixedValue(stripIgnoreCase(String(operand).trim()));
if (isObjectIdString(suffix)) {
return new mongoose.Types.ObjectId(suffix);
}
return null;
}
const REF_OPERATOR_SYMBOLS = {
eq: '',
ne: '<>',
gt: '>',
gte: '>=',
lt: '<',
lte: '<=',
};
function buildRefListExpression(operator, operand) {
const text = String(operand).trim();
if (operator === 'eq') return text;
return `${REF_OPERATOR_SYMBOLS[operator]}${text}`;
}
// Pre-list the referenced model, matching the expression against name and/or _reference via parseFilter.
async function listRefModelIds(refModelEntry, expression) {
const refModel = refModelEntry.model;
const orClauses = [];
if (refModel.schema.path('name')) {
orClauses.push(await parseFilter('name', expression, refModel));
}
if (refModel.schema.path('_reference')) {
orClauses.push(await parseFilter('_reference', expression, refModel));
}
if (orClauses.length === 0) return [];
const query = orClauses.length === 1 ? orClauses[0] : { $or: orClauses };
const docs = await refModel.find(query).select('_id').lean();
return docs.map((doc) => doc._id);
}
function conditionToIds(cond) {
if (cond.query) return null;
if (cond.value !== undefined) return [cond.value];
if (cond.op?.$in) return cond.op.$in;
return null;
}
function combineOrRefIds(children) {
const ids = new Set();
for (const child of children) {
if (child.query) return child;
const childIds = conditionToIds(child);
if (!childIds) return child;
childIds.forEach((id) => ids.add(String(id)));
}
return idsToCondition([...ids].map((id) => new mongoose.Types.ObjectId(id)));
}
function combineAndRefIds(children) {
let ids = null;
for (const child of children) {
if (child.query) return child;
const childIds = conditionToIds(child);
if (!childIds || childIds.length === 0) return NO_MATCH_CONDITION;
const set = new Set(childIds.map(String));
if (ids === null) {
ids = set;
} else {
ids = new Set([...ids].filter((id) => set.has(id)));
}
if (ids.size === 0) return NO_MATCH_CONDITION;
}
return idsToCondition([...ids].map((id) => new mongoose.Types.ObjectId(id)));
}
async function resolveRefOperand(operand, refModelEntry, operator = 'eq') {
const lookupOperand = String(operand).trim();
if (isEmptyOperand(lookupOperand)) {
if (operator === 'eq' || operator === 'ne') return emptyCondition(operator);
return NO_MATCH_CONDITION;
}
const { prefix, suffix, hadPrefix } = parsePrefixedValue(lookupOperand);
if (hadPrefix) {
const prefixEntry = getRefModelEntryFromPrefix(prefix);
if (prefixEntry) refModelEntry = prefixEntry;
}
if (!refModelEntry?.model) return NO_MATCH_CONDITION;
const objectId = extractObjectIdFromOperand(lookupOperand);
if (objectId) {
if (operator === 'ne') return { op: { $ne: objectId } };
if (operator === 'eq') return { value: objectId };
return NO_MATCH_CONDITION;
}
const expressionOperand = hadPrefix ? suffix : lookupOperand;
if (operator === 'ne') {
const ids = await listRefModelIds(refModelEntry, expressionOperand);
if (ids.length === 0) return { query: {} };
return { op: { $nin: ids } };
}
const expression = buildRefListExpression(operator, expressionOperand);
const ids = await listRefModelIds(refModelEntry, expression);
return idsToCondition(ids);
}
async function resolveRefLeaf(rawToken, fallbackRefName) {
const token = rawToken.trim();
if (token === '') return NO_MATCH_CONDITION;
if (isEmptyOperand(token)) return emptyCondition('eq');
const refModelEntry = getRefModelEntryForToken(token, fallbackRefName);
const lookupToken = stripPrefixFromOperand(token);
if (!refModelEntry?.model) {
const objectId = extractObjectIdFromOperand(lookupToken);
if (objectId) return { value: objectId };
return NO_MATCH_CONDITION;
}
const rangeIdx = lookupToken.indexOf('..');
if (rangeIdx !== -1) {
const ids = await listRefModelIds(refModelEntry, lookupToken);
return idsToCondition(ids);
}
const operators = [
['<>', 'ne'],
['>=', 'gte'],
['<=', 'lte'],
['>', 'gt'],
['<', 'lt'],
['=', 'eq'],
];
for (const [symbol, name] of operators) {
if (lookupToken.startsWith(symbol)) {
return resolveRefOperand(lookupToken.slice(symbol.length), refModelEntry, name);
}
}
const objectId = extractObjectIdFromOperand(lookupToken);
if (objectId) return { value: objectId };
const ids = await listRefModelIds(refModelEntry, lookupToken);
return idsToCondition(ids);
}
async function resolveRefCondition(node, fallbackRefName) {
if (node.type === 'leaf') {
return resolveRefLeaf(node.token, fallbackRefName);
}
const children = await Promise.all(
node.items.map((item) => resolveRefCondition(item, fallbackRefName))
);
return node.type === 'and' ? combineAndRefIds(children) : combineOrRefIds(children);
}
async function resolveObjectRefFilter(filterProperty, expression, fallbackRefName) {
const tree = parseExpression(expression);
const condition = await resolveRefCondition(tree, fallbackRefName);
return conditionToQuery(condition, filterProperty);
}
async function parseFilter(property, value, model = null) {
// Normalize state filter to state.type for schemas with state: { type }
if (property === 'state') {
property = 'state.type';
}
const fieldKind = getFilterFieldKind(property, model);
const filterProperty = fieldKind.property;
const isBoolField = isBooleanField(property, model);
if (value?._id !== undefined && value?._id !== null) {
return { [filterProperty]: 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 { [filterProperty]: { $in: [] } };
}
return { [filterProperty]: value };
}
const trimmed = value.trim();
if ((fieldKind.kind === 'objectId' || fieldKind.kind === 'objectRef') && property != '_id') {
const refName = fieldKind.ref ?? getSchemaRefName(property, model);
return resolveObjectRefFilter(filterProperty, trimmed, refName);
}
let expression = trimmed;
if (fieldKind.kind === 'default') {
expression = stripModelPrefixFromExpression(expression);
}
const isDateField = isDateTimeField(property, model);
const isObjectIdField = fieldKind.kind === 'objectId';
const isNumField = isNumberField(filterProperty, model);
const tree = parseExpression(expression);
if (isDateField && !isValidDateFilterNode(tree)) {
return {};
}
const condition = buildCondition(
tree,
filterProperty,
isDateField,
isBoolField,
isObjectIdField,
isNumField
);
return conditionToQuery(condition, filterProperty);
}
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', 'objectView', 'marketplaceEvent'];
const SENSITIVE_KEYS = ['secret', 'password'];
const DISTRIBUTE_KEYS = {
_id: true,
_reference: true,
name: true,
type: 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,
...resolveAuditOwner(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,
...resolveAuditOwner(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 ${actorDisplayName(user)}`,
`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 ?? user?.name ?? '',
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,
...resolveAuditOwner(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 ${actorDisplayName(user)}`,
`The ${parentType} ${parentId} has been deleted.`,
'deleteObject',
{
object: omitSensitive(object),
objectType: parentType,
object: { _id: parentId },
user: {
_id: user?._id,
firstName: user?.firstName ?? user?.name ?? '',
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 ${actorDisplayName(user)}`,
`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,
...resolveAuditOwner(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,
...resolveAuditOwner(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, _id: id });
}
async function distributeStats(value, type) {
await natsServer.publish(`${type}s.stats`, filterDistributeKeys(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);
}
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;
}
async function createDocumentJobUserNotifier(documentJobId, user) {
const userId = user?._id ?? user;
if (!userId) return null;
const existing = await userNotifierModel.findOne({
user: userId,
object: documentJobId,
objectType: 'documentJob',
});
if (existing) return existing;
const userNotifier = await userNotifierModel.create({
user: userId,
object: documentJobId,
objectType: 'documentJob',
email: false,
});
await distributeNew(userNotifier, 'userNotifier');
return userNotifier;
}
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 isByte(value) {
const num = Number(value);
return Number.isInteger(num) && num >= 0 && num <= 255;
}
function isTwelveByteBuffer(val) {
if (!val) return false;
if (Buffer.isBuffer(val) || val instanceof Uint8Array) {
return val.length === 12;
}
if (typeof val !== 'object' || Array.isArray(val)) return false;
for (let i = 0; i < 12; i++) {
if (!isByte(val[i])) return false;
}
const indexKeys = Object.keys(val).filter((key) => /^\d+$/.test(key));
return indexKeys.length === 12;
}
function objectIdToString(val) {
if (val == null) return val;
if (typeof val === 'string') return val;
if (typeof val.toHexString === 'function') return val.toHexString();
if (val instanceof mongoose.Types.ObjectId) return val.toString();
if (isTwelveByteBuffer(val)) {
const bytes =
Buffer.isBuffer(val) || val instanceof Uint8Array
? val
: Array.from({ length: 12 }, (_, i) => Number(val[i]));
return Buffer.from(bytes).toString('hex');
}
if (val && typeof val === 'object' && isTwelveByteBuffer(val.buffer || val.id)) {
return objectIdToString(val.buffer || val.id);
}
return String(val);
}
function expandObjectIds(input) {
const excludedFields = ['createdAt', 'updatedAt', 'name', '_id'];
function isObjectId(val) {
if (val == null || typeof val === 'boolean' || typeof val === 'number') return false;
if (val instanceof Date) return false;
if (val instanceof mongoose.Types.ObjectId) return true;
if (typeof val === 'string' && /^[a-fA-F\d]{24}$/.test(val)) return true;
if (typeof val === 'object' && (val._bsontype === 'ObjectId' || val._bsontype === 'ObjectID')) {
return true;
}
if (typeof val.toHexString === 'function' && isTwelveByteBuffer(val.id || val.buffer)) {
return true;
}
if (isTwelveByteBuffer(val)) return true;
if (val && typeof val === 'object' && !Array.isArray(val)) {
const keys = Object.keys(val);
if (
keys.length > 0 &&
keys.every((key) => key === 'buffer' || key === 'id') &&
isTwelveByteBuffer(val.buffer || val.id)
) {
return true;
}
}
return false;
}
function expand(value) {
if (Array.isArray(value)) {
return value.map(expand);
} else if (value instanceof Date) {
return value;
} else if (isObjectId(value)) {
return { _id: objectIdToString(value) };
} else if (value && typeof value === 'object') {
var result = {};
for (const [key, val] of Object.entries(value)) {
if (excludedFields.includes(key)) {
result[key] = val == null ? val : key === '_id' ? objectIdToString(val) : val.toString();
} else if (isObjectId(val)) {
result[key] = { _id: objectIdToString(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 {
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 validated sort field based on allowed sorters
function getSort(sort, allowedSorters, defaultSort = 'createdAt') {
if (sort && allowedSorters.includes(sort)) {
return sort;
}
return defaultSort;
}
function normalizeFilterQuery(query = {}) {
const queryClean = { ...query };
// qs turns `order._id=` into `{ order: { _id } }`. Lift it so it can be parsed
// as the order object filter instead of a nested query object.
if (
queryClean.order &&
typeof queryClean.order === 'object' &&
!Array.isArray(queryClean.order)
) {
if (queryClean.order._id !== undefined) {
queryClean['order._id'] = queryClean.order._id;
}
delete queryClean.order;
}
return queryClean;
}
// Returns a filter object based on allowed filters and req.query
async function getFilter(query, allowedFilters, parse = true, model = null) {
const clauses = [];
const queryClean = normalizeFilterQuery(query);
for (const key of ['sortProperty', 'sortOrder', 'page', 'limit']) {
if (key in queryClean) delete queryClean[key];
}
if (!parse && queryClean['order._id'] !== undefined && allowedFilters.includes('order')) {
queryClean.order = queryClean['order._id'];
delete queryClean['order._id'];
}
for (const [key, value] of Object.entries(queryClean)) {
if (allowedFilters.includes(key)) {
if (parse) {
const clause = await parseFilter(key, value, model);
// Drop date filters that contain anything other than operators and dates.
if (clause && Object.keys(clause).length > 0) {
clauses.push(clause);
}
} else {
clauses.push({ [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 getSchemaTypeRef(schemaType) {
if (!schemaType) return undefined;
return (
schemaType.options?.ref ||
schemaType.caster?.options?.ref ||
schemaType.embeddedSchemaType?.options?.ref
);
}
function forEachSchemaPath(schema, callback) {
if (!schema || typeof callback !== 'function') return;
schema.eachPath((pathName, schemaType) => {
callback(pathName, schemaType);
});
const subpaths = schema.subpaths || {};
for (const [pathName, schemaType] of Object.entries(subpaths)) {
if (pathName.endsWith('.$')) continue;
callback(pathName, schemaType);
}
}
function modelHasRef(model, refName) {
if (!model || !model.schema) {
return false;
}
let hasRef = false;
forEachSchemaPath(model.schema, (_pathName, schemaType) => {
if (getSchemaTypeRef(schemaType) === refName) {
hasRef = true;
}
});
return hasRef;
}
function getFieldsByRef(model, refName) {
if (!model || !model.schema) {
return [];
}
const fields = [];
const seen = new Set();
forEachSchemaPath(model.schema, (pathName, schemaType) => {
if (getSchemaTypeRef(schemaType) !== refName || seen.has(pathName)) return;
seen.add(pathName);
fields.push(pathName);
});
return fields;
}
function collectIdsAtPath(object, pathName) {
if (object == null || !pathName) return [];
const parts = String(pathName).split('.').filter(Boolean);
let nodes = [object];
for (const part of parts) {
nodes = nodes.flatMap((node) => {
if (node == null || typeof node !== 'object') return [];
const next = node[part];
if (next == null || next === '') return [];
return Array.isArray(next) ? next : [next];
});
}
return nodes
.map((value) => {
if (value == null || value === '') return null;
if (typeof value === 'object') return value._id != null ? String(value._id) : null;
return String(value);
})
.filter(Boolean);
}
// 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 ref = getSchemaTypeRef(schemaType);
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,
createDocumentJobUserNotifier,
sendEmailNotification,
getFilter,
getSort,
convertPropertiesString,
getFileMeta,
modelHasRef,
getFieldsByRef,
collectIdsAtPath,
jsonToCacheKey,
subscribeAuditLog,
unsubscribeAuditLog,
resolveAuditOwner,
actorDisplayName,
};