Updated log level to trace in config, added OData routes, implemented HTTP Basic Auth for user authentication, and added functionality to set app password for users.

This commit is contained in:
Tom Butcher 2026-03-02 00:54:10 +00:00
parent ed25260d5b
commit eaa8bf4836
9 changed files with 618 additions and 1 deletions

164
src/database/odata.js Normal file
View File

@ -0,0 +1,164 @@
import log4js from 'log4js';
import mongoose from 'mongoose';
import config from '../config.js';
import { listObjects } from './database.js';
const logger = log4js.getLogger('DatabaseOData');
logger.level = config.server.logLevel;
const EXCLUDED_PATHS = ['appPasswordHash', '__v'];
/** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */
function isEmbeddedObject(path) {
if (!path) return false;
if (path.options?.type === mongoose.Schema.Types.ObjectId) return false;
if (path.instance === 'Array') return false;
return path.schema != null || path.caster?.schema != null;
}
/** Get all top-level property keys declared in OData metadata for a schema. */
function getDeclaredPropertyKeys(schema) {
if (!schema?.paths) return [];
return Object.keys(schema.paths)
.filter((k) => !k.includes('.'))
.filter((k) => !EXCLUDED_PATHS.includes(k))
.filter((k) => schema.paths[k]?.options?.select !== false);
}
/**
* Ensure all declared properties are present in each document. OData clients (e.g. Excel)
* expect every metadata property to exist; omit none. Use null for missing/invalid values.
*/
function normalizeODataResponse(data, schema) {
const declaredKeys = getDeclaredPropertyKeys(schema);
if (declaredKeys.length === 0) return data;
function normalize(doc) {
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc;
const result = { ...doc };
for (const key of declaredKeys) {
const val = result[key];
if (isEmbeddedObject(schema.paths[key])) {
// ComplexType: object or null only
const isValid = val != null && typeof val === 'object' && !Array.isArray(val);
result[key] = isValid ? val : null;
} else {
// Primitive, array, etc.: value or null (never omit)
result[key] = val !== undefined && val !== null ? val : null;
}
}
return result;
}
return Array.isArray(data) ? data.map(normalize) : normalize(data);
}
/**
* Convert expanded ObjectId refs ({ _id: x }) back to primitive strings for OData.
* OData metadata declares refs as Edm.String; clients expect primitives, not objects.
*/
function compactObjectIdsForOData(data) {
function isExpandedRef(val) {
return val && typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 1 && '_id' in val;
}
function compact(val) {
if (Array.isArray(val)) return val.map(compact);
if (isExpandedRef(val)) return val._id?.toString?.() ?? val._id;
if (val instanceof Date || val instanceof Buffer) return val; // pass through primitives
if (val && typeof val === 'object') {
const result = {};
for (const [k, v] of Object.entries(val)) result[k] = compact(v);
return result;
}
return val;
}
return Array.isArray(data) ? data.map(compact) : compact(data);
}
/**
* Translate filter keys ending with ._id for Mongoose compatibility
* @param {Object} filter - Filter object (will be mutated)
*/
function translateFilterKeys(filter) {
Object.keys(filter).forEach((key) => {
if (key.endsWith('._id')) {
const baseKey = key.slice(0, -4);
filter[baseKey] = filter[key];
delete filter[key];
}
});
}
/**
* List objects with OData-compatible response format.
* Works like listObjects but returns { @odata.context, @odata.count?, value }.
*
* @param {Object} options - Same options as listObjects, plus:
* @param {boolean} [options.count=false] - When true, include @odata.count (total before $skip/$top)
* @returns {Promise<Object>} OData-formatted response or { error, code } on failure
*/
export const listObjectsOData = async ({
model,
populate = [],
page = 1,
limit = 25,
filter = {},
sort = '',
order = 'ascend',
pagination = true,
project,
count = false,
}) => {
try {
const filterCopy = JSON.parse(JSON.stringify(filter));
translateFilterKeys(filterCopy);
const [data, total] = await Promise.all([
listObjects({
model,
populate,
page,
limit,
filter: filterCopy,
sort,
order,
pagination,
project,
}),
count ? model.countDocuments(filterCopy) : Promise.resolve(undefined),
]);
if (data?.error) {
return data;
}
// Strip __v from each document (Mongoose version key - causes OData client issues)
const sanitizedData = Array.isArray(data)
? data.map(({ __v, ...doc }) => doc)
: data;
// Convert expanded ObjectId refs ({ _id: x }) to primitive strings - OData clients expect Edm.String
let odataData = compactObjectIdsForOData(sanitizedData);
// Ensure all declared properties are present; use null for missing values (Excel requires this)
odataData = normalizeODataResponse(odataData, model.schema);
const baseUrl = config.app?.urlApi || '';
const modelName = model.modelName;
const context = `${baseUrl}/odata/$metadata#${modelName}`;
const response = {
'@odata.context': context,
value: odataData,
};
if (count && total !== undefined) {
response['@odata.count'] = total;
}
return response;
} catch (error) {
logger.error('OData list error:', error);
return { error, code: 500 };
}
};

View File

@ -10,6 +10,7 @@ const userSchema = new mongoose.Schema(
lastName: { required: false, type: String }, lastName: { required: false, type: String },
email: { required: true, type: String }, email: { required: true, type: String },
profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false }, profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
appPasswordHash: { type: String, required: false, select: false },
}, },
{ timestamps: true } { timestamps: true }
); );

View File

@ -42,6 +42,7 @@ import {
salesOrderRoutes, salesOrderRoutes,
userNotifierRoutes, userNotifierRoutes,
notificationRoutes, notificationRoutes,
odataRoutes,
} from './routes/index.js'; } from './routes/index.js';
import path from 'path'; import path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
@ -149,6 +150,7 @@ app.use('/salesorders', salesOrderRoutes);
app.use('/notes', noteRoutes); app.use('/notes', noteRoutes);
app.use('/usernotifiers', userNotifierRoutes); app.use('/usernotifiers', userNotifierRoutes);
app.use('/notifications', notificationRoutes); app.use('/notifications', notificationRoutes);
app.use('/odata', odataRoutes);
// Start the application // Start the application
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {

View File

@ -5,6 +5,7 @@
import config, { getEnvironment } from './config.js'; import config, { getEnvironment } from './config.js';
import log4js from 'log4js'; import log4js from 'log4js';
import NodeCache from 'node-cache'; import NodeCache from 'node-cache';
import bcrypt from 'bcrypt';
import { userModel } from './database/schemas/management/user.schema.js'; import { userModel } from './database/schemas/management/user.schema.js';
import { getObject } from './database/database.js'; import { getObject } from './database/database.js';
import { hostModel } from './database/schemas/management/host.schema.js'; import { hostModel } from './database/schemas/management/host.schema.js';
@ -50,7 +51,8 @@ const lookupUser = async (preferredUsername) => {
/** /**
* Middleware to check if the user is authenticated. * Middleware to check if the user is authenticated.
* Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer), 3) x-host-id + x-auth-code (host auth) * Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer),
* 3) HTTP Basic Auth (username + app password), 4) x-host-id + x-auth-code (host auth)
*/ */
const isAuthenticated = async (req, res, next) => { const isAuthenticated = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization; const authHeader = req.headers.authorization || req.headers.Authorization;
@ -77,6 +79,28 @@ const isAuthenticated = async (req, res, next) => {
} }
} }
// Try HTTP Basic Auth (username + app password)
if (authHeader && authHeader.startsWith('Basic ')) {
try {
logger.debug('Basic auth header:', authHeader);
const base64Credentials = authHeader.substring(6);
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const [username, password] = credentials.split(':');
logger.debug('Basic auth credentials:', { username, password });
if (username && password) {
const user = await userModel.findOne({ username }).select('+appPasswordHash');
if (user?.appPasswordHash && (await bcrypt.compare(password, user.appPasswordHash))) {
user.appPasswordHash = undefined; // don't expose hash downstream
req.user = user;
req.session = { user };
return next();
}
}
} catch (error) {
logger.error('Basic auth error:', error.message);
}
}
const hostId = req.headers['x-host-id']; const hostId = req.headers['x-host-id'];
const authCode = req.headers['x-auth-code']; const authCode = req.headers['x-auth-code'];
if (hostId && authCode) { if (hostId && authCode) {

View File

@ -36,6 +36,7 @@ import salesOrderRoutes from './sales/salesorders.js';
import noteRoutes from './misc/notes.js'; import noteRoutes from './misc/notes.js';
import userNotifierRoutes from './misc/usernotifiers.js'; import userNotifierRoutes from './misc/usernotifiers.js';
import notificationRoutes from './misc/notifications.js'; import notificationRoutes from './misc/notifications.js';
import odataRoutes from './misc/odata.js';
export { export {
userRoutes, userRoutes,
@ -76,4 +77,5 @@ export {
salesOrderRoutes, salesOrderRoutes,
userNotifierRoutes, userNotifierRoutes,
notificationRoutes, notificationRoutes,
odataRoutes,
}; };

View File

@ -10,6 +10,7 @@ import {
editUserRouteHandler, editUserRouteHandler,
getUserStatsRouteHandler, getUserStatsRouteHandler,
getUserHistoryRouteHandler, getUserHistoryRouteHandler,
setAppPasswordRouteHandler,
} from '../../services/management/users.js'; } from '../../services/management/users.js';
// list of document templates // list of document templates
@ -50,4 +51,8 @@ router.put('/:id', isAuthenticated, async (req, res) => {
editUserRouteHandler(req, res); editUserRouteHandler(req, res);
}); });
router.post('/:id/setAppPassword', isAuthenticated, async (req, res) => {
setAppPasswordRouteHandler(req, res);
});
export default router; export default router;

10
src/routes/misc/odata.js Normal file
View File

@ -0,0 +1,10 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import { listODataRouteHandler, metadataODataRouteHandler } from '../../services/misc/odata.js';
const router = express.Router();
router.get('/$metadata', isAuthenticated, metadataODataRouteHandler);
router.get('/:objectType', isAuthenticated, listODataRouteHandler);
export default router;

View File

@ -2,6 +2,8 @@ import config from '../../config.js';
import { userModel } from '../../database/schemas/management/user.schema.js'; import { userModel } from '../../database/schemas/management/user.schema.js';
import log4js from 'log4js'; import log4js from 'log4js';
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import bcrypt from 'bcrypt';
import { nanoid } from 'nanoid';
import { import {
listObjects, listObjects,
listObjectsByProperties, listObjectsByProperties,
@ -139,3 +141,50 @@ export const getUserHistoryRouteHandler = async (req, res) => {
logger.trace('User history:', result); logger.trace('User history:', result);
res.send(result); res.send(result);
}; };
export const setAppPasswordRouteHandler = async (req, res) => {
console.log(req.user);
if (req.user._id.toString() !== req.params.id) {
return res
.status(403)
.send({ error: 'You are not authorized to set the app password for this user.' });
}
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`Setting app password for user with ID: ${id}`);
const userResult = await getObject({
model: userModel,
id,
});
if (userResult?.error) {
logger.warn('User not found with supplied id.');
return res.status(userResult.code).send(userResult);
}
const appPassword = nanoid(32);
const appPasswordHash = await bcrypt.hash(appPassword, 10);
const updateData = {
updatedAt: new Date(),
appPasswordHash,
};
const result = await editObject({
model: userModel,
id,
updateData,
user: req.user,
});
if (result?.error) {
logger.error('Error setting app password:', result.error);
return res.status(result.code || 500).send(result);
}
logger.debug(`Set app password for user with ID: ${id}`);
res.send({ appPassword });
};

360
src/services/misc/odata.js Normal file
View File

@ -0,0 +1,360 @@
import config from '../../config.js';
import log4js from 'log4js';
import mongoose from 'mongoose';
import { getModelByName, getAllModels } from './model.js';
import { listObjectsOData } from '../../database/odata.js';
import { getFilter } from '../../utils.js';
const logger = log4js.getLogger('OData');
logger.level = config.server.logLevel;
const EXCLUDED_PATHS = ['appPasswordHash', '__v'];
function mongooseTypeToEdm(path) {
const instance = path?.instance;
if (instance === 'String') return 'Edm.String';
if (instance === 'Number') return 'Edm.Decimal';
if (instance === 'Date') return 'Edm.DateTimeOffset';
if (instance === 'Boolean') return 'Edm.Boolean';
if (instance === 'ObjectID') return 'Edm.String';
if (path?.options?.type === mongoose.Schema.Types.ObjectId) return 'Edm.String';
if (instance === 'Object' || instance === 'Mixed') return 'Edm.String'; // JSON-serialized
if (instance === 'Buffer') return 'Edm.Binary';
if (path?.options?.type === mongoose.Schema.Types.Mixed) return 'Edm.String';
return 'Edm.String';
}
/** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */
function isEmbeddedObject(path) {
if (!path) return false;
if (path.options?.type === mongoose.Schema.Types.ObjectId) return false;
if (path.instance === 'Array') return false;
return path.schema != null || path.caster?.schema != null;
}
/** Check if path is an array of subdocuments (embedded schemas). */
function isArrayOfSubdocuments(path) {
if (!path || path.instance !== 'Array') return false;
const schema = path.caster?.schema;
return schema != null && schema.paths != null;
}
/** Check if path is an array of ObjectIds. */
function isArrayOfObjectIds(path) {
if (!path || path.instance !== 'Array') return false;
return (
path.caster?.instance === 'ObjectID' ||
path.caster?.options?.type === mongoose.Schema.Types.ObjectId
);
}
/** Get nested properties from embedded schema for ComplexType */
function getEmbeddedProperties(path, parentPaths, parentKey) {
const schema = path?.schema ?? path?.caster?.schema;
if (schema?.paths) {
return Object.entries(schema.paths)
.filter(([key]) => !key.includes('.'))
.map(([key, p]) => ({ name: key, edmType: mongooseTypeToEdm(p) }));
}
// Fallback: derive from parent schema's dotted paths (e.g. state.type, state.message)
if (parentPaths && parentKey) {
const prefix = parentKey + '.';
return Object.entries(parentPaths)
.filter(([key]) => key.startsWith(prefix) && !key.slice(prefix.length).includes('.'))
.map(([key, p]) => ({ name: key.slice(prefix.length), edmType: mongooseTypeToEdm(p) }));
}
return [];
}
function escapeXml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Build OData 4.0 CSDL $metadata document from Mongoose models.
* Handles embedded objects (e.g. state) as ComplexTypes.
*/
function buildMetadataXml() {
const baseUrl = config.app?.urlApi || '';
const namespace = 'FarmControl';
const schema = [];
const complexTypes = new Map(); // pathKey -> { typeName, properties }
const entries = getAllModels().filter((e) => e.model != null);
// First pass: collect ComplexTypes from embedded paths and array-of-subdocument paths (merge props when same key)
for (const entry of entries) {
const paths = entry.model.schema.paths;
for (const [key, path] of Object.entries(paths)) {
if (EXCLUDED_PATHS.includes(key) || key.includes('.') || path.options?.select === false)
continue;
if (isEmbeddedObject(path)) {
const embeddedProps = getEmbeddedProperties(path, entry.model.schema.paths, key);
if (embeddedProps.length === 0) continue;
const typeName = key.charAt(0).toUpperCase() + key.slice(1) + 'Type';
const existing = complexTypes.get(key);
if (existing) {
const propNames = new Set(existing.properties.map((p) => p.name));
for (const p of embeddedProps) {
if (!propNames.has(p.name)) {
existing.properties.push(p);
propNames.add(p.name);
}
}
} else {
complexTypes.set(key, { typeName, properties: embeddedProps });
}
} else if (isArrayOfSubdocuments(path)) {
const embeddedProps = getEmbeddedProperties(path, entry.model.schema.paths, key);
if (embeddedProps.length === 0) continue;
const typeName = key.charAt(0).toUpperCase() + key.slice(1) + 'Type';
const existing = complexTypes.get(key);
if (existing) {
const propNames = new Set(existing.properties.map((p) => p.name));
for (const p of embeddedProps) {
if (!propNames.has(p.name)) {
existing.properties.push(p);
propNames.add(p.name);
}
}
} else {
complexTypes.set(key, { typeName, properties: embeddedProps });
}
}
}
}
// Second pass: Mongoose sometimes omits parent path when nested object has 'type' as first key
// (e.g. state: { type: String, progress: Number } yields state.type, state.progress but no state)
// Derive ComplexTypes from dotted paths where parent is not a top-level path
for (const entry of entries) {
const paths = entry.model.schema.paths;
const topLevelKeys = new Set(Object.keys(paths).filter((k) => !k.includes('.')));
for (const [key, path] of Object.entries(paths)) {
if (!key.includes('.') || path.options?.select === false) continue;
const dotIdx = key.indexOf('.');
const parentKey = key.slice(0, dotIdx);
const childKey = key.slice(dotIdx + 1);
if (childKey.includes('.')) continue; // only one level of nesting for this pass
if (topLevelKeys.has(parentKey)) continue; // parent exists, already handled
if (path.options?.type === mongoose.Schema.Types.ObjectId) continue; // skip ObjectId refs
const typeName = parentKey.charAt(0).toUpperCase() + parentKey.slice(1) + 'Type';
const prop = { name: childKey, edmType: mongooseTypeToEdm(path) };
const existing = complexTypes.get(parentKey);
if (existing) {
const propNames = new Set(existing.properties.map((p) => p.name));
if (!propNames.has(prop.name)) {
existing.properties.push(prop);
}
} else {
complexTypes.set(parentKey, { typeName, properties: [prop] });
}
}
}
// Emit ComplexType definitions
for (const [, { typeName, properties }] of complexTypes) {
const props = properties
.map((p) => ` <Property Name="${escapeXml(p.name)}" Type="${p.edmType}" Nullable="true"/>`)
.join('\n');
schema.push(` <ComplexType Name="${escapeXml(typeName)}">`);
schema.push(props);
schema.push(` </ComplexType>`);
}
// EntityTypes
for (const entry of entries) {
const modelName = entry.model.modelName;
const entityName = modelName.charAt(0).toUpperCase() + modelName.slice(1);
const paths = entry.model.schema.paths;
const topLevelKeys = new Set(Object.keys(paths).filter((k) => !k.includes('.')));
const properties = [];
const addedKeys = new Set();
for (const [key, path] of Object.entries(paths)) {
if (EXCLUDED_PATHS.includes(key) || key.includes('.') || path.options?.select === false)
continue;
addedKeys.add(key);
let edmType;
if (isEmbeddedObject(path)) {
const ct = complexTypes.get(key);
if (!ct) continue;
edmType = `${namespace}.${ct.typeName}`;
} else if (isArrayOfSubdocuments(path)) {
const ct = complexTypes.get(key);
if (!ct) continue;
edmType = `Collection(${namespace}.${ct.typeName})`;
} else if (isArrayOfObjectIds(path)) {
edmType = 'Collection(Edm.String)';
} else if (path.instance === 'Array' && path.caster) {
edmType = `Collection(${mongooseTypeToEdm(path.caster)})`;
} else {
edmType = mongooseTypeToEdm(path);
}
properties.push(` <Property Name="${escapeXml(key)}" Type="${edmType}" Nullable="true"/>`);
}
// Add properties for dotted-path-derived ComplexTypes (e.g. state from state.type, state.progress)
for (const [key, path] of Object.entries(paths)) {
if (!key.includes('.') || path?.options?.select === false) continue;
const parentKey = key.slice(0, key.indexOf('.'));
if (topLevelKeys.has(parentKey) || addedKeys.has(parentKey)) continue;
const ct = complexTypes.get(parentKey);
if (!ct) continue;
if (!addedKeys.has(parentKey)) {
addedKeys.add(parentKey);
properties.push(
` <Property Name="${escapeXml(parentKey)}" Type="${namespace}.${ct.typeName}" Nullable="true"/>`
);
}
}
if (properties.length === 0) continue;
schema.push(` <EntityType Name="${escapeXml(entityName)}" OpenType="true">`);
schema.push(` <Key><PropertyRef Name="_id"/></Key>`);
schema.push(properties.join('\n'));
schema.push(` </EntityType>`);
}
const entitySets = entries
.map((e) => {
const modelName = e.model.modelName;
const entityName = modelName.charAt(0).toUpperCase() + modelName.slice(1);
return ` <EntitySet Name="${escapeXml(modelName)}" EntityType="${namespace}.${escapeXml(entityName)}"/>`;
})
.filter(Boolean)
.join('\n');
return `<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="${namespace}" xmlns="http://docs.oasis-open.org/odata/ns/edm">
${schema.join('\n')}
<EntityContainer Name="Container">
${entitySets}
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>`;
}
/**
* Route handler for GET /odata/$metadata
* Returns OData 4.0 CSDL metadata document for Power BI / OData clients.
*/
export const metadataODataRouteHandler = (req, res) => {
const xml = buildMetadataXml();
res.set('OData-Version', '4.0');
res.set('Content-Type', 'application/xml');
res.send(xml);
};
/**
* Parse OData $orderby into sort and order.
* Supports "field asc", "field desc", or just "field" (defaults asc).
*/
function parseOrderBy(orderby) {
if (!orderby || typeof orderby !== 'string') {
return { sort: 'createdAt', order: 'ascend' };
}
const trimmed = orderby.trim();
const parts = trimmed.split(/\s+/);
const sort = parts[0] || 'createdAt';
const dir = (parts[1] || 'asc').toLowerCase();
const order = dir === 'desc' ? 'descend' : 'ascend';
return { sort, order };
}
/**
* Route handler for GET /odata/:objectType
* Supports OData query options: $top, $skip, $orderby, $count, $select
* and passes through allowed filters from query params.
*/
export const listODataRouteHandler = async (req, res) => {
const objectType = req.params.objectType;
const entry = getModelByName(objectType);
if (!entry?.model) {
logger.warn(`OData: unknown object type "${objectType}"`);
return res.status(404).send({ error: `Unknown object type: ${objectType}` });
}
const { $top, $skip, $orderby, $count, $select } = req.query;
const limit = $top ? Math.min(parseInt($top, 10) || 25, 1000) : 25;
const skip = $skip ? Math.max(0, parseInt($skip, 10) || 0) : 0;
const page = Math.floor(skip / limit) + 1;
const includeCount = $count === 'true' || $count === true;
const { sort, order } = parseOrderBy($orderby);
const rawProject = $select
? String($select)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join(' ') || undefined
: undefined;
// Always exclude __v (Mongoose version key) - causes OData client issues
const project = rawProject ? `${rawProject} -__v` : '-__v';
// Build filter from query params - allow common filter keys per model
const allowedFilters = getModelFilterFields(objectType);
const filter = getFilter(req.query, allowedFilters);
const result = await listObjectsOData({
model: entry.model,
populate: [],
page,
limit,
filter,
sort,
order,
pagination: true,
project,
count: includeCount,
});
if (result?.error) {
logger.error('OData list error:', result.error);
return res.status(result.code || 500).send(result);
}
res.set('OData-Version', '4.0');
res.set('Content-Type', 'application/json; odata.metadata=minimal');
res.send(result);
};
/**
* Return allowed filter fields for a given object type.
* Extends a base set with type-specific fields.
*/
function getModelFilterFields(objectType) {
const base = ['_id'];
const byType = {
note: ['parent._id', 'noteType', 'user'],
notification: ['user'],
userNotifier: ['user', 'object', 'objectType'],
printer: ['host'],
job: ['printer', 'gcodeFile'],
subJob: ['job'],
filamentStock: ['filament'],
partStock: ['part'],
purchaseOrder: ['vendor'],
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'shipment._id'],
shipment: ['order._id', 'orderType', 'courierService._id'],
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
stockAudit: ['filamentStock._id', 'partStock._id'],
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
documentTemplate: ['parent._id', 'documentSize._id'],
salesOrder: ['client'],
invoice: ['to._id', 'from._id', 'order._id', 'orderType'],
auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
};
const extra = byType[objectType] || [];
return [...base, ...extra];
}