From efcf1389a5fd1459e5d22dfd8ccf89ecb1bc117a Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Thu, 3 Sep 2026 03:18:12 +0100 Subject: [PATCH] Add object view functionality with CRUD operations and schema integration This commit introduces a new `objectView` schema and corresponding routes for managing object views, enhancing the application's data handling capabilities. It includes CRUD operations for object views, allowing users to create, retrieve, update, and delete views based on specific object types. The implementation also updates existing services and routes to integrate the new functionality, ensuring proper authentication and data validation. Additionally, the commit modifies relevant utility functions and filters to accommodate the new object view model, improving overall application structure and maintainability. --- src/database/database.js | 6 +- .../management/documentprinter.schema.js | 8 + .../schemas/misc/objectview.schema.js | 58 ++++++ src/database/schemas/models.js | 2 + src/index.js | 2 + src/routes/index.js | 2 + src/routes/misc/objectviews.js | 34 ++++ src/services/management/documentprinters.js | 12 +- src/services/misc/export.js | 1 + src/services/misc/objectviews.js | 186 ++++++++++++++++++ src/utils.js | 2 +- 11 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 src/database/schemas/misc/objectview.schema.js create mode 100644 src/routes/misc/objectviews.js create mode 100644 src/services/misc/objectviews.js diff --git a/src/database/database.js b/src/database/database.js index ed789ca..31c606b 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -1242,7 +1242,8 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc if ( parentType !== 'notification' && parentType !== 'auditLog' && - parentType !== 'userNotifier' + parentType !== 'userNotifier' && + parentType !== 'objectView' ) { await editNotification(previousExpandedObject, updatedObject, id, parentType, user); } @@ -1600,7 +1601,8 @@ export const deleteObject = async ( if ( parentType !== 'notification' && parentType !== 'auditLog' && - parentType !== 'userNotifier' + parentType !== 'userNotifier' && + parentType !== 'objectView' ) { await deleteNotification(deleted, id.toString(), parentType, user); } diff --git a/src/database/schemas/management/documentprinter.schema.js b/src/database/schemas/management/documentprinter.schema.js index 09d9be0..70f1325 100644 --- a/src/database/schemas/management/documentprinter.schema.js +++ b/src/database/schemas/management/documentprinter.schema.js @@ -8,6 +8,8 @@ const connectionSchema = new Schema( protocol: { type: String, required: true }, host: { type: String, required: true }, port: { type: Number, required: false }, + username: { type: String, required: false }, + password: { type: String, required: false }, }, { _id: false } ); @@ -22,6 +24,8 @@ const documentPrinterSchema = new Schema( }, connection: { type: connectionSchema, required: true }, currentDocumentSize: { type: Schema.Types.ObjectId, ref: 'documentSize', required: false }, + supportedDocumentSizes: [{ type: Schema.Types.ObjectId, ref: 'documentSize', required: false }], + rotateOrientation: { type: Boolean, required: false, default: false }, tags: [{ type: String }], online: { type: Boolean, required: true, default: false }, active: { type: Boolean, required: true, default: true }, @@ -30,6 +34,10 @@ const documentPrinterSchema = new Schema( message: { type: String, required: false }, progress: { type: Number, required: false }, }, + paperState: { + type: { type: String, required: true, default: 'unknown' }, + message: { type: String, required: false }, + }, connectedAt: { type: Date, default: null }, host: { type: Schema.Types.ObjectId, ref: 'host', required: true }, vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false }, diff --git a/src/database/schemas/misc/objectview.schema.js b/src/database/schemas/misc/objectview.schema.js new file mode 100644 index 0000000..4a99201 --- /dev/null +++ b/src/database/schemas/misc/objectview.schema.js @@ -0,0 +1,58 @@ +import mongoose from 'mongoose'; +const { Schema } = mongoose; + +const objectViewSchema = new mongoose.Schema({ + user: { + type: Schema.Types.ObjectId, + ref: 'user', + required: true, + }, + objectType: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + color: { + type: String, + required: true, + default: '#3498DB', + }, + private: { + type: Boolean, + required: true, + default: true, + }, + filter: { + type: Schema.Types.Mixed, + default: () => ({}), + }, + sort: { + type: Schema.Types.Mixed, + default: () => ({}), + }, + viewMode: { + type: Schema.Types.Mixed, + default: null, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + updatedAt: { + type: Date, + required: true, + default: Date.now, + }, +}); + +objectViewSchema.virtual('id').get(function () { + return this._id; +}); + +objectViewSchema.set('toJSON', { virtuals: true }); + +export const objectViewModel = mongoose.model('objectView', objectViewSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 2cb8d25..9c67b04 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -32,6 +32,7 @@ import { noteTypeModel } from './management/notetype.schema.js'; import { noteModel } from './misc/note.schema.js'; import { notificationModel } from './misc/notification.schema.js'; import { userNotifierModel } from './misc/usernotifier.schema.js'; +import { objectViewModel } from './misc/objectview.schema.js'; import { documentSizeModel } from './management/documentsize.schema.js'; import { documentTemplateModel } from './management/documenttemplate.schema.js'; import { hostModel } from './management/host.schema.js'; @@ -103,6 +104,7 @@ export const models = { NTE: modelEntry(() => noteModel, 'note', 'Note'), NTF: modelEntry(() => notificationModel, 'notification', 'Notification'), ONF: modelEntry(() => userNotifierModel, 'userNotifier', 'User Notifier'), + OVW: modelEntry(() => objectViewModel, 'objectView', 'Object View'), DSZ: modelEntry(() => documentSizeModel, 'documentSize', 'Document Size'), DTP: modelEntry(() => documentTemplateModel, 'documentTemplate', 'Document Template'), DPR: modelEntry(() => documentPrinterModel, 'documentPrinter', 'Document Printer'), diff --git a/src/index.js b/src/index.js index 9b1ba9d..1a0a62e 100644 --- a/src/index.js +++ b/src/index.js @@ -61,6 +61,7 @@ import { returnPolicyRoutes, paymentPolicyRoutes, userNotifierRoutes, + objectViewRoutes, notificationRoutes, odataRoutes, rssRoutes, @@ -230,6 +231,7 @@ app.use('/returnpolicies', returnPolicyRoutes); app.use('/paymentpolicies', paymentPolicyRoutes); app.use('/notes', noteRoutes); app.use('/usernotifiers', userNotifierRoutes); +app.use('/objectviews', objectViewRoutes); app.use('/notifications', notificationRoutes); app.use('/odata', odataRoutes); app.use('/rss', rssRoutes); diff --git a/src/routes/index.js b/src/routes/index.js index 0dff857..8637c31 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -54,6 +54,7 @@ import returnPolicyRoutes from './sales/returnpolicies.js'; import paymentPolicyRoutes from './finance/paymentpolicies.js'; import noteRoutes from './misc/notes.js'; import userNotifierRoutes from './misc/usernotifiers.js'; +import objectViewRoutes from './misc/objectviews.js'; import notificationRoutes from './misc/notifications.js'; import odataRoutes from './misc/odata.js'; import rssRoutes from './misc/rss.js'; @@ -121,6 +122,7 @@ export { returnPolicyRoutes, paymentPolicyRoutes, userNotifierRoutes, + objectViewRoutes, notificationRoutes, odataRoutes, rssRoutes, diff --git a/src/routes/misc/objectviews.js b/src/routes/misc/objectviews.js new file mode 100644 index 0000000..841ad41 --- /dev/null +++ b/src/routes/misc/objectviews.js @@ -0,0 +1,34 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { + listObjectViewsRouteHandler, + getObjectViewRouteHandler, + newObjectViewRouteHandler, + deleteObjectViewRouteHandler, + editObjectViewRouteHandler, +} from '../../services/misc/objectviews.js'; + +const router = express.Router(); + +router.get('/', isAuthenticated, async (req, res) => { + const { page, limit, objectType } = req.query; + listObjectViewsRouteHandler(req, res, page, limit, objectType); +}); + +router.post('/', isAuthenticated, async (req, res) => { + newObjectViewRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, async (req, res) => { + getObjectViewRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editObjectViewRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteObjectViewRouteHandler(req, res); +}); + +export default router; diff --git a/src/services/management/documentprinters.js b/src/services/management/documentprinters.js index 9146b5f..7fe5509 100644 --- a/src/services/management/documentprinters.js +++ b/src/services/management/documentprinters.js @@ -38,7 +38,7 @@ export const listDocumentPrintersRouteHandler = async ( search, sort, order, - populate: ['currentDocumentSize', 'host'], + populate: ['currentDocumentSize', 'supportedDocumentSizes', 'host'], }); if (result?.error) { @@ -65,7 +65,7 @@ export const listDocumentPrintersByPropertiesRouteHandler = async ( properties, filter, masterFilter, - populate: ['currentDocumentSize', 'host'], + populate: ['currentDocumentSize', 'supportedDocumentSizes', 'host'], }); if (result?.error) { @@ -106,7 +106,7 @@ export const getDocumentPrinterRouteHandler = async (req, res) => { const result = await getObject({ model: documentPrinterModel, id, - populate: ['currentDocumentSize', 'host'], + populate: ['currentDocumentSize', 'supportedDocumentSizes', 'host'], }); if (result?.error) { logger.warn(`Document Template not found with supplied id.`); @@ -129,6 +129,8 @@ export const editDocumentPrinterRouteHandler = async (req, res) => { active: req.body.active, connection: req.body.connection, currentDocumentSize: req.body.currentDocumentSize, + supportedDocumentSizes: req.body.supportedDocumentSizes, + rotateOrientation: req.body.rotateOrientation, host: req.body.host, vendor: req.body.vendor, }; @@ -138,7 +140,7 @@ export const editDocumentPrinterRouteHandler = async (req, res) => { id, updateData, user: req.user, - populate: ['currentDocumentSize', 'host'], + populate: ['currentDocumentSize', 'supportedDocumentSizes', 'host'], }); if (result.error) { @@ -160,6 +162,8 @@ export const newDocumentPrinterRouteHandler = async (req, res) => { active: req.body.active, connection: req.body.connection, currentDocumentSize: req.body.currentDocumentSize, + supportedDocumentSizes: req.body.supportedDocumentSizes, + rotateOrientation: req.body.rotateOrientation, host: req.body.host, vendor: req.body.vendor, }; diff --git a/src/services/misc/export.js b/src/services/misc/export.js index 77184d2..e0b8b45 100644 --- a/src/services/misc/export.js +++ b/src/services/misc/export.js @@ -8,6 +8,7 @@ export const EXPORT_FILTER_BY_TYPE = { note: ['parent._id', 'noteType', 'user'], notification: ['user'], userNotifier: ['user', 'object', 'objectType'], + objectView: ['user', 'objectType', 'private'], printer: ['host'], job: ['printer', 'gcodeFile'], subJob: ['job'], diff --git a/src/services/misc/objectviews.js b/src/services/misc/objectviews.js new file mode 100644 index 0000000..ab2e16e --- /dev/null +++ b/src/services/misc/objectviews.js @@ -0,0 +1,186 @@ +import config from '../../config.js'; +import { objectViewModel } from '../../database/schemas/misc/objectview.schema.js'; +import log4js from 'log4js'; +import { + deleteObject, + editObject, + getObject, + newObject, +} from '../../database/database.js'; + +const logger = log4js.getLogger('ObjectViews'); +logger.level = config.server.logLevel; + +const canModifyObjectView = (existing, user) => { + if (existing?.private === false) { + return true; + } + return String(existing?.user?._id ?? existing?.user) === String(user._id); +}; + +export const listObjectViewsRouteHandler = async ( + req, + res, + page = 1, + limit = 100, + objectType = '' +) => { + if (!objectType) { + return res.status(400).send({ error: 'objectType is required', code: 400 }); + } + + const query = { + objectType, + $or: [{ private: false }, { user: req.user._id }], + }; + + const skip = (Math.max(parseInt(page, 10) || 1, 1) - 1) * (parseInt(limit, 10) || 100); + const parsedLimit = parseInt(limit, 10) || 100; + + try { + const [items, totalCount] = await Promise.all([ + objectViewModel + .find(query) + .populate('user') + .sort({ createdAt: 1 }) + .skip(skip) + .limit(parsedLimit) + .lean({ virtuals: true }), + objectViewModel.countDocuments(query), + ]); + + logger.debug( + `List of object views for ${objectType} (Page ${page}, Limit ${parsedLimit}). Count: ${items.length}` + ); + res.set('X-Total-Count', String(totalCount)); + res.send(items); + } catch (error) { + logger.error('Error listing object views.', error); + res.status(500).send({ error: error.message, code: 500 }); + } +}; + +export const getObjectViewRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: objectViewModel, + id, + populate: ['user'], + }); + if (result?.error) { + logger.warn(`Object view not found with supplied id.`); + return res.status(result.code).send(result); + } + + const isVisible = + result.private === false || + String(result.user?._id ?? result.user) === String(req.user._id); + if (!isVisible) { + return res.status(403).send({ error: 'Forbidden', code: 403 }); + } + + logger.debug(`Retrieved object view with ID: ${id}`); + res.send(result); +}; + +export const newObjectViewRouteHandler = async (req, res) => { + const newData = { + user: req.user._id, + objectType: req.body.objectType, + name: req.body.name || 'New View', + color: req.body.color || '#3498DB', + private: req.body.private !== false, + filter: req.body.filter || {}, + sort: req.body.sort || {}, + viewMode: req.body.viewMode ?? null, + }; + + if (!newData.objectType) { + return res.status(400).send({ error: 'objectType is required', code: 400 }); + } + + const result = await newObject({ + model: objectViewModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No object view created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New object view with ID: ${result._id}`); + res.send(result); +}; + +export const editObjectViewRouteHandler = async (req, res) => { + const id = req.params.id; + + const existing = await getObject({ + model: objectViewModel, + id, + }); + if (existing?.error) { + return res.status(existing.code).send(existing); + } + if (!canModifyObjectView(existing, req.user)) { + return res.status(403).send({ error: 'Forbidden: you cannot edit this view', code: 403 }); + } + + const updateData = { + updatedAt: new Date(), + }; + if (req.body.name !== undefined) updateData.name = req.body.name; + if (req.body.color !== undefined) updateData.color = req.body.color; + if (req.body.private !== undefined) updateData.private = req.body.private; + if (req.body.filter !== undefined) updateData.filter = req.body.filter; + if (req.body.sort !== undefined) updateData.sort = req.body.sort; + if (req.body.viewMode !== undefined) updateData.viewMode = req.body.viewMode; + + const result = await editObject({ + model: objectViewModel, + id, + updateData, + user: req.user, + populate: ['user'], + }); + + if (result.error) { + logger.error('Error editing object view:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Edited object view with ID: ${id}`); + res.send(result); +}; + +export const deleteObjectViewRouteHandler = async (req, res) => { + const id = req.params.id; + + const existing = await getObject({ + model: objectViewModel, + id, + }); + if (existing?.error) { + return res.status(existing.code).send(existing); + } + if (!canModifyObjectView(existing, req.user)) { + return res.status(403).send({ error: 'Forbidden: you cannot delete this view', code: 403 }); + } + + const result = await deleteObject({ + model: objectViewModel, + id, + user: req.user, + }); + + if (result?.error) { + logger.error('No object view deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.info(`Successfully deleted object view ${id}`); + res.send({ + status: 'ok', + }); +}; diff --git a/src/utils.js b/src/utils.js index d054547..c8b6c69 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1186,7 +1186,7 @@ function getChangedValues(oldObj, newObj, old = false) { return changes; } -const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier', 'marketplaceEvent']; +const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier', 'objectView', 'marketplaceEvent']; const SENSITIVE_KEYS = ['secret']; const DISTRIBUTE_KEYS = {