Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
This commit introduces a new module, `auditOwner.js`, which includes functions for resolving audit owner details and generating display names for actors. The `resolveAuditOwner` function determines the owner type based on the actor's object type, defaulting to 'user' if not specified. The `actorDisplayName` function formats the display name based on the actor's properties, enhancing the clarity of audit logs. Additionally, the `AUDIT_OWNER_TYPES` constant is exported for use in other modules. Updates to existing files incorporate these new functions for improved audit logging and notification handling.
1817 lines
57 KiB
JavaScript
1817 lines
57 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, 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)
|
|
//
|
|
// 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 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 };
|
|
}
|
|
|
|
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) {
|
|
const { suffix, hadPrefix } = parsePrefixedValue(String(operand).trim());
|
|
return hadPrefix ? suffix : String(operand).trim();
|
|
}
|
|
|
|
function getRefModelEntryForToken(token, fallbackRefName) {
|
|
const { prefix, hadPrefix } = parsePrefixedValue(String(token).trim());
|
|
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 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, isObjectIdField = false) {
|
|
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 } };
|
|
}
|
|
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: buildRegexOp(buildWildcardRegexPattern(value)) };
|
|
}
|
|
|
|
// Builds a comparison condition for a single operator.
|
|
function buildComparison(
|
|
name,
|
|
value,
|
|
isDateField,
|
|
isBooleanField = false,
|
|
isObjectIdField = false
|
|
) {
|
|
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);
|
|
|
|
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: buildRegexOp(buildWildcardRegexPattern(value)) } };
|
|
}
|
|
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) } };
|
|
}
|
|
|
|
function parseLeafComparisonOperators(token, isDateField, isBooleanField, isObjectIdField) {
|
|
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
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Parses a leaf token (range, comparison, or plain value) into a condition.
|
|
function parseLeafCondition(
|
|
rawToken,
|
|
isDateField,
|
|
isBooleanField = false,
|
|
isObjectIdField = false
|
|
) {
|
|
const token = rawToken.trim();
|
|
if (token === '') {
|
|
if (isBooleanField || isObjectIdField) return NO_MATCH_CONDITION;
|
|
return { op: buildRegexOp('^$') };
|
|
}
|
|
|
|
// 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());
|
|
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
|
|
);
|
|
if (comparison) return comparison;
|
|
|
|
return buildEquality(stripIgnoreCase(token), isDateField, isBooleanField, isObjectIdField);
|
|
}
|
|
|
|
// 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
|
|
) {
|
|
if (node.type === 'leaf') {
|
|
return parseLeafCondition(node.token, isDateField, isBooleanField, isObjectIdField);
|
|
}
|
|
const children = node.items.map((item) =>
|
|
buildCondition(item, property, isDateField, isBooleanField, isObjectIdField)
|
|
);
|
|
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') {
|
|
if (!refModelEntry?.model) return NO_MATCH_CONDITION;
|
|
|
|
const lookupOperand = String(operand).trim();
|
|
const objectId = extractObjectIdFromOperand(lookupOperand);
|
|
|
|
if (objectId) {
|
|
if (operator === 'ne') return { op: { $ne: objectId } };
|
|
if (operator === 'eq') return { value: objectId };
|
|
return NO_MATCH_CONDITION;
|
|
}
|
|
|
|
const expression = buildRefListExpression(operator, lookupOperand);
|
|
const ids = await listRefModelIds(refModelEntry, expression);
|
|
|
|
if (operator === 'ne') {
|
|
if (ids.length === 0) return { query: {} };
|
|
return { op: { $nin: ids } };
|
|
}
|
|
|
|
return idsToCondition(ids);
|
|
}
|
|
|
|
async function resolveRefLeaf(rawToken, fallbackRefName) {
|
|
const token = rawToken.trim();
|
|
if (token === '') return NO_MATCH_CONDITION;
|
|
|
|
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.charAt(3) === ':') {
|
|
const afterColon = value.split(':')[1];
|
|
expression = afterColon != null ? afterColon.trim() : '';
|
|
}
|
|
|
|
const isDateField = looksLikeDateField(property);
|
|
const isObjectIdField = fieldKind.kind === 'objectId';
|
|
const tree = parseExpression(expression);
|
|
const condition = buildCondition(tree, filterProperty, isDateField, isBoolField, isObjectIdField);
|
|
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', 'marketplaceEvent'];
|
|
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,
|
|
...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;
|
|
}
|
|
}
|
|
console.log('filterDistributeKeys', result);
|
|
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`, 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 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 instanceof Date) {
|
|
return value;
|
|
} 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 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];
|
|
}
|
|
logger.info('queryExcludingSortAndOrder', queryClean);
|
|
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)) {
|
|
clauses.push(parse ? await 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,
|
|
createDocumentJobUserNotifier,
|
|
sendEmailNotification,
|
|
getFilter,
|
|
getSort,
|
|
convertPropertiesString,
|
|
getFileMeta,
|
|
modelHasRef,
|
|
getFieldsByRef,
|
|
jsonToCacheKey,
|
|
subscribeAuditLog,
|
|
unsubscribeAuditLog,
|
|
resolveAuditOwner,
|
|
actorDisplayName,
|
|
};
|