diff --git a/src/database/schemas/misc/usersettings.schema.js b/src/database/schemas/misc/usersettings.schema.js new file mode 100644 index 0000000..d643f50 --- /dev/null +++ b/src/database/schemas/misc/usersettings.schema.js @@ -0,0 +1,44 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const userSettingsSchema = new mongoose.Schema({ + _reference: { type: String, default: () => generateId()() }, + user: { + type: Schema.Types.ObjectId, + ref: 'user', + required: true, + unique: true + }, + defaults: { + viewMode: { type: Schema.Types.Mixed, default: () => ({}) }, + filterSidebarVisibility: { + type: Schema.Types.Mixed, + default: () => ({}) + }, + columnVisibility: { type: Schema.Types.Mixed, default: () => ({}) }, + collapseState: { type: Schema.Types.Mixed, default: () => ({}) } + }, + + createdAt: { + type: Date, + required: true, + default: Date.now + }, + updatedAt: { + type: Date, + required: true, + default: Date.now + } +}); + +userSettingsSchema.virtual('id').get(function () { + return this._id; +}); + +userSettingsSchema.set('toJSON', { virtuals: true }); + +export const userSettingsModel = mongoose.model( + 'userSettings', + userSettingsSchema +); diff --git a/src/socket/__tests__/socketuser.test.js b/src/socket/__tests__/socketuser.test.js index 9498626..67b2bd3 100644 --- a/src/socket/__tests__/socketuser.test.js +++ b/src/socket/__tests__/socketuser.test.js @@ -77,6 +77,13 @@ jest.unstable_mockModule('../../server/servermanager.js', () => ({ })) })); +jest.unstable_mockModule('../../usersettings/usersettingsmanager.js', () => ({ + UserSettingsManager: jest.fn().mockImplementation(() => ({ + getUserSettings: jest.fn(), + updateUserSettings: jest.fn() + })) +})); + jest.unstable_mockModule('log4js', () => ({ default: { getLogger: () => ({ @@ -130,6 +137,14 @@ describe('SocketUser', () => { 'getServerVersion', expect.any(Function) ); + expect(mockSocket.on).toHaveBeenCalledWith( + 'getUserSettings', + expect.any(Function) + ); + expect(mockSocket.on).toHaveBeenCalledWith( + 'updateUserSettings', + expect.any(Function) + ); }); describe('handleAuthenticateEvent', () => { @@ -196,15 +211,17 @@ describe('SocketUser', () => { describe('object type subscription handlers', () => { it('should pass filters to type subscription methods', async () => { - const data = { objectType: 'note', filter: { 'parent._id': 'parent-id' } }; + const data = { + objectType: 'note', + filter: { 'parent._id': 'parent-id' } + }; const callback = jest.fn(); await socketUser.handleSubscribeToObjectTypeUpdateEvent(data, callback); - expect(socketUser.updateManager.subscribeToObjectNew).toHaveBeenCalledWith( - 'note', - data.filter - ); + expect( + socketUser.updateManager.subscribeToObjectNew + ).toHaveBeenCalledWith('note', data.filter); expect( socketUser.updateManager.subscribeToObjectDelete ).toHaveBeenCalledWith('note', data.filter); @@ -212,7 +229,10 @@ describe('SocketUser', () => { }); it('should pass filters to type unsubscribe methods', async () => { - const data = { objectType: 'note', filter: { 'parent._id': 'parent-id' } }; + const data = { + objectType: 'note', + filter: { 'parent._id': 'parent-id' } + }; await socketUser.handleUnsubscribeToObjectTypeUpdateEvent(data); @@ -252,6 +272,54 @@ describe('SocketUser', () => { }); }); + describe('user settings event handlers', () => { + it('returns user settings through the acknowledgement callback', async () => { + const callback = jest.fn(); + const settings = { viewMode: { payments: 'cards' } }; + socketUser.userSettingsManager.getUserSettings.mockResolvedValue( + settings + ); + + await socketUser.handleGetUserSettingsEvent({}, callback); + + expect(callback).toHaveBeenCalledWith({ success: true, settings }); + }); + + it('updates user settings through the acknowledgement callback', async () => { + const callback = jest.fn(); + const data = { + category: 'viewMode', + key: 'payments', + value: 'cards' + }; + const settings = { viewMode: { payments: 'cards' } }; + socketUser.userSettingsManager.updateUserSettings.mockResolvedValue( + settings + ); + + await socketUser.handleUpdateUserSettingsEvent(data, callback); + + expect( + socketUser.userSettingsManager.updateUserSettings + ).toHaveBeenCalledWith(data); + expect(callback).toHaveBeenCalledWith({ success: true, settings }); + }); + + it('returns user settings errors through the acknowledgement callback', async () => { + const callback = jest.fn(); + socketUser.userSettingsManager.getUserSettings.mockRejectedValue( + new Error('database unavailable') + ); + + await socketUser.handleGetUserSettingsEvent({}, callback); + + expect(callback).toHaveBeenCalledWith({ + success: false, + error: 'database unavailable' + }); + }); + }); + describe('handleDisconnect', () => { it('should remove all listeners', async () => { await socketUser.handleDisconnect(); @@ -260,7 +328,9 @@ describe('SocketUser', () => { expect(socketUser.updateManager.removeAllListeners).toHaveBeenCalled(); expect(socketUser.eventManager.removeAllListeners).toHaveBeenCalled(); expect(socketUser.statsManager.removeAllListeners).toHaveBeenCalled(); - expect(socketUser.notificationManager.removeAllListeners).toHaveBeenCalled(); + expect( + socketUser.notificationManager.removeAllListeners + ).toHaveBeenCalled(); }); }); }); diff --git a/src/socket/socketuser.js b/src/socket/socketuser.js index d3d2550..a8649c3 100644 --- a/src/socket/socketuser.js +++ b/src/socket/socketuser.js @@ -10,6 +10,7 @@ import { EventManager } from '../events/eventmanager.js'; import { StatsManager } from '../stats/statsmanager.js'; import { NotificationManager } from '../notification/notificationmanager.js'; import { ServerManager } from '../server/servermanager.js'; +import { UserSettingsManager } from '../usersettings/usersettingsmanager.js'; const config = loadConfig(); @@ -31,6 +32,7 @@ export class SocketUser { this.statsManager = new StatsManager(this); this.notificationManager = new NotificationManager(this); this.serverManager = new ServerManager(this); + this.userSettingsManager = new UserSettingsManager(this); this.templateManager = socketManager.templateManager; this.keycloakAuth = new KeycloakAuth(); this.setupSocketEventHandlers(); @@ -90,6 +92,14 @@ export class SocketUser { 'getServerVersion', this.handleGetServerVersionEvent.bind(this) ); + this.socket.on( + 'getUserSettings', + this.handleGetUserSettingsEvent.bind(this) + ); + this.socket.on( + 'updateUserSettings', + this.handleUpdateUserSettingsEvent.bind(this) + ); this.socket.on('objectAction', this.handleObjectActionEvent.bind(this)); this.socket.on('disconnect', this.handleDisconnect.bind(this)); } @@ -260,6 +270,31 @@ export class SocketUser { responseCallback(this.serverManager.getServerVersion()); } + async handleGetUserSettingsEvent(data, callback) { + const responseCallback = typeof callback === 'function' ? callback : data; + try { + const settings = await this.userSettingsManager.getUserSettings(); + responseCallback({ success: true, settings }); + } catch (error) { + logger.error('Get user settings error:', error); + responseCallback({ success: false, error: error.message }); + } + } + + async handleUpdateUserSettingsEvent(data, callback) { + try { + const settings = await this.userSettingsManager.updateUserSettings(data); + if (typeof callback === 'function') { + callback({ success: true, settings }); + } + } catch (error) { + logger.error('Update user settings error:', error); + if (typeof callback === 'function') { + callback({ success: false, error: error.message }); + } + } + } + async handleObjectActionEvent(data, callback) { await this.actionManager.sendObjectAction( data._id, diff --git a/src/usersettings/usersettingsmanager.js b/src/usersettings/usersettingsmanager.js new file mode 100644 index 0000000..235627b --- /dev/null +++ b/src/usersettings/usersettingsmanager.js @@ -0,0 +1,113 @@ +import { userSettingsModel } from '../database/schemas/misc/usersettings.schema.js'; + +const SETTINGS_CATEGORIES = [ + 'viewMode', + 'filterSidebarVisibility', + 'columnVisibility', + 'collapseState' +]; + +const createEmptySettings = () => ({ + viewMode: {}, + filterSidebarVisibility: {}, + columnVisibility: {}, + collapseState: {} +}); + +const normalizeCategory = category => + category && typeof category === 'object' && !Array.isArray(category) + ? category + : {}; + +const normalizeSettings = (defaults = {}) => ({ + viewMode: normalizeCategory(defaults?.viewMode), + filterSidebarVisibility: normalizeCategory(defaults?.filterSidebarVisibility), + columnVisibility: normalizeCategory(defaults?.columnVisibility), + collapseState: normalizeCategory(defaults?.collapseState) +}); + +const isSafeSettingsKey = key => + typeof key === 'string' && + key.length > 0 && + key.length <= 128 && + !key.includes('.') && + !key.includes('$') && + !key.includes('\0'); + +export class UserSettingsManager { + constructor(socketClient) { + this.socketClient = socketClient; + } + + getUserId() { + const userId = this.socketClient.user?._id; + if (!this.socketClient.authenticated || !userId) { + throw new Error('User is not authenticated'); + } + return userId; + } + + async getUserSettings() { + const userId = this.getUserId(); + const userSettings = await userSettingsModel + .findOne({ user: userId }) + .select('defaults') + .lean(); + + return normalizeSettings(userSettings?.defaults); + } + + async updateUserSettings({ category, key, value } = {}) { + const userId = this.getUserId(); + + if (!SETTINGS_CATEGORIES.includes(category)) { + throw new Error('Invalid user settings category'); + } + if (!isSafeSettingsKey(key)) { + throw new Error('Invalid user settings key'); + } + if (value === undefined) { + throw new Error('User settings value is required'); + } + + const update = { + $set: { + [`defaults.${category}.${key}`]: value, + updatedAt: new Date() + }, + $setOnInsert: { + user: userId, + createdAt: new Date() + } + }; + + let userSettings; + try { + userSettings = await userSettingsModel + .findOneAndUpdate({ user: userId }, update, { + new: true, + upsert: true, + setDefaultsOnInsert: true + }) + .select('defaults') + .lean(); + } catch (error) { + if (error?.code !== 11000) { + throw error; + } + + userSettings = await userSettingsModel + .findOneAndUpdate( + { user: userId }, + { $set: update.$set }, + { new: true } + ) + .select('defaults') + .lean(); + } + + return normalizeSettings(userSettings?.defaults); + } +} + +export { SETTINGS_CATEGORIES, createEmptySettings, isSafeSettingsKey }; diff --git a/src/usersettings/usersettingsmanager.test.js b/src/usersettings/usersettingsmanager.test.js new file mode 100644 index 0000000..7b77e50 --- /dev/null +++ b/src/usersettings/usersettingsmanager.test.js @@ -0,0 +1,107 @@ +import { jest } from '@jest/globals'; + +const findOne = jest.fn(); +const findOneAndUpdate = jest.fn(); + +jest.unstable_mockModule( + '../database/schemas/misc/usersettings.schema.js', + () => ({ + userSettingsModel: { + findOne, + findOneAndUpdate + } + }) +); + +const { UserSettingsManager, createEmptySettings, isSafeSettingsKey } = + await import('./usersettingsmanager.js'); + +const queryResult = value => ({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(value) + }) +}); + +describe('UserSettingsManager', () => { + let manager; + + beforeEach(() => { + jest.clearAllMocks(); + manager = new UserSettingsManager({ + authenticated: true, + user: { _id: 'user-id' } + }); + }); + + it('returns empty defaults when the user has no settings document', async () => { + findOne.mockReturnValue(queryResult(null)); + + await expect(manager.getUserSettings()).resolves.toEqual( + createEmptySettings() + ); + expect(findOne).toHaveBeenCalledWith({ user: 'user-id' }); + }); + + it('atomically upserts only the requested category and key', async () => { + const defaults = { + ...createEmptySettings(), + viewMode: { payments: 'cards' } + }; + findOneAndUpdate.mockReturnValue(queryResult({ defaults })); + + await expect( + manager.updateUserSettings({ + category: 'viewMode', + key: 'payments', + value: 'cards' + }) + ).resolves.toEqual(defaults); + + expect(findOneAndUpdate).toHaveBeenCalledWith( + { user: 'user-id' }, + expect.objectContaining({ + $set: expect.objectContaining({ + 'defaults.viewMode.payments': 'cards' + }), + $setOnInsert: expect.objectContaining({ user: 'user-id' }) + }), + { + new: true, + upsert: true, + setDefaultsOnInsert: true + } + ); + }); + + it('rejects invalid categories and unsafe keys', async () => { + await expect( + manager.updateUserSettings({ + category: 'unknown', + key: 'payments', + value: true + }) + ).rejects.toThrow('Invalid user settings category'); + + await expect( + manager.updateUserSettings({ + category: 'viewMode', + key: 'payments.value', + value: true + }) + ).rejects.toThrow('Invalid user settings key'); + + expect(isSafeSettingsKey('$payments')).toBe(false); + expect(findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('rejects unauthenticated access', async () => { + manager = new UserSettingsManager({ + authenticated: false, + user: { _id: 'user-id' } + }); + + await expect(manager.getUserSettings()).rejects.toThrow( + 'User is not authenticated' + ); + }); +});