Compare commits
No commits in common. "8ad3d3da5cdd39f2ff476ac3047d62f852754165" and "f23503863ce8d915a9e722c379e85ca1c99d2ba7" have entirely different histories.
8ad3d3da5c
...
f23503863c
10
config.json
10
config.json
@ -165,12 +165,12 @@
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"host": "mail.tombutcher.work",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"host": "localhost",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"auth": {
|
||||
"user": "farmcontrol",
|
||||
"pass": "XwV5u3jWufuo5E5U4N9hBHfNfwk28D7fNdFN"
|
||||
"user": "",
|
||||
"pass": ""
|
||||
},
|
||||
"from": "FarmControl <noreply@farmcontrol.app>"
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
"scripts": {
|
||||
"syncModelsWithWS": "node fcdev.js",
|
||||
"watch:schemas": "nodemon --config nodemon.schemas.json",
|
||||
"dev": "concurrently --kill-others --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"",
|
||||
"dev": "concurrently --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"",
|
||||
"dev:api": "nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js",
|
||||
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
||||
"seed": "node src/mongo/seedData.js",
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
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', 'secret', '__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 };
|
||||
}
|
||||
};
|
||||
@ -1,22 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { generateId } from '../../utils.js';
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const appPasswordSchema = new mongoose.Schema(
|
||||
{
|
||||
_reference: { type: String, default: () => generateId()() },
|
||||
name: { type: String, required: true },
|
||||
user: { type: Schema.Types.ObjectId, ref: 'user', required: true },
|
||||
active: { type: Boolean, required: true, default: true },
|
||||
secret: { type: String, required: true, select: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
appPasswordSchema.virtual('id').get(function () {
|
||||
return this._id;
|
||||
});
|
||||
|
||||
appPasswordSchema.set('toJSON', { virtuals: true });
|
||||
|
||||
export const appPasswordModel = mongoose.model('appPassword', appPasswordSchema);
|
||||
@ -10,7 +10,6 @@ const userSchema = new mongoose.Schema(
|
||||
lastName: { required: false, type: String },
|
||||
email: { required: true, type: String },
|
||||
profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false },
|
||||
appPasswordHash: { type: String, required: false, select: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@ -14,7 +14,6 @@ import { stockAuditModel } from './inventory/stockaudit.schema.js';
|
||||
import { partStockModel } from './inventory/partstock.schema.js';
|
||||
import { auditLogModel } from './management/auditlog.schema.js';
|
||||
import { userModel } from './management/user.schema.js';
|
||||
import { appPasswordModel } from './management/apppassword.schema.js';
|
||||
import { noteTypeModel } from './management/notetype.schema.js';
|
||||
import { noteModel } from './misc/note.schema.js';
|
||||
import { notificationModel } from './misc/notification.schema.js';
|
||||
@ -135,13 +134,6 @@ export const models = {
|
||||
referenceField: '_reference',
|
||||
label: 'User',
|
||||
},
|
||||
APP: {
|
||||
model: appPasswordModel,
|
||||
idField: '_id',
|
||||
type: 'appPassword',
|
||||
referenceField: '_reference',
|
||||
label: 'App Password',
|
||||
},
|
||||
NTY: {
|
||||
model: noteTypeModel,
|
||||
idField: '_id',
|
||||
|
||||
@ -6,7 +6,6 @@ import { dbConnect } from './database/mongo.js';
|
||||
import {
|
||||
authRoutes,
|
||||
userRoutes,
|
||||
appPasswordRoutes,
|
||||
fileRoutes,
|
||||
printerRoutes,
|
||||
jobRoutes,
|
||||
@ -43,7 +42,6 @@ import {
|
||||
salesOrderRoutes,
|
||||
userNotifierRoutes,
|
||||
notificationRoutes,
|
||||
odataRoutes,
|
||||
} from './routes/index.js';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
@ -115,7 +113,6 @@ app.get('/', function (req, res) {
|
||||
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/users', userRoutes);
|
||||
app.use('/apppasswords', appPasswordRoutes);
|
||||
app.use('/files', fileRoutes);
|
||||
app.use('/spotlight', spotlightRoutes);
|
||||
app.use('/printers', printerRoutes);
|
||||
@ -152,7 +149,6 @@ app.use('/salesorders', salesOrderRoutes);
|
||||
app.use('/notes', noteRoutes);
|
||||
app.use('/usernotifiers', userNotifierRoutes);
|
||||
app.use('/notifications', notificationRoutes);
|
||||
app.use('/odata', odataRoutes);
|
||||
|
||||
// Start the application
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
|
||||
@ -5,9 +5,7 @@
|
||||
import config, { getEnvironment } from './config.js';
|
||||
import log4js from 'log4js';
|
||||
import NodeCache from 'node-cache';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { userModel } from './database/schemas/management/user.schema.js';
|
||||
import { appPasswordModel } from './database/schemas/management/apppassword.schema.js';
|
||||
import { getObject } from './database/database.js';
|
||||
import { hostModel } from './database/schemas/management/host.schema.js';
|
||||
import { getSession, lookupUserByToken } from './services/misc/auth.js';
|
||||
@ -52,8 +50,7 @@ const lookupUser = async (preferredUsername) => {
|
||||
|
||||
/**
|
||||
* Middleware to check if the user is authenticated.
|
||||
* 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)
|
||||
* Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer), 3) x-host-id + x-auth-code (host auth)
|
||||
*/
|
||||
const isAuthenticated = async (req, res, next) => {
|
||||
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||
@ -92,41 +89,6 @@ const isAuthenticated = async (req, res, next) => {
|
||||
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
||||
};
|
||||
|
||||
const isAppAuthenticated = async (req, res, next) => {
|
||||
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||
// Try HTTP Basic Auth (username + app password secret)
|
||||
if (authHeader?.startsWith('Basic ')) {
|
||||
try {
|
||||
logger.debug('Basic auth header present');
|
||||
const base64Credentials = authHeader.substring(6);
|
||||
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
||||
const colonIndex = credentials.indexOf(':');
|
||||
const username = credentials.substring(0, colonIndex).trim();
|
||||
const secret = credentials.substring(colonIndex + 1).trim();
|
||||
if (username && secret) {
|
||||
const user = await userModel.findOne({ username });
|
||||
if (user) {
|
||||
const appPasswords = await appPasswordModel
|
||||
.find({ user: user._id, active: true })
|
||||
.select('+secret')
|
||||
.lean();
|
||||
for (const appPassword of appPasswords) {
|
||||
const storedHash = appPassword.secret;
|
||||
if (storedHash && (await bcrypt.compare(secret, storedHash))) {
|
||||
req.user = user;
|
||||
req.session = { user };
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Basic auth error:', error.message);
|
||||
}
|
||||
}
|
||||
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
||||
};
|
||||
|
||||
const clearUserCache = () => {
|
||||
userCache.flushAll();
|
||||
logger.info('User cache cleared');
|
||||
@ -143,7 +105,6 @@ const removeUserFromCache = (username) => {
|
||||
|
||||
export {
|
||||
isAuthenticated,
|
||||
isAppAuthenticated,
|
||||
lookupUser,
|
||||
clearUserCache,
|
||||
getUserCacheStats,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import userRoutes from './management/users.js';
|
||||
import appPasswordRoutes from './management/apppasswords.js';
|
||||
import fileRoutes from './management/files.js';
|
||||
import authRoutes from './misc/auth.js';
|
||||
import printerRoutes from './production/printers.js';
|
||||
@ -37,11 +36,9 @@ import salesOrderRoutes from './sales/salesorders.js';
|
||||
import noteRoutes from './misc/notes.js';
|
||||
import userNotifierRoutes from './misc/usernotifiers.js';
|
||||
import notificationRoutes from './misc/notifications.js';
|
||||
import odataRoutes from './misc/odata.js';
|
||||
|
||||
export {
|
||||
userRoutes,
|
||||
appPasswordRoutes,
|
||||
fileRoutes,
|
||||
authRoutes,
|
||||
printerRoutes,
|
||||
@ -79,5 +76,4 @@ export {
|
||||
salesOrderRoutes,
|
||||
userNotifierRoutes,
|
||||
notificationRoutes,
|
||||
odataRoutes,
|
||||
};
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import express from 'express';
|
||||
import { isAuthenticated } from '../../keycloak.js';
|
||||
import { getFilter, convertPropertiesString } from '../../utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
import {
|
||||
listAppPasswordsRouteHandler,
|
||||
getAppPasswordRouteHandler,
|
||||
editAppPasswordRouteHandler,
|
||||
newAppPasswordRouteHandler,
|
||||
deleteAppPasswordRouteHandler,
|
||||
listAppPasswordsByPropertiesRouteHandler,
|
||||
getAppPasswordStatsRouteHandler,
|
||||
getAppPasswordHistoryRouteHandler,
|
||||
regenerateSecretRouteHandler,
|
||||
} from '../../services/management/apppasswords.js';
|
||||
|
||||
router.get('/', isAuthenticated, (req, res) => {
|
||||
const { page, limit, property, search, sort, order } = req.query;
|
||||
const allowedFilters = ['_id', 'name', 'user', 'active', 'user._id'];
|
||||
const filter = getFilter(req.query, allowedFilters);
|
||||
listAppPasswordsRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||
});
|
||||
|
||||
router.get('/properties', isAuthenticated, (req, res) => {
|
||||
const properties = convertPropertiesString(req.query.properties);
|
||||
const allowedFilters = ['name', 'user', 'active'];
|
||||
const filter = getFilter(req.query, allowedFilters, false);
|
||||
const masterFilter = req.query.masterFilter ? JSON.parse(req.query.masterFilter) : {};
|
||||
listAppPasswordsByPropertiesRouteHandler(req, res, properties, filter, masterFilter);
|
||||
});
|
||||
|
||||
router.post('/', isAuthenticated, (req, res) => {
|
||||
newAppPasswordRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.get('/stats', isAuthenticated, (req, res) => {
|
||||
getAppPasswordStatsRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.get('/history', isAuthenticated, (req, res) => {
|
||||
getAppPasswordHistoryRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.post('/:id/regenerateSecret', isAuthenticated, async (req, res) => {
|
||||
regenerateSecretRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.get('/:id', isAuthenticated, (req, res) => {
|
||||
getAppPasswordRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.put('/:id', isAuthenticated, async (req, res) => {
|
||||
editAppPasswordRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.delete('/:id', isAuthenticated, async (req, res) => {
|
||||
deleteAppPasswordRouteHandler(req, res);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -10,7 +10,6 @@ import {
|
||||
editUserRouteHandler,
|
||||
getUserStatsRouteHandler,
|
||||
getUserHistoryRouteHandler,
|
||||
setAppPasswordRouteHandler,
|
||||
} from '../../services/management/users.js';
|
||||
|
||||
// list of document templates
|
||||
@ -51,8 +50,4 @@ router.put('/:id', isAuthenticated, async (req, res) => {
|
||||
editUserRouteHandler(req, res);
|
||||
});
|
||||
|
||||
router.post('/:id/setAppPassword', isAuthenticated, async (req, res) => {
|
||||
setAppPasswordRouteHandler(req, res);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import express from 'express';
|
||||
import { isAppAuthenticated } from '../../keycloak.js';
|
||||
import { listODataRouteHandler, metadataODataRouteHandler } from '../../services/misc/odata.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/$metadata', isAppAuthenticated, metadataODataRouteHandler);
|
||||
router.get('/:objectType', isAppAuthenticated, listODataRouteHandler);
|
||||
|
||||
export default router;
|
||||
@ -1,229 +0,0 @@
|
||||
import config from '../../config.js';
|
||||
import { appPasswordModel } from '../../database/schemas/management/apppassword.schema.js';
|
||||
import log4js from 'log4js';
|
||||
import mongoose from 'mongoose';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
listObjects,
|
||||
listObjectsByProperties,
|
||||
getObject,
|
||||
editObject,
|
||||
newObject,
|
||||
deleteObject,
|
||||
getModelStats,
|
||||
getModelHistory,
|
||||
} from '../../database/database.js';
|
||||
|
||||
const logger = log4js.getLogger('AppPasswords');
|
||||
logger.level = config.server.logLevel;
|
||||
|
||||
export const listAppPasswordsRouteHandler = async (
|
||||
req,
|
||||
res,
|
||||
page = 1,
|
||||
limit = 25,
|
||||
property = '',
|
||||
filter = {},
|
||||
search = '',
|
||||
sort = '',
|
||||
order = 'ascend'
|
||||
) => {
|
||||
const result = await listObjects({
|
||||
model: appPasswordModel,
|
||||
page,
|
||||
limit,
|
||||
property,
|
||||
filter,
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
populate: ['user'],
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
logger.error('Error listing app passwords.');
|
||||
res.status(result.code).send(result);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`List of app passwords (Page ${page}, Limit ${limit}). Count: ${result.length}`);
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
export const listAppPasswordsByPropertiesRouteHandler = async (
|
||||
req,
|
||||
res,
|
||||
properties = '',
|
||||
filter = {},
|
||||
masterFilter = {}
|
||||
) => {
|
||||
const result = await listObjectsByProperties({
|
||||
model: appPasswordModel,
|
||||
properties,
|
||||
filter,
|
||||
masterFilter,
|
||||
populate: ['user'],
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
logger.error('Error listing app passwords.');
|
||||
res.status(result.code).send(result);
|
||||
return;
|
||||
}
|
||||
result.forEach((item) => {
|
||||
item.secret = undefined;
|
||||
});
|
||||
|
||||
logger.debug(`List of app passwords. Count: ${result.length}`);
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
export const getAppPasswordRouteHandler = async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const result = await getObject({
|
||||
model: appPasswordModel,
|
||||
id,
|
||||
populate: ['user'],
|
||||
});
|
||||
if (result?.error) {
|
||||
logger.warn(`App password not found with supplied id.`);
|
||||
return res.status(result.code).send(result);
|
||||
}
|
||||
logger.debug(`Retrieved app password with ID: ${id}`);
|
||||
res.send({ ...result, secret: undefined });
|
||||
};
|
||||
|
||||
export const editAppPasswordRouteHandler = async (req, res) => {
|
||||
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||
|
||||
logger.trace(`App password with ID: ${id}`);
|
||||
|
||||
const updateData = {
|
||||
updatedAt: new Date(),
|
||||
name: req.body.name,
|
||||
active: req.body.active,
|
||||
};
|
||||
|
||||
if (req.body.secret) {
|
||||
updateData.secret = await bcrypt.hash(req.body.secret, 10);
|
||||
}
|
||||
|
||||
const result = await editObject({
|
||||
model: appPasswordModel,
|
||||
id,
|
||||
updateData,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
logger.error('Error editing app password:', result.error);
|
||||
return res.status(result.code).send(result);
|
||||
}
|
||||
|
||||
logger.debug(`Edited app password with ID: ${id}`);
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
export const newAppPasswordRouteHandler = async (req, res) => {
|
||||
const plainSecret = req.body.secret?.trim() || nanoid(32);
|
||||
const secretHash = await bcrypt.hash(plainSecret, 10);
|
||||
|
||||
const newData = {
|
||||
name: req.body.name,
|
||||
user: req.body.user,
|
||||
active: req.body.active ?? true,
|
||||
secret: secretHash,
|
||||
};
|
||||
|
||||
const result = await newObject({
|
||||
model: appPasswordModel,
|
||||
newData,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
logger.error('No app password created:', result.error);
|
||||
return res.status(result.code).send(result);
|
||||
}
|
||||
|
||||
logger.debug(`New app password with ID: ${result._id}`);
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
export const deleteAppPasswordRouteHandler = async (req, res) => {
|
||||
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||
|
||||
logger.trace(`App password with ID: ${id}`);
|
||||
|
||||
const result = await deleteObject({
|
||||
model: appPasswordModel,
|
||||
id,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
logger.error('No app password deleted:', result.error);
|
||||
return res.status(result.code).send(result);
|
||||
}
|
||||
|
||||
logger.debug(`Deleted app password with ID: ${id}`);
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
export const getAppPasswordStatsRouteHandler = async (req, res) => {
|
||||
const result = await getModelStats({ model: appPasswordModel });
|
||||
if (result?.error) {
|
||||
logger.error('Error fetching app password stats:', result.error);
|
||||
return res.status(result.code).send(result);
|
||||
}
|
||||
logger.trace('App password stats:', result);
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
export const getAppPasswordHistoryRouteHandler = async (req, res) => {
|
||||
const from = req.query.from;
|
||||
const to = req.query.to;
|
||||
const result = await getModelHistory({ model: appPasswordModel, from, to });
|
||||
if (result?.error) {
|
||||
logger.error('Error fetching app password history:', result.error);
|
||||
return res.status(result.code).send(result);
|
||||
}
|
||||
logger.trace('App password history:', result);
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
export const regenerateSecretRouteHandler = async (req, res) => {
|
||||
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||
|
||||
const appPasswordDoc = await appPasswordModel.findById(id).select('+secret').populate('user');
|
||||
if (!appPasswordDoc) {
|
||||
return res.status(404).send({ error: 'App password not found.' });
|
||||
}
|
||||
|
||||
const appPasswordUserId =
|
||||
appPasswordDoc.user?._id?.toString?.() ?? appPasswordDoc.user?.toString?.();
|
||||
if (appPasswordUserId !== req.user._id.toString()) {
|
||||
return res.status(403).send({
|
||||
error: 'You are not authorized to regenerate the secret for this app password.',
|
||||
});
|
||||
}
|
||||
|
||||
const plainSecret = nanoid(32);
|
||||
const secretHash = await bcrypt.hash(plainSecret, 10);
|
||||
|
||||
const result = await editObject({
|
||||
model: appPasswordModel,
|
||||
id,
|
||||
updateData: { secret: secretHash, updatedAt: new Date() },
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
logger.error('Error regenerating app password secret:', result.error);
|
||||
return res.status(result.code || 500).send(result);
|
||||
}
|
||||
|
||||
logger.debug(`Regenerated secret for app password with ID: ${id}`);
|
||||
res.send({ appPassword: plainSecret });
|
||||
};
|
||||
@ -2,8 +2,6 @@ import config from '../../config.js';
|
||||
import { userModel } from '../../database/schemas/management/user.schema.js';
|
||||
import log4js from 'log4js';
|
||||
import mongoose from 'mongoose';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
listObjects,
|
||||
listObjectsByProperties,
|
||||
@ -141,50 +139,3 @@ export const getUserHistoryRouteHandler = async (req, res) => {
|
||||
logger.trace('User history:', 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 });
|
||||
};
|
||||
|
||||
@ -1,361 +0,0 @@
|
||||
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', 'secret', '__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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'],
|
||||
appPassword: ['name', 'user', 'active'],
|
||||
};
|
||||
const extra = byType[objectType] || [];
|
||||
return [...base, ...extra];
|
||||
}
|
||||
32
src/utils.js
32
src/utils.js
@ -361,24 +361,12 @@ function getChangedValues(oldObj, newObj, old = false) {
|
||||
}
|
||||
|
||||
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 });
|
||||
// Filter out createdAt and updatedAt from newValue
|
||||
const filteredNewValue = { ...newValue };
|
||||
delete filteredNewValue.createdAt;
|
||||
delete filteredNewValue.updatedAt;
|
||||
const auditLog = new auditLogModel({
|
||||
@ -401,8 +389,8 @@ 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));
|
||||
const changedOldValues = getChangedValues(oldValue, newValue, true);
|
||||
const changedNewValues = 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) {
|
||||
@ -429,8 +417,8 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) {
|
||||
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));
|
||||
const changedOldValues = getChangedValues(oldValue, newValue, true);
|
||||
const changedNewValues = getChangedValues(oldValue, newValue, false);
|
||||
|
||||
if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) {
|
||||
return;
|
||||
@ -461,7 +449,7 @@ async function deleteAuditLog(deleteValue, parentId, parentType, user) {
|
||||
|
||||
const auditLog = new auditLogModel({
|
||||
changes: {
|
||||
old: omitSensitive(deleteValue),
|
||||
old: deleteValue,
|
||||
},
|
||||
parent: parentId,
|
||||
parentType,
|
||||
@ -485,7 +473,7 @@ async function deleteNotification(object, parentId, parentType, user) {
|
||||
`The ${parentType} ${parentId} has been deleted.`,
|
||||
'deleteObject',
|
||||
{
|
||||
object: omitSensitive(object),
|
||||
object: object,
|
||||
objectType: parentType,
|
||||
object: { _id: parentId },
|
||||
user: { _id: user._id, firstName: user.firstName, lastName: user.lastName },
|
||||
@ -611,7 +599,7 @@ async function createNotification(user, title, message, type = 'info', metadata)
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
metadata: omitSensitive(metadata ?? {}),
|
||||
metadata,
|
||||
});
|
||||
await notification.save();
|
||||
const value = notification.toJSON ? notification.toJSON() : notification;
|
||||
@ -670,7 +658,7 @@ async function sendEmailNotification(user, title, message, type = 'info', metada
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
metadata: omitSensitive(metadata || {}),
|
||||
metadata: metadata || {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
authCode,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user