Add object view functionality with CRUD operations and schema integration
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

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.
This commit is contained in:
Tom Butcher 2026-09-03 03:18:12 +01:00
parent 850c721867
commit efcf1389a5
11 changed files with 306 additions and 7 deletions

View File

@ -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);
}

View File

@ -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 },

View File

@ -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);

View File

@ -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'),

View File

@ -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);

View File

@ -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,

View File

@ -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;

View File

@ -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,
};

View File

@ -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'],

View File

@ -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',
});
};

View File

@ -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 = {