farmcontrol-api/src/utils.js

1286 lines
42 KiB
JavaScript

import { mongoose } from 'mongoose';
import { auditLogModel } from './database/schemas/management/auditlog.schema.js';
import { notificationModel } from './database/schemas/misc/notification.schema.js';
import { userNotifierModel } from './database/schemas/misc/usernotifier.schema.js';
import { userModel } from './database/schemas/management/user.schema.js';
import exifr from 'exifr';
import { natsServer } from './database/nats.js';
import log4js from 'log4js';
import config from './config.js';
import crypto from 'crypto';
import canonicalize from 'canonical-json';
import { getModelByName } from './services/misc/model.js';
import { createEmailRenderAuthCode } from './services/misc/emailRenderAuth.js';
import { Worker } from 'worker_threads';
import path from 'path';
import { fileURLToPath } from 'url';
import { diffJson } from 'diff';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const logger = log4js.getLogger('Utils');
logger.level = config.server.logLevel;
// ---------------------------------------------------------------------------
// Filter expression parsing (Microsoft Dynamics NAV / Business Central style)
//
// Supported syntax (all compiled down to MongoDB query operators):
// = equal to 377 -> { field: 377 }
// <> not equal to <>0 -> { field: { $ne: 0 } }
// > greater than >1200 -> { field: { $gt: 1200 } }
// >= greater than or equal >=1200 -> { field: { $gte: 1200 } }
// < less than <1200 -> { field: { $lt: 1200 } }
// <= less than or equal <=1200 -> { field: { $lte: 1200 } }
// .. interval 1100..2100 -> { field: { $gte: 1100, $lte: 2100 } }
// open-ended intervals ..2500 / 23..
// | either / or 1200|1300 -> { field: { $in: [1200, 1300] } }
// & and <2000&>1000 -> { field: { $gt: 1000, $lt: 2000 } }
// ( ) grouping / precedence 30|(>=10&<=20)
// * any number of characters Co* / *Co / *Co*
// ? a single character Hans?n
// @ ignore case @location (text matching is case-insensitive)
//
// Date-typed fields (detected by name, e.g. *At / *Date / *Time) interpret bare
// values as calendar days/datetimes, e.g. "22" => the whole of day 22 of the
// current month/year, "22..23" => start of 22 through end of 23.
//
// Note: BC's space-delimited date shorthand is locale dependent; here numeric
// date operands are read day-first ("D M Y H Min S") with missing components
// filled from the current date and the interval boundary.
// ---------------------------------------------------------------------------
function buildWildcardRegexPattern(input) {
// Escape all regex special chars except * and ? (which we treat as wildcards)
const escaped = String(input).replace(/[.+^${}()|[\]\\]/g, '\\$&');
// * matches any run of characters, ? matches exactly one character
const withWildcards = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
// Anchor so that, without wildcards, this is an exact match
return `^${withWildcards}$`;
}
function looksLikeDateField(property) {
const last = String(property).split('.').pop();
return /[a-z](?:At|Date|Time)$/.test(last) || /^(?:date|time|datetime)$/i.test(last);
}
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) {
if (isDateField) {
const start = parseDateOperand(value, 'start');
const end = parseDateOperand(value, 'end');
if (start && end) return { op: { $gte: start, $lte: end } };
}
const lower = value.toLowerCase();
if (lower === 'true') return { value: true };
if (lower === 'false') return { value: false };
if (isObjectIdString(value)) return { value: new mongoose.Types.ObjectId(value) };
if (isNumeric(value)) return { value: Number(value) };
return { op: { $regex: buildWildcardRegexPattern(value), $options: 'i' } };
}
// Builds a comparison condition for a single operator.
function buildComparison(name, value, isDateField) {
if (name === 'eq') return buildEquality(value, isDateField);
if (name === 'ne') {
if (isDateField) {
const start = parseDateOperand(value, 'start');
const end = parseDateOperand(value, 'end');
if (start && end) return { op: { $not: { $gte: start, $lte: end } } };
}
if (/[*?]/.test(value)) {
return { op: { $not: { $regex: buildWildcardRegexPattern(value), $options: 'i' } } };
}
return { op: { $ne: coerceScalar(value) } };
}
// For dates, < and >= align to the start of the day, > and <= to the end.
const boundary = name === 'gt' || name === 'lte' ? 'end' : 'start';
return { op: { [`$${name}`]: coerceBoundary(value, isDateField, boundary) } };
}
// Parses a leaf token (range, comparison, or plain value) into a condition.
function parseLeafCondition(rawToken, isDateField) {
const token = rawToken.trim();
if (token === '') {
return { op: { $regex: '^$', $options: 'i' } };
}
// Interval: a..b, ..b, a..
const rangeIdx = token.indexOf('..');
if (rangeIdx !== -1) {
const lo = stripIgnoreCase(token.slice(0, rangeIdx).trim());
const hi = stripIgnoreCase(token.slice(rangeIdx + 2).trim());
const op = {};
if (lo !== '') op.$gte = coerceBoundary(lo, isDateField, 'start');
if (hi !== '') op.$lte = coerceBoundary(hi, isDateField, 'end');
// A bare ".." places no constraint on the field.
if (Object.keys(op).length === 0) return { query: {} };
return { op };
}
// Comparison operators, longest symbols first.
const operators = [
['<>', 'ne'],
['>=', 'gte'],
['<=', 'lte'],
['>', 'gt'],
['<', 'lt'],
['=', 'eq'],
];
for (const [symbol, name] of operators) {
if (token.startsWith(symbol)) {
return buildComparison(name, stripIgnoreCase(token.slice(symbol.length).trim()), isDateField);
}
}
return buildEquality(stripIgnoreCase(token), isDateField);
}
// Converts a condition descriptor into a MongoDB query object for `property`.
function conditionToQuery(cond, property) {
if (cond.query) return cond.query;
if (cond.op) return { [property]: cond.op };
return { [property]: cond.value };
}
function isOnlyRegex(op) {
return Object.keys(op).every((key) => key === '$regex' || key === '$options');
}
// Combines AND-ed conditions, merging operator objects on the same field where possible.
function combineAnd(children, property) {
if (children.some((child) => child.query)) {
return { query: { $and: children.map((child) => conditionToQuery(child, property)) } };
}
const merged = {};
for (const child of children) {
if (child.value !== undefined) {
merged.$eq = child.value;
} else if (child.op) {
Object.assign(merged, child.op);
}
}
return { op: merged };
}
// Combines OR-ed conditions, collapsing to $in or a single regex when possible.
function combineOr(children, property) {
if (children.every((child) => child.value !== undefined)) {
return { op: { $in: children.map((child) => child.value) } };
}
if (children.every((child) => child.op && isOnlyRegex(child.op))) {
const pattern = children.map((child) => child.op.$regex).join('|');
return { op: { $regex: pattern, $options: 'i' } };
}
return { query: { $or: children.map((child) => conditionToQuery(child, property)) } };
}
function buildCondition(node, property, isDateField) {
if (node.type === 'leaf') {
return parseLeafCondition(node.token, isDateField);
}
const children = node.items.map((item) => buildCondition(item, property, isDateField));
return node.type === 'and' ? combineAnd(children, property) : combineOr(children, property);
}
function parseFilter(property, value) {
// Normalize state filter to state.type for schemas with state: { type }
if (property === 'state') {
property = 'state.type';
}
if (value?._id !== undefined && value?._id !== null) {
return { [property]: { _id: new mongoose.Types.ObjectId(value._id) } };
}
// Non-string values (actual booleans, numbers, objects, etc.) pass through.
if (typeof value !== 'string') {
return { [property]: value };
}
let trimmed = value.trim();
if (trimmed.charAt(3) === ':') {
const afterColon = value.split(':')[1];
trimmed = afterColon != null ? afterColon.trim() : '';
}
const isDateField = looksLikeDateField(property);
const tree = parseExpression(trimmed);
const condition = buildCondition(tree, property, isDateField);
return conditionToQuery(condition, property);
}
function convertToCamelCase(obj) {
const result = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key];
// Convert the key to camelCase
let camelKey = key
// First handle special cases with spaces, brackets and other characters
.replace(/\s*\[.*?\]\s*/g, '') // Remove brackets and their contents
.replace(/\s+/g, ' ') // Normalize spaces
.trim()
// Split by common separators (space, underscore, hyphen)
.split(/[\s_-]/)
// Convert to camelCase
.map((word, index) => {
// Remove any non-alphanumeric characters
word = word.replace(/[^a-zA-Z0-9]/g, '');
// Lowercase first word, uppercase others
return index === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join('');
// Handle values that are objects recursively
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
result[camelKey] = convertToCamelCase(value);
} else {
result[camelKey] = value;
}
}
}
return result;
}
function extractGCodeConfigBlock(fileContent, useCamelCase = true) {
const configObject = {};
// Extract header information
const headerBlockRegex = /; HEADER_BLOCK_START([\s\S]*?)(?:; HEADER_BLOCK_END|$)/;
const headerBlockMatch = fileContent.match(headerBlockRegex);
if (headerBlockMatch && headerBlockMatch[1]) {
const headerLines = headerBlockMatch[1].split('\n');
headerLines.forEach((line) => {
const keyValueRegex = /^\s*;\s*([^:]+?):\s*(.*?)\s*$/;
const simpleValueRegex = /^\s*;\s*(.*?)\s*$/;
// Try key-value format first
let match = line.match(keyValueRegex);
if (match) {
const key = match[1].trim();
let value = match[2].trim();
// Try to convert value to appropriate type
if (!isNaN(value) && value !== '') {
value = Number(value);
}
configObject[key] = value;
} else {
// Try the simple format like "; generated by OrcaSlicer 2.1.1 on 2025-04-28 at 13:30:11"
match = line.match(simpleValueRegex);
if (match && match[1] && !match[1].includes('HEADER_BLOCK')) {
const text = match[1].trim();
// Extract slicer info
const slicerMatch = text.match(/generated by (.*?) on (.*?) at (.*?)$/);
if (slicerMatch) {
configObject['slicer'] = slicerMatch[1].trim();
configObject['date'] = slicerMatch[2].trim();
configObject['time'] = slicerMatch[3].trim();
} else {
// Just add as a general header entry if it doesn't match any specific pattern
const key = `header_${Object.keys(configObject).length}`;
configObject[key] = text;
}
}
}
});
}
// Extract thumbnail data
const thumbnailBlockRegex = /; THUMBNAIL_BLOCK_START([\s\S]*?)(?:; THUMBNAIL_BLOCK_END|$)/;
const thumbnailBlockMatch = fileContent.match(thumbnailBlockRegex);
if (thumbnailBlockMatch && thumbnailBlockMatch[1]) {
const thumbnailLines = thumbnailBlockMatch[1].split('\n');
let base64Data = '';
let thumbnailInfo = {};
thumbnailLines.forEach((line) => {
// Extract thumbnail dimensions and size from the line "thumbnail begin 640x640 27540"
const thumbnailHeaderRegex = /^\s*;\s*thumbnail begin (\d+)x(\d+) (\d+)/;
const match = line.match(thumbnailHeaderRegex);
if (match) {
thumbnailInfo.width = parseInt(match[1], 10);
thumbnailInfo.height = parseInt(match[2], 10);
thumbnailInfo.size = parseInt(match[3], 10);
} else if (line.trim().startsWith('; ') && !line.includes('THUMBNAIL_BLOCK')) {
// Collect base64 data (remove the leading semicolon and space and thumbnail end)
const dataLine = line.trim().substring(2);
if (dataLine && dataLine != 'thumbnail end') {
base64Data += dataLine;
}
}
});
// Add thumbnail data to config object
if (base64Data) {
configObject.thumbnail = {
data: base64Data,
...thumbnailInfo,
};
}
}
// Extract CONFIG_BLOCK
const configBlockRegex = /; CONFIG_BLOCK_START([\s\S]*?)(?:; CONFIG_BLOCK_END|$)/;
const configBlockMatch = fileContent.match(configBlockRegex);
if (configBlockMatch && configBlockMatch[1]) {
// Extract each config line
const configLines = configBlockMatch[1].split('\n');
// Process each line
configLines.forEach((line) => {
// Check if the line starts with a semicolon and has an equals sign
const configLineRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/;
const match = line.match(configLineRegex);
if (match) {
const key = match[1].trim();
let value = match[2].trim();
// Try to convert value to appropriate type
if (value === 'true' || value === 'false') {
value = value === 'true';
} else if (!isNaN(value) && value !== '') {
// Check if it's a number (but not a percentage)
if (!value.includes('%')) {
value = Number(value);
}
}
configObject[key] = value;
}
});
}
// Extract additional variables that appear after EXECUTABLE_BLOCK_END
const additionalVarsRegex =
/; EXECUTABLE_BLOCK_(?:START|END)([\s\S]*?)(?:; CONFIG_BLOCK_START|$)/i;
const additionalVarsMatch = fileContent.match(additionalVarsRegex);
if (additionalVarsMatch && additionalVarsMatch[1]) {
const additionalLines = additionalVarsMatch[1].split('\n');
additionalLines.forEach((line) => {
// Match both standard format and the special case for "total filament cost"
const varRegex =
/^\s*;\s*((?:filament used|filament cost|total filament used|total filament cost|total layers count|estimated printing time)[^=]*?)\s*=\s*(.*?)\s*$/;
const match = line.match(varRegex);
if (match) {
const key = match[1].replace(/\[([^\]]+)\]/g, '$1').trim();
let value = match[2].trim();
// Clean up values - remove units in brackets and handle special cases
if (key.includes('filament used')) {
// Extract just the numeric value, ignoring units in brackets
const numMatch = value.match(/(\d+\.\d+)/);
if (numMatch) {
value = parseFloat(numMatch[1]);
}
} else if (key.includes('filament cost')) {
// Extract just the numeric value
const numMatch = value.match(/(\d+\.\d+)/);
if (numMatch) {
value = parseFloat(numMatch[1]);
}
} else if (key.includes('total layers count')) {
value = parseInt(value, 10);
} else if (key.includes('estimated printing time')) {
// Keep as string but trim any additional whitespace
value = value.trim();
}
configObject[key] = value;
}
});
}
// Also extract extrusion width settings
const extrusionWidthRegex = /;\s*(.*?)\s*extrusion width\s*=\s*(.*?)mm/g;
let extrusionMatch;
while ((extrusionMatch = extrusionWidthRegex.exec(fileContent)) !== null) {
const settingName = extrusionMatch[1].trim();
const settingValue = parseFloat(extrusionMatch[2].trim());
configObject[`${settingName} extrusion width`] = settingValue;
}
// Extract additional parameters after CONFIG_BLOCK_END if they exist
const postConfigParams = /; CONFIG_BLOCK_END\s*\n([\s\S]*?)$/;
const postConfigMatch = fileContent.match(postConfigParams);
if (postConfigMatch && postConfigMatch[1]) {
const postConfigLines = postConfigMatch[1].split('\n');
postConfigLines.forEach((line) => {
// Match lines with format "; parameter_name = value"
const paramRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/;
const match = line.match(paramRegex);
if (match) {
const key = match[1].trim();
let value = match[2].trim();
// Try to convert value to appropriate type
if (value === 'true' || value === 'false') {
value = value === 'true';
} else if (!isNaN(value) && value !== '') {
// Check if it's a number (but not a percentage)
if (!value.includes('%')) {
value = Number(value);
}
}
// Add to config object if not already present
if (!configObject[key]) {
configObject[key] = value;
}
}
});
}
// Apply camelCase conversion if requested
return useCamelCase ? convertToCamelCase(configObject) : configObject;
}
const AUDIT_IGNORED_KEYS = ['createdAt', 'updatedAt', '_id'];
function isDiffableObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) && value !== null;
}
function isNumericValue(value) {
return (
typeof value === 'number' ||
(value !== null && value !== undefined && !isNaN(Number(value)) && value !== '')
);
}
function normalizeDiffValue(value) {
return isNumericValue(value) ? Number(value) : value;
}
function valuesDiffer(oldVal, newVal) {
return diffJson(
{ value: normalizeDiffValue(oldVal) },
{ value: normalizeDiffValue(newVal) }
).some((part) => part.added || part.removed);
}
function getChangedValues(oldObj, newObj, old = false) {
const changes = {};
const combinedObj = { ...oldObj, ...newObj };
// Check all keys in the new object
for (const key in combinedObj) {
// Skip if the key is _id or timestamps
if (AUDIT_IGNORED_KEYS.includes(key)) continue;
const oldVal = oldObj ? oldObj[key] : undefined;
const newVal = newObj ? newObj[key] : undefined;
// If both values are objects (but not arrays or null), recurse
if (isDiffableObject(oldVal) && isDiffableObject(newVal)) {
if (oldVal?._id || newVal?._id) {
if (valuesDiffer(oldVal?._id, newVal?._id)) {
changes[key] = old ? oldVal : newVal;
}
} else {
const nestedChanges = getChangedValues(oldVal, newVal, old);
if (Object.keys(nestedChanges).length > 0) {
changes[key] = nestedChanges;
}
}
} else {
if (valuesDiffer(oldVal, newVal)) {
// If the old value is different from the new value, include it
changes[key] = old ? oldVal : newVal;
}
}
}
return changes;
}
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier'];
const SENSITIVE_KEYS = ['secret'];
function omitSensitive(obj) {
if (obj == null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(omitSensitive);
const result = {};
for (const [key, value] of Object.entries(obj)) {
if (SENSITIVE_KEYS.includes(key)) continue;
result[key] = omitSensitive(value);
}
return result;
}
async function newAuditLog(newValue, parentId, parentType, user) {
if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return;
// Filter out createdAt, updatedAt, and sensitive fields from newValue
const filteredNewValue = omitSensitive({ ...newValue });
delete filteredNewValue.createdAt;
delete filteredNewValue.updatedAt;
const auditLog = new auditLogModel({
changes: {
new: filteredNewValue,
},
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
operation: 'new',
});
await auditLog.save();
await distributeNew(auditLog, 'auditLog');
}
async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return;
// Get only the changed values
const changedOldValues = omitSensitive(getChangedValues(oldValue, newValue, true));
const changedNewValues = omitSensitive(getChangedValues(oldValue, newValue, false));
// If no values changed, don't create an audit log
if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) {
return;
}
const auditLog = new auditLogModel({
changes: {
old: changedOldValues,
new: changedNewValues,
},
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
operation: 'edit',
});
await auditLog.save();
await distributeNew(auditLog, 'auditLog');
}
async function editNotification(oldValue, newValue, parentId, parentType, user) {
const model = getModelByName(parentType);
const objectName = oldValue?.name ?? newValue?.name ?? model?.label ?? parentType;
const changedOldValues = omitSensitive(getChangedValues(oldValue, newValue, true));
const changedNewValues = omitSensitive(getChangedValues(oldValue, newValue, false));
if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) {
return;
}
await notfiyObjectUserNotifiers(
parentId,
parentType,
`${objectName} edited by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
`The ${parentType} ${parentId} has been updated.`,
'editObject',
{
old: changedOldValues,
new: changedNewValues,
objectType: parentType,
object: { _id: String(parentId ?? '') },
user: {
_id: String(user?._id ?? ''),
firstName: user.firstName,
lastName: user.lastName,
},
}
);
}
async function deleteAuditLog(deleteValue, parentId, parentType, user) {
if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return;
const auditLog = new auditLogModel({
changes: {
old: omitSensitive(deleteValue),
},
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
operation: 'delete',
});
await auditLog.save();
await distributeNew(auditLog, 'auditLog');
}
async function deleteNotification(object, parentId, parentType, user) {
const model = getModelByName(parentType);
const objectName = object?.name || model?.label || parentType;
await notfiyObjectUserNotifiers(
parentId,
parentType,
`${objectName} deleted by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
`The ${parentType} ${parentId} has been deleted.`,
'deleteObject',
{
object: omitSensitive(object),
objectType: parentType,
object: { _id: parentId },
user: { _id: user._id, firstName: user.firstName, lastName: user.lastName },
}
);
}
async function newNoteNotification(note, user) {
const model = getModelByName(note.parentType);
const objectName = model?.label ?? note.parentType;
await notfiyObjectUserNotifiers(
note.parent,
note.parentType,
`New note added to ${objectName.toLowerCase()} by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
`A new note has been created.`,
'newNote',
{
objectType: note.parentType,
object: { _id: String(note.parent ?? '') },
note: note,
user: {
_id: String(user?._id ?? ''),
firstName: user?.firstName,
lastName: user?.lastName,
},
}
);
}
async function subscribeAuditLog(parentId, parentType, user) {
const auditLog = new auditLogModel({
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
operation: 'subscribe',
});
await auditLog.save();
await distributeNew(auditLog, 'auditLog');
return auditLog;
}
async function unsubscribeAuditLog(parentId, parentType, user) {
const auditLog = new auditLogModel({
parent: parentId,
parentType,
owner: user._id,
ownerType: 'user',
operation: 'unsubscribe',
});
await auditLog.save();
await distributeNew(auditLog, 'auditLog');
return auditLog;
}
async function getAuditLogs(idOrIds) {
if (Array.isArray(idOrIds)) {
return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner');
} else {
return auditLogModel.find({ parent: idOrIds }).populate('owner');
}
}
async function distributeUpdate(value, id, type) {
await natsServer.publish(`${type}s.${id}.object`, value);
}
async function distributeStats(value, type) {
await natsServer.publish(`${type}s.stats`, value);
}
async function distributeNew(value, type) {
await natsServer.publish(`${type}s.new`, value);
}
async function distributeDelete(value, type) {
await natsServer.publish(`${type}s.delete`, value);
}
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`, {
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`, {
type: 'childNew',
data: { parentId: id, parentType: model.modelName },
});
}
}
async function notfiyObjectUserNotifiers(id, objectType, title, message, type = 'info', metadata) {
const userNotifiers = await userNotifierModel.find({ object: id, objectType: objectType });
for (const userNotifier of userNotifiers) {
await createNotification(userNotifier.user._id, title, message, type, metadata);
if (userNotifier.email == true) {
await sendEmailNotification(userNotifier.user, title, message, type, metadata);
}
}
}
async function createNotification(user, title, message, type = 'info', metadata) {
const notification = new notificationModel({
user,
title,
message,
type,
metadata: omitSensitive(metadata ?? {}),
});
await notification.save();
const value = notification.toJSON ? notification.toJSON() : notification;
await natsServer.publish(`notifications.${user._id}`, value);
return notification;
}
let mailWorker = null;
function getMailWorker() {
if (!mailWorker) {
const workerPath = path.join(__dirname, 'mailworker.js');
mailWorker = new Worker(workerPath);
mailWorker.on('error', (err) => logger.error('MailWorker error:', err));
mailWorker.on('exit', (code) => {
if (code !== 0) logger.warn('MailWorker exited with code', code);
mailWorker = null;
});
}
return mailWorker;
}
/**
* Sends an email notification asynchronously via a worker thread.
* Renders a React template on the urlClient, captures HTML with Puppeteer, and emails the user.
* Accepts the same input as createNotification: user, title, message, type, metadata.
* Returns immediately; does not wait for the email to be sent.
* @param {ObjectId|Object} user - User ID or user object (must have email)
* @param {string} title - Notification title
* @param {string} message - Notification message
* @param {string} type - Notification type (info, editObject, deleteObject, error, success)
* @param {Object} metadata - Optional metadata object
*/
async function sendEmailNotification(user, title, message, type = 'info', metadata) {
let userDoc = user;
if (user && (mongoose.Types.ObjectId.isValid(user) || user._id)) {
const userId = user._id || user;
userDoc = await userModel.findById(userId).lean();
}
if (!userDoc?.email) {
logger.warn('sendEmailNotification: no email for user', user);
return null;
}
const smtpConfig = config.smtp;
if (!smtpConfig?.host) {
logger.warn('sendEmailNotification: SMTP not configured, skipping email');
return null;
}
const urlClient = config.app?.urlClient || 'http://localhost:3000';
const authCode = createEmailRenderAuthCode(userDoc);
const payload = {
email: userDoc.email,
title,
message,
type,
metadata: omitSensitive(metadata || {}),
createdAt: new Date(),
updatedAt: new Date(),
authCode,
smtpConfig: {
host: smtpConfig.host,
port: smtpConfig.port || 587,
secure: smtpConfig.secure || false,
auth: smtpConfig.auth?.user ? smtpConfig.auth : undefined,
from: smtpConfig.from || 'FarmControl <noreply@farmcontrol.app>',
},
urlClient,
};
try {
getMailWorker().postMessage(payload);
} catch (err) {
logger.error('sendEmailNotification: failed to post to worker', err.message);
}
return null;
}
function flatternObjectIds(object) {
if (!object || typeof object !== 'object') {
return object;
}
const result = {};
for (const [key, value] of Object.entries(object)) {
if (value && typeof value === 'object' && value._id) {
// If the value is an object with _id, convert to just the _id
result[key] = value._id;
} else {
// Keep primitive values as is
result[key] = value;
}
}
return result;
}
function expandObjectIds(input) {
const excludedFields = ['createdAt', 'updatedAt', 'name', '_id'];
// Helper to check if a value is an ObjectId or a 24-char hex string
function isObjectId(val) {
// Check for MongoDB ObjectId instance
if (val instanceof mongoose.Types.ObjectId) return true;
// Check for exactly 24 hex characters (no special characters)
if (typeof val === 'string' && /^[a-fA-F\d]{24}$/.test(val)) return true;
return false;
}
// Recursive function
function expand(value) {
if (Array.isArray(value)) {
return value.map(expand);
} else if (value && typeof value === 'object' && !(value instanceof mongoose.Types.ObjectId)) {
var result = {};
for (const [key, val] of Object.entries(value)) {
if (excludedFields.includes(key)) {
// Do not expand keys that are excluded
result[key] = val.toString();
} else if (isObjectId(val)) {
result[key] = { _id: val };
} else if (Array.isArray(val)) {
result[key] = val.map(expand);
} else if (val instanceof Date) {
result[key] = val;
} else if (val && typeof val === 'object') {
result[key] = expand(val);
} else {
result[key] = val;
}
}
return result;
} else if (isObjectId(value)) {
return { _id: value };
} else {
return value;
}
}
return expand(input);
}
// Merges per-field filter clauses, falling back to $and when clauses use
// logical operators or target the same field (so nothing is silently lost).
function mergeFilterClauses(clauses) {
const valid = clauses.filter((clause) => clause && Object.keys(clause).length > 0);
if (valid.length === 0) return {};
if (valid.length === 1) return valid[0];
const seen = new Set();
let needsAnd = false;
for (const clause of valid) {
for (const key of Object.keys(clause)) {
if (key === '$or' || key === '$and' || seen.has(key)) {
needsAnd = true;
}
seen.add(key);
}
}
return needsAnd ? { $and: valid } : Object.assign({}, ...valid);
}
// Returns a filter object based on allowed filters and req.query
function getFilter(query, allowedFilters, parse = true) {
const clauses = [];
for (const [key, value] of Object.entries(query)) {
if (allowedFilters.includes(key)) {
clauses.push(parse ? parseFilter(key, value) : { [key]: value });
}
}
console.log('clauses', clauses);
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,
distributeUpdate,
distributeStats,
distributeNew,
distributeDelete,
distributeChildUpdate,
distributeChildDelete,
distributeChildNew,
notfiyObjectUserNotifiers,
createNotification,
sendEmailNotification,
getFilter, // <-- add here
convertPropertiesString,
getFileMeta,
modelHasRef,
getFieldsByRef,
jsonToCacheKey,
subscribeAuditLog,
unsubscribeAuditLog,
};