From 1c51766da63bd29b46a63f9f124bdb4010a2a669 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Thu, 20 Aug 2026 15:04:52 +0100 Subject: [PATCH] Add user group management functionality and refactor permission settings This commit introduces a new feature for managing user groups within the application, including the creation of a `usergroup.schema.js` file for the Mongoose schema and corresponding route handlers for CRUD operations. Additionally, the permission settings functionality has been refactored to use a singular `permissionSetting` model instead of the previous plural form. Tests have been added to ensure the functionality of both user groups and permission settings services, enhancing the application's capability to manage user roles and permissions effectively. --- src/database/__tests__/permissions.test.js | 27 ++ src/database/database.js | 7 +- src/database/permissions.js | 24 ++ .../management/permissionsetting.schema.js | 62 +++++ .../management/permissionsettings.schema.js | 59 ----- .../schemas/management/user.schema.js | 33 +-- .../schemas/management/usergroup.schema.js | 74 ++++++ src/database/schemas/models.js | 16 +- src/index.js | 2 + src/routes/index.js | 4 +- ...issionsettings.js => permissionsetting.js} | 2 +- src/routes/management/usergroups.js | 99 ++++++++ src/routes/management/users.js | 1 + ...ings.test.js => permissionsetting.test.js} | 14 +- .../management/__tests__/usergroups.test.js | 130 ++++++++++ ...issionsettings.js => permissionsetting.js} | 26 +- src/services/management/usergroups.js | 240 ++++++++++++++++++ src/services/management/users.js | 5 +- 18 files changed, 717 insertions(+), 108 deletions(-) create mode 100644 src/database/schemas/management/permissionsetting.schema.js delete mode 100644 src/database/schemas/management/permissionsettings.schema.js create mode 100644 src/database/schemas/management/usergroup.schema.js rename src/routes/management/{permissionsettings.js => permissionsetting.js} (98%) create mode 100644 src/routes/management/usergroups.js rename src/services/management/__tests__/{permissionsettings.test.js => permissionsetting.test.js} (89%) create mode 100644 src/services/management/__tests__/usergroups.test.js rename src/services/management/{permissionsettings.js => permissionsetting.js} (89%) create mode 100644 src/services/management/usergroups.js diff --git a/src/database/__tests__/permissions.test.js b/src/database/__tests__/permissions.test.js index 75b6a57..61e1471 100644 --- a/src/database/__tests__/permissions.test.js +++ b/src/database/__tests__/permissions.test.js @@ -26,4 +26,31 @@ describe('applyPermissionSettingsList', () => { it('returns an empty object when nothing is explicitly set', () => { expect(applyPermissionSettingsList([{ permissions: { user: { info: null } } }])).toEqual({}); }); + + it('applies group permissions first, then user permission settings', () => { + const result = applyPermissionSettingsList([ + { + permissions: { + user: { info: true, edit: false }, + printer: { info: true }, + }, + }, + { + permissions: { + user: { delete: true }, + }, + }, + { + permissions: { + user: { edit: true }, + printer: { info: false }, + }, + }, + ]); + + expect(result).toEqual({ + user: { info: true, edit: true, delete: true }, + printer: { info: false }, + }); + }); }); diff --git a/src/database/database.js b/src/database/database.js index 576541f..12cf402 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -45,10 +45,13 @@ const NEIGHBORS_CACHE_WINDOW = 25; const NEIGHBORS_CACHE_PREFIX = 'neighbors'; const mergeObjectUpdates = (target, source) => - _.mergeWith(target, source, (objValue, srcValue) => { + _.mergeWith(target, source, (objValue, srcValue, key) => { if (Array.isArray(objValue) || Array.isArray(srcValue)) { return srcValue; } + if (key === 'permissions' && srcValue !== undefined) { + return srcValue; + } }); export const retrieveObjectCache = async ({ model, id, populate = [] }) => { @@ -1021,7 +1024,7 @@ export const getObject = async ({ model, id, populate }) => { return { error: 'Object not found.', code: 404 }; } - const expanded = _.merge(cachedObject || {}, expandObjectIds(result)); + const expanded = mergeObjectUpdates(cachedObject || {}, expandObjectIds(result)); // Update cache with the expanded object await updateObjectCache({ diff --git a/src/database/permissions.js b/src/database/permissions.js index 73d5b4a..45f30e1 100644 --- a/src/database/permissions.js +++ b/src/database/permissions.js @@ -1,3 +1,5 @@ +import mongoose from 'mongoose'; + export const applyPermissionSettingsList = (settingsList = []) => { const permissions = {}; @@ -36,3 +38,25 @@ export const getPermissionSettingsId = (value) => { } return value; }; + +export const excludeIdFromList = (items = [], id) => + (items || []).filter((item) => String(getPermissionSettingsId(item)) !== String(id)); + +export const resolveReferencedDocs = async (modelName, items = []) => { + const ids = (items || []).map(getPermissionSettingsId).filter(Boolean); + if (ids.length === 0) { + return []; + } + + const docs = await mongoose + .model(modelName) + .find({ _id: { $in: ids } }) + .lean(); + const docsById = new Map(docs.map((doc) => [String(doc._id), doc])); + + return ids.map((id) => docsById.get(String(id))).filter(Boolean); +}; + +export const resolvePermissionSettings = async (owner) => { + return resolveReferencedDocs('permissionSetting', owner?.permissionSettings); +}; diff --git a/src/database/schemas/management/permissionsetting.schema.js b/src/database/schemas/management/permissionsetting.schema.js new file mode 100644 index 0000000..bc9aefa --- /dev/null +++ b/src/database/schemas/management/permissionsetting.schema.js @@ -0,0 +1,62 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +import { excludeIdFromList, getPermissionSettingsId } from '../../permissions.js'; + +const { Schema } = mongoose; + +const permissionSettingSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) }, + }, + { timestamps: true } +); + +permissionSettingSchema.index({ name: 'text' }); + +permissionSettingSchema.virtual('id').get(function () { + return this._id; +}); + +permissionSettingSchema.set('toJSON', { virtuals: true }); + +const recalculateAssignees = async (model, permissionSettingId, stillExists, user) => { + const assigned = await model.find({ permissionSettings: permissionSettingId }).lean(); + + if (!stillExists) { + await model.updateMany( + { permissionSettings: permissionSettingId }, + { $pull: { permissionSettings: permissionSettingId } } + ); + } + + for (const item of assigned) { + const nextItem = stillExists + ? item + : { + ...item, + permissionSettings: excludeIdFromList(item.permissionSettings, permissionSettingId), + }; + await model.recalculate(nextItem, user); + } +}; + +permissionSettingSchema.statics.recalculate = async function (permissionSetting, user) { + const permissionSettingId = getPermissionSettingsId(permissionSetting); + if (!permissionSettingId) { + return; + } + + const stillExists = await this.exists({ _id: permissionSettingId }); + const userGroupModel = mongoose.model('userGroup'); + const userModel = mongoose.model('user'); + + await recalculateAssignees(userGroupModel, permissionSettingId, stillExists, user); + await recalculateAssignees(userModel, permissionSettingId, stillExists, user); +}; + +export const permissionSettingModel = mongoose.model( + 'permissionSetting', + permissionSettingSchema +); diff --git a/src/database/schemas/management/permissionsettings.schema.js b/src/database/schemas/management/permissionsettings.schema.js deleted file mode 100644 index 688c38f..0000000 --- a/src/database/schemas/management/permissionsettings.schema.js +++ /dev/null @@ -1,59 +0,0 @@ -import mongoose from 'mongoose'; -import { generateId } from '../../utils.js'; -import { getPermissionSettingsId } from '../../permissions.js'; - -const { Schema } = mongoose; - -const permissionSettingsSchema = new Schema( - { - _reference: { type: String, default: () => generateId()() }, - name: { type: String, required: true }, - permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) }, - }, - { timestamps: true } -); - -permissionSettingsSchema.index({ name: 'text' }); - -permissionSettingsSchema.virtual('id').get(function () { - return this._id; -}); - -permissionSettingsSchema.set('toJSON', { virtuals: true }); - -permissionSettingsSchema.statics.recalculate = async function (permissionSettings, user) { - const permissionSettingsId = getPermissionSettingsId(permissionSettings); - if (!permissionSettingsId) { - return; - } - - const userModel = mongoose.model('user'); - const users = await userModel - .find({ permissionSettings: permissionSettingsId }) - .lean(); - - const stillExists = await this.exists({ _id: permissionSettingsId }); - if (!stillExists) { - await userModel.updateMany( - { permissionSettings: permissionSettingsId }, - { $pull: { permissionSettings: permissionSettingsId } } - ); - } - - for (const assignedUser of users) { - const nextUser = stillExists - ? assignedUser - : { - ...assignedUser, - permissionSettings: (assignedUser.permissionSettings || []).filter( - (item) => String(getPermissionSettingsId(item)) !== String(permissionSettingsId) - ), - }; - await userModel.recalculate(nextUser, user); - } -}; - -export const permissionSettingsModel = mongoose.model( - 'permissionSettings', - permissionSettingsSchema -); diff --git a/src/database/schemas/management/user.schema.js b/src/database/schemas/management/user.schema.js index 1da0e62..0112da3 100644 --- a/src/database/schemas/management/user.schema.js +++ b/src/database/schemas/management/user.schema.js @@ -1,6 +1,10 @@ import mongoose from 'mongoose'; import { generateId } from '../../utils.js'; -import { applyPermissionSettingsList, getPermissionSettingsId } from '../../permissions.js'; +import { + applyPermissionSettingsList, + resolvePermissionSettings, + resolveReferencedDocs, +} from '../../permissions.js'; const { Schema } = mongoose; @@ -14,8 +18,9 @@ const userSchema = new mongoose.Schema( email: { required: true, type: String }, profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false }, appPasswordHash: { type: String, required: false, select: false }, + groups: [{ type: Schema.Types.ObjectId, ref: 'userGroup', required: false }], permissionSettings: [ - { type: Schema.Types.ObjectId, ref: 'permissionSettings', required: false }, + { type: Schema.Types.ObjectId, ref: 'permissionSetting', required: false }, ], permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) }, }, @@ -30,30 +35,18 @@ userSchema.virtual('id').get(function () { userSchema.set('toJSON', { virtuals: true }); -const resolvePermissionSettings = async (user) => { - const permissionSettingsModel = mongoose.model('permissionSettings'); - const items = user?.permissionSettings || []; - const ids = items.map(getPermissionSettingsId).filter(Boolean); - if (ids.length === 0) { - return []; - } - - const docs = await permissionSettingsModel - .find({ _id: { $in: ids } }) - .lean(); - const docsById = new Map(docs.map((doc) => [String(doc._id), doc])); - - return ids.map((id) => docsById.get(String(id))).filter(Boolean); -}; - userSchema.statics.recalculate = async function (user, actingUser) { const userId = user?._id || user; if (!userId) { return; } + const groups = await resolveReferencedDocs('userGroup', user?.groups); const settings = await resolvePermissionSettings(user); - const permissions = applyPermissionSettingsList(settings); + const permissions = applyPermissionSettingsList([...groups, ...settings]); + if (user && typeof user === 'object' && !user._bsontype) { + user.permissions = permissions; + } const { editObject } = await import('../../database.js'); await editObject({ @@ -61,7 +54,7 @@ userSchema.statics.recalculate = async function (user, actingUser) { id: userId, updateData: { permissions }, user: actingUser, - populate: ['profileImage', 'permissionSettings'], + populate: ['profileImage', 'permissionSettings', 'groups'], recalculate: false, }); }; diff --git a/src/database/schemas/management/usergroup.schema.js b/src/database/schemas/management/usergroup.schema.js new file mode 100644 index 0000000..a748e59 --- /dev/null +++ b/src/database/schemas/management/usergroup.schema.js @@ -0,0 +1,74 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +import { + applyPermissionSettingsList, + excludeIdFromList, + resolvePermissionSettings, +} from '../../permissions.js'; + +const { Schema } = mongoose; + +const userGroupSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + permissionSettings: [ + { type: Schema.Types.ObjectId, ref: 'permissionSetting', required: false }, + ], + permissions: { type: Schema.Types.Mixed, required: false, default: () => ({}) }, + }, + { timestamps: true } +); + +userGroupSchema.index({ name: 'text' }); + +userGroupSchema.virtual('id').get(function () { + return this._id; +}); + +userGroupSchema.set('toJSON', { virtuals: true }); + +userGroupSchema.statics.recalculate = async function (userGroup, actingUser) { + const userGroupId = userGroup?._id || userGroup; + if (!userGroupId) { + return; + } + + const stillExists = await this.exists({ _id: userGroupId }); + if (stillExists) { + const settings = await resolvePermissionSettings(userGroup); + const permissions = applyPermissionSettingsList(settings); + if (userGroup && typeof userGroup === 'object' && !userGroup._bsontype) { + userGroup.permissions = permissions; + } + const { editObject } = await import('../../database.js'); + + await editObject({ + model: this, + id: userGroupId, + updateData: { permissions }, + user: actingUser, + populate: ['permissionSettings'], + recalculate: false, + }); + } + + const userModel = mongoose.model('user'); + const users = await userModel.find({ groups: userGroupId }).lean(); + + if (!stillExists) { + await userModel.updateMany({ groups: userGroupId }, { $pull: { groups: userGroupId } }); + } + + for (const assignedUser of users) { + const nextUser = stillExists + ? assignedUser + : { + ...assignedUser, + groups: excludeIdFromList(assignedUser.groups, userGroupId), + }; + await userModel.recalculate(nextUser, actingUser); + } +}; + +export const userGroupModel = mongoose.model('userGroup', userGroupSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 7a63124..e4276e6 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -24,7 +24,8 @@ import { stockLocationModel } from './inventory/stocklocation.schema.js'; import { stockTransferModel } from './inventory/stocktransfer.schema.js'; import { auditLogModel } from './management/auditlog.schema.js'; import { userModel } from './management/user.schema.js'; -import { permissionSettingsModel } from './management/permissionsettings.schema.js'; +import { userGroupModel } from './management/usergroup.schema.js'; +import { permissionSettingModel } from './management/permissionsetting.schema.js'; import { appPasswordModel } from './management/apppassword.schema.js'; import { noteTypeModel } from './management/notetype.schema.js'; import { noteModel } from './misc/note.schema.js'; @@ -213,10 +214,17 @@ export const models = { referenceField: '_reference', label: 'User', }, - PMS: { - model: permissionSettingsModel, + UGP: { + model: userGroupModel, idField: '_id', - type: 'permissionSettings', + type: 'userGroup', + referenceField: '_reference', + label: 'User Group', + }, + PMS: { + model: permissionSettingModel, + idField: '_id', + type: 'permissionSetting', referenceField: '_reference', label: 'Permission Settings', }, diff --git a/src/index.js b/src/index.js index a546fc3..3e31bab 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import { redisServer } from './database/redis.js'; import { authRoutes, userRoutes, + userGroupRoutes, permissionSettingsRoutes, appPasswordRoutes, fileRoutes, @@ -155,6 +156,7 @@ app.get('/', function (req, res) { app.use('/auth', authRoutes); app.use('/users', userRoutes); +app.use('/usergroups', userGroupRoutes); app.use('/permissionsettings', permissionSettingsRoutes); app.use('/apppasswords', appPasswordRoutes); app.use('/files', fileRoutes); diff --git a/src/routes/index.js b/src/routes/index.js index b6188eb..4c33889 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,5 +1,6 @@ import userRoutes from './management/users.js'; -import permissionSettingsRoutes from './management/permissionsettings.js'; +import userGroupRoutes from './management/usergroups.js'; +import permissionSettingsRoutes from './management/permissionsetting.js'; import appPasswordRoutes from './management/apppasswords.js'; import fileRoutes from './management/files.js'; import authRoutes from './misc/auth.js'; @@ -61,6 +62,7 @@ import slicerRoutes from './misc/slicer.js'; export { userRoutes, + userGroupRoutes, permissionSettingsRoutes, appPasswordRoutes, fileRoutes, diff --git a/src/routes/management/permissionsettings.js b/src/routes/management/permissionsetting.js similarity index 98% rename from src/routes/management/permissionsettings.js rename to src/routes/management/permissionsetting.js index f2be443..1aafa10 100644 --- a/src/routes/management/permissionsettings.js +++ b/src/routes/management/permissionsetting.js @@ -19,7 +19,7 @@ import { searchPermissionSettingsRouteHandler, getPermissionSettingsPropertyValuesRouteHandler, getPermissionSettingsNeighborsRouteHandler, -} from '../../services/management/permissionsettings.js'; +} from '../../services/management/permissionsetting.js'; router.get('/', isAuthenticated, async (req, res) => { const { page, limit, property, search, sort, order } = req.query; diff --git a/src/routes/management/usergroups.js b/src/routes/management/usergroups.js new file mode 100644 index 0000000..135c864 --- /dev/null +++ b/src/routes/management/usergroups.js @@ -0,0 +1,99 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { getFilter, convertPropertiesString, getSort } from '../../utils.js'; + +const router = express.Router(); + +const listAllowedFilters = ['name', 'permissionSettings', 'createdAt', 'updatedAt', '_reference']; +const listAllowedSorters = ['name', 'createdAt', '_id', 'updatedAt']; +const propertiesAllowedFilters = ['name']; +import { + listUserGroupsRouteHandler, + getUserGroupRouteHandler, + editUserGroupRouteHandler, + newUserGroupRouteHandler, + deleteUserGroupRouteHandler, + listUserGroupsByPropertiesRouteHandler, + getUserGroupStatsRouteHandler, + getUserGroupHistoryRouteHandler, + searchUserGroupsRouteHandler, + getUserGroupPropertyValuesRouteHandler, + getUserGroupNeighborsRouteHandler, +} from '../../services/management/usergroups.js'; + +router.get('/', isAuthenticated, async (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + listUserGroupsRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sort, listAllowedSorters), + order + ); +}); + +router.get('/properties', isAuthenticated, async (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertiesAllowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listUserGroupsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); + +router.get('/values', isAuthenticated, async (req, res) => { + const { property } = req.query; + getUserGroupPropertyValuesRouteHandler(req, res, property); +}); + +router.get('/search', isAuthenticated, async (req, res) => { + const { search } = req.query; + searchUserGroupsRouteHandler(req, res, search); +}); + +router.post('/', isAuthenticated, async (req, res) => { + newUserGroupRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, async (req, res) => { + getUserGroupStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, async (req, res) => { + getUserGroupHistoryRouteHandler(req, res); +}); + +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sort, order, id } = req.query; + const filter = await getFilter(req.query, listAllowedFilters); + getUserGroupNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sort, listAllowedSorters), + order, + id + ); +}); + +router.get('/:id', isAuthenticated, async (req, res) => { + getUserGroupRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editUserGroupRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteUserGroupRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/management/users.js b/src/routes/management/users.js index e1e4119..58f941e 100644 --- a/src/routes/management/users.js +++ b/src/routes/management/users.js @@ -10,6 +10,7 @@ const listAllowedFilters = [ 'firstName', 'lastName', 'email', + 'groups', 'permissionSettings', 'createdAt', 'updatedAt', diff --git a/src/services/management/__tests__/permissionsettings.test.js b/src/services/management/__tests__/permissionsetting.test.js similarity index 89% rename from src/services/management/__tests__/permissionsettings.test.js rename to src/services/management/__tests__/permissionsetting.test.js index 4ae626a..907ea33 100644 --- a/src/services/management/__tests__/permissionsettings.test.js +++ b/src/services/management/__tests__/permissionsetting.test.js @@ -14,8 +14,8 @@ jest.unstable_mockModule('../../../database/database.js', () => ({ getObjectNeighbors: jest.fn(), })); -jest.unstable_mockModule('../../../database/schemas/management/permissionsettings.schema.js', () => ({ - permissionSettingsModel: { modelName: 'PermissionSettings' }, +jest.unstable_mockModule('../../../database/schemas/management/permissionsetting.schema.js', () => ({ + permissionSettingModel: { modelName: 'PermissionSetting' }, })); jest.unstable_mockModule('log4js', () => ({ @@ -35,13 +35,13 @@ const { getPermissionSettingsRouteHandler, newPermissionSettingsRouteHandler, editPermissionSettingsRouteHandler, -} = await import('../permissionsettings.js'); +} = await import('../permissionsetting.js'); const { listObjects, getObject, editObject, newObject } = await import( '../../../database/database.js' ); -const { permissionSettingsModel } = await import( - '../../../database/schemas/management/permissionsettings.schema.js' +const { permissionSettingModel } = await import( + '../../../database/schemas/management/permissionsetting.schema.js' ); describe('Permission Settings Service Route Handlers', () => { @@ -69,7 +69,7 @@ describe('Permission Settings Service Route Handlers', () => { await listPermissionSettingsRouteHandler(req, res); expect(listObjects).toHaveBeenCalledWith( - expect.objectContaining({ model: permissionSettingsModel }) + expect.objectContaining({ model: permissionSettingModel }) ); expect(res.send).toHaveBeenCalledWith(mockResult); }); @@ -112,7 +112,7 @@ describe('Permission Settings Service Route Handlers', () => { expect(getObject).toHaveBeenCalledWith( expect.objectContaining({ - model: permissionSettingsModel, + model: permissionSettingModel, id: '123', }) ); diff --git a/src/services/management/__tests__/usergroups.test.js b/src/services/management/__tests__/usergroups.test.js new file mode 100644 index 0000000..3e84651 --- /dev/null +++ b/src/services/management/__tests__/usergroups.test.js @@ -0,0 +1,130 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../database/database.js', () => ({ + searchObjects: jest.fn(), + getPropertyValues: jest.fn(), + listObjects: jest.fn(), + getObject: jest.fn(), + editObject: jest.fn(), + newObject: jest.fn(), + deleteObject: jest.fn(), + listObjectsByProperties: jest.fn(), + getModelStats: jest.fn(), + getModelHistory: jest.fn(), + getObjectNeighbors: jest.fn(), +})); + +jest.unstable_mockModule('../../../database/schemas/management/usergroup.schema.js', () => ({ + userGroupModel: { modelName: 'UserGroup' }, +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }), + }, +})); + +const { + listUserGroupsRouteHandler, + getUserGroupRouteHandler, + newUserGroupRouteHandler, + editUserGroupRouteHandler, +} = await import('../usergroups.js'); + +const { listObjects, getObject, editObject, newObject } = await import( + '../../../database/database.js' +); +const { userGroupModel } = await import( + '../../../database/schemas/management/usergroup.schema.js' +); + +describe('User Groups Service Route Handlers', () => { + let req, res; + + beforeEach(() => { + req = { + params: {}, + query: {}, + body: {}, + user: { id: 'test-user-id' }, + }; + res = { + send: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + jest.clearAllMocks(); + }); + + describe('listUserGroupsRouteHandler', () => { + it('should list user groups', async () => { + const mockResult = [{ _id: '1', name: 'Admins' }]; + listObjects.mockResolvedValue(mockResult); + + await listUserGroupsRouteHandler(req, res); + + expect(listObjects).toHaveBeenCalledWith( + expect.objectContaining({ model: userGroupModel }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('newUserGroupRouteHandler', () => { + it('should create a new user group', async () => { + req.body = { name: 'Editors', permissionSettings: [{ _id: 'pms-1' }] }; + const mockUserGroup = { _id: '456', ...req.body }; + newObject.mockResolvedValue(mockUserGroup); + + await newUserGroupRouteHandler(req, res); + + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: userGroupModel, + newData: expect.objectContaining({ + name: 'Editors', + permissionSettings: ['pms-1'], + }), + }) + ); + expect(res.send).toHaveBeenCalledWith(mockUserGroup); + }); + }); + + describe('editUserGroupRouteHandler', () => { + it('should update a user group', async () => { + req.params.id = '507f1f77bcf86cd799439011'; + req.body = { name: 'Editors', permissionSettings: ['pms-1'] }; + const mockResult = { _id: '507f1f77bcf86cd799439011', ...req.body }; + editObject.mockResolvedValue(mockResult); + + await editUserGroupRouteHandler(req, res); + + expect(editObject).toHaveBeenCalled(); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('getUserGroupRouteHandler', () => { + it('should get a user group by id', async () => { + req.params.id = '123'; + const mockResult = { _id: '123', name: 'Admins' }; + getObject.mockResolvedValue(mockResult); + + await getUserGroupRouteHandler(req, res); + + expect(getObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: userGroupModel, + id: '123', + }) + ); + expect(res.send).toHaveBeenCalledWith(mockResult); + }); + }); +}); diff --git a/src/services/management/permissionsettings.js b/src/services/management/permissionsetting.js similarity index 89% rename from src/services/management/permissionsettings.js rename to src/services/management/permissionsetting.js index 58e8c5a..83ad134 100644 --- a/src/services/management/permissionsettings.js +++ b/src/services/management/permissionsetting.js @@ -1,5 +1,5 @@ import config from '../../config.js'; -import { permissionSettingsModel } from '../../database/schemas/management/permissionsettings.schema.js'; +import { permissionSettingModel } from '../../database/schemas/management/permissionsetting.schema.js'; import log4js from 'log4js'; import mongoose from 'mongoose'; import { @@ -16,7 +16,7 @@ import { getObjectNeighbors, } from '../../database/database.js'; -const logger = log4js.getLogger('PermissionSettings'); +const logger = log4js.getLogger('PermissionSetting'); logger.level = config.server.logLevel; export const listPermissionSettingsRouteHandler = async ( @@ -31,7 +31,7 @@ export const listPermissionSettingsRouteHandler = async ( order = 'ascend' ) => { const result = await listObjects({ - model: permissionSettingsModel, + model: permissionSettingModel, page, limit, property, @@ -61,7 +61,7 @@ export const listPermissionSettingsByPropertiesRouteHandler = async ( masterFilter = {} ) => { const result = await listObjectsByProperties({ - model: permissionSettingsModel, + model: permissionSettingModel, properties, filter, masterFilter, @@ -79,7 +79,7 @@ export const listPermissionSettingsByPropertiesRouteHandler = async ( export const getPermissionSettingsPropertyValuesRouteHandler = async (req, res, property) => { const result = await getPropertyValues({ - model: permissionSettingsModel, + model: permissionSettingModel, property, }); res.send(result); @@ -87,7 +87,7 @@ export const getPermissionSettingsPropertyValuesRouteHandler = async (req, res, export const searchPermissionSettingsRouteHandler = async (req, res, search) => { const result = await searchObjects({ - model: permissionSettingsModel, + model: permissionSettingModel, search, }); res.send(result); @@ -96,7 +96,7 @@ export const searchPermissionSettingsRouteHandler = async (req, res, search) => export const getPermissionSettingsRouteHandler = async (req, res) => { const id = req.params.id; const result = await getObject({ - model: permissionSettingsModel, + model: permissionSettingModel, id, }); if (result?.error) { @@ -118,7 +118,7 @@ export const editPermissionSettingsRouteHandler = async (req, res) => { permissions: req.body.permissions, }; const result = await editObject({ - model: permissionSettingsModel, + model: permissionSettingModel, id, updateData, user: req.user, @@ -142,7 +142,7 @@ export const newPermissionSettingsRouteHandler = async (req, res) => { permissions: req.body.permissions, }; const result = await newObject({ - model: permissionSettingsModel, + model: permissionSettingModel, newData, user: req.user, }); @@ -162,7 +162,7 @@ export const deletePermissionSettingsRouteHandler = async (req, res) => { logger.trace(`Permission settings with ID: ${id}`); const result = await deleteObject({ - model: permissionSettingsModel, + model: permissionSettingModel, id, user: req.user, }); @@ -177,7 +177,7 @@ export const deletePermissionSettingsRouteHandler = async (req, res) => { }; export const getPermissionSettingsStatsRouteHandler = async (req, res) => { - const result = await getModelStats({ model: permissionSettingsModel }); + const result = await getModelStats({ model: permissionSettingModel }); if (result?.error) { logger.error('Error fetching permission settings stats:', result.error); return res.status(result.code).send(result); @@ -189,7 +189,7 @@ export const getPermissionSettingsStatsRouteHandler = async (req, res) => { export const getPermissionSettingsHistoryRouteHandler = async (req, res) => { const from = req.query.from; const to = req.query.to; - const result = await getModelHistory({ model: permissionSettingsModel, from, to }); + const result = await getModelHistory({ model: permissionSettingModel, from, to }); if (result?.error) { logger.error('Error fetching permission settings history:', result.error); return res.status(result.code).send(result); @@ -213,7 +213,7 @@ export const getPermissionSettingsNeighborsRouteHandler = async ( } const result = await getObjectNeighbors({ - model: permissionSettingsModel, + model: permissionSettingModel, id, filter, search, diff --git a/src/services/management/usergroups.js b/src/services/management/usergroups.js new file mode 100644 index 0000000..7edafb5 --- /dev/null +++ b/src/services/management/usergroups.js @@ -0,0 +1,240 @@ +import config from '../../config.js'; +import { userGroupModel } from '../../database/schemas/management/usergroup.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, +} from '../../database/database.js'; + +const logger = log4js.getLogger('UserGroups'); +logger.level = config.server.logLevel; + +const USER_GROUP_POPULATE = ['permissionSettings']; + +const toIdList = (value) => + Array.isArray(value) ? value.map((item) => item?._id || item) : value; + +export const listUserGroupsRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: userGroupModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: USER_GROUP_POPULATE, + }); + + if (result?.error) { + logger.error('Error listing user groups.'); + res.status(result.code).send(result); + return; + } + + logger.debug( + `List of user groups (Page ${page}, Limit ${limit}). Count: ${result.length}.` + ); + res.send(result); +}; + +export const listUserGroupsByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: userGroupModel, + properties, + filter, + masterFilter, + }); + + if (result?.error) { + logger.error('Error listing user groups.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of user groups. Count: ${result.length}`); + res.send(result); +}; + +export const getUserGroupPropertyValuesRouteHandler = async (req, res, property) => { + const result = await getPropertyValues({ + model: userGroupModel, + property, + }); + res.send(result); +}; + +export const searchUserGroupsRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: userGroupModel, + search, + }); + res.send(result); +}; + +export const getUserGroupRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: userGroupModel, + id, + populate: USER_GROUP_POPULATE, + }); + if (result?.error) { + logger.warn(`User group not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retreived user group with ID: ${id}`); + res.send(result); +}; + +export const editUserGroupRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`User group with ID: ${id}`); + + const updateData = { + updatedAt: new Date(), + name: req.body.name, + permissionSettings: toIdList(req.body.permissionSettings), + }; + const result = await editObject({ + model: userGroupModel, + id, + updateData, + user: req.user, + populate: USER_GROUP_POPULATE, + }); + + if (result.error) { + logger.error('Error editing user group:', result.error); + res.status(result).send(result); + return; + } + + logger.debug(`Edited user group with ID: ${id}`); + + res.send(result); +}; + +export const newUserGroupRouteHandler = async (req, res) => { + const newData = { + updatedAt: new Date(), + name: req.body.name, + permissionSettings: toIdList(req.body.permissionSettings), + permissions: req.body.permissions, + }; + const result = await newObject({ + model: userGroupModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No user group created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New user group with ID: ${result._id}`); + + res.send(result); +}; + +export const deleteUserGroupRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`User group with ID: ${id}`); + + const result = await deleteObject({ + model: userGroupModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No user group deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted user group with ID: ${result._id}`); + + res.send(result); +}; + +export const getUserGroupStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: userGroupModel }); + if (result?.error) { + logger.error('Error fetching user group stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('User group stats:', result); + res.send(result); +}; + +export const getUserGroupHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: userGroupModel, from, to }); + if (result?.error) { + logger.error('Error fetching user group history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('User group history:', result); + res.send(result); +}; + +export const getUserGroupNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: userGroupModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching userGroup neighbors.'); + return res.status(result.code).send(result); + } + + logger.debug(`Retrieved userGroup neighbors for ID: ${id}`); + res.send(result); +}; diff --git a/src/services/management/users.js b/src/services/management/users.js index 01bbecf..72c8c6a 100644 --- a/src/services/management/users.js +++ b/src/services/management/users.js @@ -19,7 +19,7 @@ import { const logger = log4js.getLogger('Users'); logger.level = config.server.logLevel; -const USER_POPULATE = ['profileImage', 'permissionSettings']; +const USER_POPULATE = ['profileImage', 'permissionSettings', 'groups']; export const listUsersRouteHandler = async ( req, @@ -122,6 +122,9 @@ export const editUserRouteHandler = async (req, res) => { lastName: req.body.lastName, email: req.body.email, profileImage: req.body.profileImage, + groups: Array.isArray(req.body.groups) + ? req.body.groups.map((item) => item?._id || item) + : req.body.groups, permissionSettings: Array.isArray(req.body.permissionSettings) ? req.body.permissionSettings.map((item) => item?._id || item) : req.body.permissionSettings,