From 49226290b6558903ff52282c74696f7e0aaed535 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 26 Jul 2026 00:43:24 +0100 Subject: [PATCH] Add ActivityManager and integrate activity tracking functionality - Introduced the ActivityManager class to handle object activity tracking, including viewing and editing events. - Implemented methods for setting, clearing, and retrieving activities, as well as subscribing to activity updates via websockets. - Replaced the LockManager with ActivityManager in SocketUser and SocketManager classes to streamline activity management. - Added comprehensive tests for ActivityManager to ensure correct functionality and error handling. - Removed LockManager and its associated tests to eliminate redundancy in functionality. --- .../__tests__/activitymanager.test.js | 258 +++++++++++++++++ src/activity/activitymanager.js | 262 ++++++++++++++++++ src/lock/__tests__/lockmanager.test.js | 226 --------------- src/lock/lockmanager.js | 117 -------- src/socket/__tests__/socketmanager.test.js | 4 +- src/socket/__tests__/socketuser.test.js | 36 ++- src/socket/socketmanager.js | 5 +- src/socket/socketuser.js | 140 ++++++++-- 8 files changed, 657 insertions(+), 391 deletions(-) create mode 100644 src/activity/__tests__/activitymanager.test.js create mode 100644 src/activity/activitymanager.js delete mode 100644 src/lock/__tests__/lockmanager.test.js delete mode 100644 src/lock/lockmanager.js diff --git a/src/activity/__tests__/activitymanager.test.js b/src/activity/__tests__/activitymanager.test.js new file mode 100644 index 0000000..10cc304 --- /dev/null +++ b/src/activity/__tests__/activitymanager.test.js @@ -0,0 +1,258 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../database/nats.js', () => ({ + natsServer: { + publish: jest.fn().mockResolvedValue({ success: true }), + subscribe: jest.fn().mockResolvedValue({ success: true }), + removeSubscription: jest.fn().mockResolvedValue({ success: true }) + } +})); + +jest.unstable_mockModule('../../database/redis.js', () => ({ + redisServer: { + setKey: jest.fn().mockResolvedValue(undefined), + getKey: jest.fn().mockResolvedValue(null), + deleteKey: jest.fn().mockResolvedValue(undefined), + getKeysByPattern: jest.fn().mockResolvedValue([]) + } +})); + +jest.unstable_mockModule('../../database/database.js', () => ({ + getObject: jest.fn().mockResolvedValue({ + _id: 'user-123', + username: 'testuser', + name: 'Test User', + profileImage: { _id: 'file-123' } + }) +})); + +jest.unstable_mockModule('../../database/utils.js', () => ({ + expandObjectIds: jest.fn(value => value) +})); + +jest.unstable_mockModule('../../database/schemas/management/user.schema.js', () => ({ + userModel: {} +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + info: jest.fn() + }) + } +})); + +jest.unstable_mockModule('../../config.js', () => ({ + loadConfig: jest.fn(() => ({ + server: { + logLevel: 'info' + } + })) +})); + +const { ActivityManager } = await import('../activitymanager.js'); +const { natsServer } = await import('../../database/nats.js'); +const { redisServer } = await import('../../database/redis.js'); + +describe('ActivityManager', () => { + let mockSocketClient; + let activityManager; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSocketClient = { + socketId: 'test-socket-id', + socket: { + emit: jest.fn() + } + }; + + activityManager = new ActivityManager(mockSocketClient); + }); + + describe('setActivity', () => { + it('should set activity in Redis and publish via NATS', async () => { + const testObject = { + _id: 'test-id-123', + type: 'printer', + user: 'user-123', + mode: 'viewing' + }; + + const result = await activityManager.setActivity(testObject); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + activity: expect.objectContaining({ + _id: 'test-id-123', + type: 'printer', + mode: 'viewing' + }) + }) + ); + expect(redisServer.setKey).toHaveBeenCalledWith( + 'activity:printers:test-id-123:user-123', + expect.objectContaining({ + _id: 'test-id-123', + type: 'printer', + user: 'user-123', + mode: 'viewing' + }) + ); + expect(natsServer.publish).toHaveBeenCalledWith( + 'activity.printers.test-id-123', + expect.objectContaining({ + mode: 'viewing' + }) + ); + }); + + it('should reject editing when another user is already editing', async () => { + redisServer.getKeysByPattern.mockResolvedValueOnce([ + 'activity:printers:test-id-123:user-456' + ]); + redisServer.getKey.mockResolvedValueOnce({ + _id: 'test-id-123', + type: 'printer', + user: 'user-456', + mode: 'editing' + }); + + const result = await activityManager.setActivity({ + _id: 'test-id-123', + type: 'printer', + user: 'user-123', + mode: 'editing' + }); + + expect(result).toEqual( + expect.objectContaining({ + success: false, + conflict: true + }) + ); + expect(redisServer.setKey).not.toHaveBeenCalled(); + expect(natsServer.publish).not.toHaveBeenCalled(); + }); + }); + + describe('clearActivity', () => { + it('should clear activity from Redis and publish via NATS', async () => { + const testObject = { + _id: 'test-id-123', + type: 'printer', + user: 'user-123' + }; + + const result = await activityManager.clearActivity(testObject); + + expect(result).toBe(true); + expect(redisServer.deleteKey).toHaveBeenCalledWith( + 'activity:printers:test-id-123:user-123' + ); + expect(natsServer.publish).toHaveBeenCalledWith( + 'activity.printers.test-id-123', + expect.objectContaining({ + _id: 'test-id-123', + type: 'printer', + user: 'user-123', + mode: null + }) + ); + }); + }); + + describe('getObjectActivities', () => { + it('should return hydrated activities for an object', async () => { + redisServer.getKeysByPattern.mockResolvedValueOnce([ + 'activity:printers:test-id-123:user-123' + ]); + redisServer.getKey.mockResolvedValueOnce({ + _id: 'test-id-123', + type: 'printer', + user: 'user-123', + mode: 'editing' + }); + + const result = await activityManager.getObjectActivities({ + _id: 'test-id-123', + type: 'printer' + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + mode: 'editing' + }) + ); + }); + }); + + describe('subscribeToObjectActivity', () => { + it('should subscribe to NATS activity updates', async () => { + const result = await activityManager.subscribeToObjectActivity( + 'test-id-123', + 'printer' + ); + + expect(result.success).toBe(true); + expect(natsServer.subscribe).toHaveBeenCalledWith( + 'activity.printers.test-id-123', + 'test-socket-id', + expect.any(Function) + ); + }); + + it('should not create duplicate subscriptions for the same object', async () => { + await activityManager.subscribeToObjectActivity('test-id-123', 'printer'); + await activityManager.subscribeToObjectActivity('test-id-123', 'printer'); + + expect(natsServer.subscribe).toHaveBeenCalledTimes(1); + }); + + it('should emit activity updates with the full activities list', async () => { + let natsCallback; + natsServer.subscribe.mockImplementation(async (_subject, _owner, callback) => { + natsCallback = callback; + return { success: true }; + }); + redisServer.getKeysByPattern.mockResolvedValue([ + 'activity:printers:test-id-123:user-123' + ]); + redisServer.getKey.mockResolvedValue({ + _id: 'test-id-123', + type: 'printer', + user: 'user-123', + mode: 'editing' + }); + + await activityManager.subscribeToObjectActivity('test-id-123', 'printer'); + await natsCallback('activity.printers.test-id-123', { + _id: 'test-id-123', + type: 'printer', + user: 'user-123', + mode: 'editing' + }); + + expect(mockSocketClient.socket.emit).toHaveBeenCalledWith( + 'activityUpdate', + expect.objectContaining({ + _id: 'test-id-123', + objectType: 'printer', + activities: [ + expect.objectContaining({ + mode: 'editing' + }) + ] + }) + ); + }); + }); +}); diff --git a/src/activity/activitymanager.js b/src/activity/activitymanager.js new file mode 100644 index 0000000..9ebb565 --- /dev/null +++ b/src/activity/activitymanager.js @@ -0,0 +1,262 @@ +import log4js from 'log4js'; +import { loadConfig } from '../config.js'; +import { natsServer } from '../database/nats.js'; +import { redisServer } from '../database/redis.js'; +import { getObject } from '../database/database.js'; +import { expandObjectIds } from '../database/utils.js'; +import { userModel } from '../database/schemas/management/user.schema.js'; + +const config = loadConfig(); + +const logger = log4js.getLogger('Activity Manager'); +logger.level = config.server.logLevel; + +const getSubscriptionKey = (subject, owner) => `${subject}:${owner}`; + +const normalizeId = value => { + if (value == null) { + return null; + } + + return typeof value === 'string' ? value : value.toString(); +}; + +const normalizeType = type => { + if (!type) { + return null; + } + + return typeof type === 'string' ? type : type.toString(); +}; + +const getActivityRedisKey = (type, id, userId) => + `activity:${normalizeType(type)}s:${normalizeId(id)}:${normalizeId(userId)}`; + +const getActivityPattern = (type, id) => + `activity:${normalizeType(type)}s:${normalizeId(id)}:*`; + +const getActivitySubject = (type, id) => + `activity.${normalizeType(type)}s.${normalizeId(id)}`; + +async function hydrateActivity(activity) { + const expanded = expandObjectIds({ ...activity }); + const userId = expanded.user?._id || expanded.user; + + if (userId) { + const user = await getObject({ + model: userModel, + id: userId.toString(), + populate: ['profileImage'], + cached: true + }); + + if (user) { + expanded.user = expandObjectIds(user); + } + } + + return expanded; +} + +/** + * ActivityManager tracks object viewing/editing activity and broadcasts events via websockets. + */ +export class ActivityManager { + constructor(socketClient) { + this.socketClient = socketClient; + this.subscriptions = new Set(); + this.objectSubscriptions = new Map(); + } + + emitActivityUpdate(id, objectType, activity, activities) { + this.socketClient.socket.emit('activityUpdate', { + _id: normalizeId(id), + objectType: normalizeType(objectType), + activity, + activities + }); + } + + async getObjectActivities(object) { + const objectId = normalizeId(object?._id); + const objectType = normalizeType(object?.type); + + logger.debug('Getting activities for object:', objectId); + try { + const pattern = getActivityPattern(objectType, objectId); + const keys = await redisServer.getKeysByPattern(pattern); + const activities = []; + + for (const key of keys) { + const storedActivity = await redisServer.getKey(key); + if (storedActivity?.mode) { + activities.push(await hydrateActivity(storedActivity)); + } + } + + return activities; + } catch (err) { + logger.error( + `Error getting activities for object ${objectId}:`, + err + ); + throw err; + } + } + + async setActivity(object) { + const objectId = normalizeId(object?._id); + const objectType = normalizeType(object?.type); + const userId = normalizeId(object?.user); + + logger.debug('Setting activity:', objectId, object.mode); + try { + if (object.mode === 'editing') { + const pattern = getActivityPattern(objectType, objectId); + const keys = await redisServer.getKeysByPattern(pattern); + + for (const key of keys) { + const storedActivity = await redisServer.getKey(key); + const activityUserId = normalizeId(storedActivity?.user); + + if ( + storedActivity?.mode === 'editing' && + activityUserId && + activityUserId !== userId + ) { + logger.info( + `Rejected editing activity for user ${userId} on object ${objectId}; already being edited by ${activityUserId}` + ); + return { + success: false, + conflict: true, + activity: await hydrateActivity(storedActivity) + }; + } + } + } + + const redisKey = getActivityRedisKey(objectType, objectId, userId); + const activityPayload = { + _id: objectId, + type: objectType, + user: userId, + mode: object.mode + }; + + await redisServer.setKey(redisKey, activityPayload); + + const hydratedPayload = await hydrateActivity(activityPayload); + const subject = getActivitySubject(objectType, objectId); + await natsServer.publish(subject, hydratedPayload); + logger.info(`Activity event published for id: ${objectId}`); + return { + success: true, + activity: hydratedPayload + }; + } catch (err) { + logger.error(`Error setting activity for object ${objectId}:`, err); + throw err; + } + } + + async clearActivity(object) { + const objectId = normalizeId(object?._id); + const objectType = normalizeType(object?.type); + const userId = normalizeId(object?.user); + const redisKey = getActivityRedisKey(objectType, objectId, userId); + + try { + logger.debug('Clearing activity:', objectId); + await redisServer.deleteKey(redisKey); + + const clearedPayload = { + _id: objectId, + type: objectType, + user: userId, + mode: null + }; + const subject = getActivitySubject(objectType, objectId); + await natsServer.publish(subject, clearedPayload); + logger.info(`Cleared activity and published event: ${objectId}`); + return true; + } catch (err) { + logger.error(`Error clearing activity for object ${objectId}:`, err); + throw err; + } + } + + async subscribeToObjectActivity(id, objectType) { + const objectId = normalizeId(id); + const normalizedType = normalizeType(objectType); + const subject = getActivitySubject(normalizedType, objectId); + const owner = this.socketClient.socketId; + const subscriptionKey = getSubscriptionKey(subject, owner); + const objectKey = `${normalizedType}:${objectId}`; + + logger.debug('Subscribing to object activity...', objectId, normalizedType); + + if (!this.subscriptions.has(subscriptionKey)) { + await natsServer.subscribe(subject, owner, async (_subject, value) => { + const expandedValue = expandObjectIds(value); + const hydratedValue = value?.mode + ? await hydrateActivity(expandedValue) + : expandedValue; + const activities = await this.getObjectActivities({ + _id: objectId, + type: normalizedType + }); + + logger.trace('Activity update event:', objectId); + this.emitActivityUpdate( + objectId, + normalizedType, + hydratedValue, + activities + ); + }); + + this.subscriptions.add(subscriptionKey); + this.objectSubscriptions.set(objectKey, subscriptionKey); + } + + const activities = await this.getObjectActivities({ + _id: objectId, + type: normalizedType + }); + + return { success: true, activities }; + } + + async removeObjectActivityListener(id, objectType) { + const objectId = normalizeId(id); + const normalizedType = normalizeType(objectType); + const subject = getActivitySubject(normalizedType, objectId); + const owner = this.socketClient.socketId; + const subscriptionKey = getSubscriptionKey(subject, owner); + const objectKey = `${normalizedType}:${objectId}`; + + await natsServer.removeSubscription(subject, owner); + this.subscriptions.delete(subscriptionKey); + this.objectSubscriptions.delete(objectKey); + return { success: true }; + } + + async removeAllListeners() { + logger.debug('Removing all activity listeners...'); + const removePromises = Array.from(this.subscriptions).map( + subscriptionKey => { + const separatorIndex = subscriptionKey.indexOf(':'); + const subject = subscriptionKey.slice(0, separatorIndex); + const owner = subscriptionKey.slice(separatorIndex + 1); + return natsServer.removeSubscription(subject, owner); + } + ); + + await Promise.all(removePromises); + this.subscriptions.clear(); + this.objectSubscriptions.clear(); + logger.debug(`Removed ${removePromises.length} activity listener(s)`); + return { success: true }; + } +} diff --git a/src/lock/__tests__/lockmanager.test.js b/src/lock/__tests__/lockmanager.test.js deleted file mode 100644 index 1a13244..0000000 --- a/src/lock/__tests__/lockmanager.test.js +++ /dev/null @@ -1,226 +0,0 @@ -import { jest } from '@jest/globals'; - -jest.unstable_mockModule('../../database/nats.js', () => ({ - natsServer: { - publish: jest.fn().mockResolvedValue({ success: true }), - subscribe: jest.fn().mockResolvedValue({ success: true }), - }, -})); - -jest.unstable_mockModule('../../database/redis.js', () => ({ - redisServer: { - setKey: jest.fn().mockResolvedValue(undefined), - getKey: jest.fn().mockResolvedValue(null), - deleteKey: jest.fn().mockResolvedValue(undefined), - }, -})); - -jest.unstable_mockModule('log4js', () => ({ - default: { - getLogger: () => ({ - level: 'info', - debug: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - trace: jest.fn(), - info: jest.fn(), - }), - }, -})); - -jest.unstable_mockModule('../../config.js', () => ({ - loadConfig: jest.fn(() => ({ - server: { - logLevel: 'info', - }, - })), -})); - -const { LockManager } = await import('../lockmanager.js'); -const { natsServer } = await import('../../database/nats.js'); -const { redisServer } = await import('../../database/redis.js'); - -describe('LockManager', () => { - let mockSocketClient; - let lockManager; - - beforeEach(() => { - jest.clearAllMocks(); - - mockSocketClient = { - id: 'test-socket-id', - socket: { - emit: jest.fn(), - }, - }; - - lockManager = new LockManager(mockSocketClient); - }); - - describe('lockObject', () => { - it('should lock an object and publish via NATS', async () => { - const testObject = { - _id: 'test-id-123', - type: 'product', - user: 'user-123', - }; - - const result = await lockManager.lockObject(testObject); - - expect(result).toBe(true); - expect(redisServer.setKey).toHaveBeenCalledWith( - 'locks:products:test-id-123', - expect.objectContaining({ - _id: 'test-id-123', - type: 'product', - user: 'user-123', - locked: true, - }) - ); - expect(natsServer.publish).toHaveBeenCalledWith( - 'locks.products.test-id-123', - expect.objectContaining({ - locked: true, - }) - ); - }); - - it('should handle errors when locking fails', async () => { - const testObject = { - _id: 'test-id-123', - type: 'product', - }; - - redisServer.setKey.mockRejectedValueOnce(new Error('Redis error')); - - await expect(lockManager.lockObject(testObject)).rejects.toThrow('Redis error'); - }); - }); - - describe('unlockObject', () => { - it('should unlock an object when user matches', async () => { - const testObject = { - _id: 'test-id-123', - type: 'product', - user: 'user-123', - }; - - redisServer.getKey.mockResolvedValueOnce({ - _id: 'test-id-123', - type: 'product', - user: 'user-123', - locked: true, - }); - - const result = await lockManager.unlockObject(testObject); - - expect(result).toBe(true); - expect(redisServer.deleteKey).toHaveBeenCalledWith('locks:products:test-id-123'); - expect(natsServer.publish).toHaveBeenCalledWith( - 'locks.products.test-id-123', - expect.objectContaining({ - _id: 'test-id-123', - type: 'product', - locked: false, - }) - ); - }); - - it('should not unlock when user does not match', async () => { - const testObject = { - _id: 'test-id-123', - type: 'product', - user: 'user-123', - }; - - redisServer.getKey.mockResolvedValueOnce({ - _id: 'test-id-123', - type: 'product', - user: 'different-user', - locked: true, - }); - - const result = await lockManager.unlockObject(testObject); - - expect(result).toBeUndefined(); - expect(redisServer.deleteKey).not.toHaveBeenCalled(); - expect(natsServer.publish).not.toHaveBeenCalled(); - }); - - it('should handle errors when unlocking fails', async () => { - const testObject = { - _id: 'test-id-123', - type: 'product', - user: 'user-123', - }; - - redisServer.getKey.mockRejectedValueOnce(new Error('Redis error')); - - await expect(lockManager.unlockObject(testObject)).rejects.toThrow('Redis error'); - }); - }); - - describe('getObjectLock', () => { - it('should return locked status when object is locked', async () => { - const testObject = { - _id: 'test-id-123', - type: 'product', - }; - - redisServer.getKey.mockResolvedValueOnce({ - _id: 'test-id-123', - type: 'product', - user: 'user-123', - locked: true, - }); - - const result = await lockManager.getObjectLock(testObject); - - expect(result).toEqual({ - _id: 'test-id-123', - type: 'product', - user: 'user-123', - locked: true, - }); - expect(redisServer.getKey).toHaveBeenCalledWith('locks:products:test-id-123'); - }); - - it('should return unlocked status when object is not locked', async () => { - const testObject = { - _id: 'test-id-123', - type: 'product', - }; - - redisServer.getKey.mockResolvedValueOnce(null); - - const result = await lockManager.getObjectLock(testObject); - - expect(result).toEqual({ - _id: 'test-id-123', - locked: false, - }); - }); - - it('should handle errors when getting lock status fails', async () => { - const testObject = { - _id: 'test-id-123', - type: 'product', - }; - - redisServer.getKey.mockRejectedValueOnce(new Error('Redis error')); - - await expect(lockManager.getObjectLock(testObject)).rejects.toThrow('Redis error'); - }); - }); - - describe('setupLocksListeners', () => { - it('should subscribe to NATS lock changes', () => { - expect(natsServer.subscribe).toHaveBeenCalledWith( - 'locks.>', - 'test-socket-id', - expect.any(Function) - ); - }); - }); -}); - diff --git a/src/lock/lockmanager.js b/src/lock/lockmanager.js deleted file mode 100644 index 88a46ec..0000000 --- a/src/lock/lockmanager.js +++ /dev/null @@ -1,117 +0,0 @@ -import { natsServer } from '../database/nats.js'; -import { redisServer } from '../database/redis.js'; -import log4js from 'log4js'; -import { loadConfig } from '../config.js'; -const config = loadConfig(); - -// Setup logger -const logger = log4js.getLogger('Lock Manager'); -logger.level = config.server.logLevel; - -/** - * LockManager handles distributed locking and broadcasts lock events via websockets. - */ -export class LockManager { - constructor(socketClient) { - this.socketClient = socketClient; - this.setupLocksListeners(); - } - - async lockObject(object) { - // Persist lock in Redis and publish via NATS - logger.debug('Locking object:', object._id); - try { - const redisKey = `locks:${object.type}s:${object._id}`; - const lockPayload = { - ...object, - locked: true - }; - await redisServer.setKey(redisKey, lockPayload); - const subject = `locks.${object.type}s.${object._id}`; - await natsServer.publish(subject, lockPayload); - logger.info(`Lock event published for id: ${object._id}`); - return true; - } catch (err) { - logger.error(`Error locking object ${object._id}:`, err); - throw err; - } - } - - async unlockObject(object) { - // Remove lock from Redis (if owned by user) and publish via NATS - const redisKey = `locks:${object.type}s:${object._id}`; - try { - logger.debug('Checking user can unlock:', object._id); - - const lockEvent = await redisServer.getKey(redisKey); - - if (lockEvent?.user === object.user) { - logger.debug('Unlocking object:', object._id); - await redisServer.deleteKey(redisKey); - const subject = `locks.${object.type}s.${object._id}`; - await natsServer.publish(subject, { - _id: object._id, - type: object.type, - locked: false - }); - logger.info(`Unlocked object and published event: ${object._id}`); - return true; - } - } catch (err) { - logger.error(`Error unlocking object ${object._id}:`, err); - throw err; - } - } - - async getObjectLock(object) { - // Get the current lock status of an object - logger.info('Getting lock status for object:', object._id); - try { - const lockKey = `locks:${object.type}s:${object._id}`; - const lockValue = await redisServer.getKey(lockKey); - - if (lockValue) { - // Object is locked - logger.debug(`Object ${object._id} is locked`); - return { - ...lockValue, - locked: true - }; - } else { - // Object is not locked - logger.debug(`Object ${object._id} is not locked`); - return { - _id: object._id, - locked: false - }; - } - } catch (err) { - logger.error(`Error getting lock status for object ${object._id}:`, err); - throw err; - } - } - - setupLocksListeners() { - // Subscribe to NATS subject for lock changes and emit via socket - const subject = 'locks.>'; - natsServer - .subscribe(subject, this.socketClient.id, (_subject, value) => { - // Expected subjects: locks.{type}s.{id} - const parts = _subject.split('.'); - const last = parts[parts.length - 1]; - const id = last; - const payload = - typeof value === 'object' - ? value - : { _id: id, locked: !!value?.locked }; - logger.debug('Lock event received:', _subject); - this.socketClient.socket.emit('lockUpdate', payload); - }) - .then(() => { - logger.info('Subscribed to NATS for lock changes.'); - }) - .catch(err => { - logger.error('Failed to subscribe to NATS lock changes:', err); - }); - } -} diff --git a/src/socket/__tests__/socketmanager.test.js b/src/socket/__tests__/socketmanager.test.js index 4b93283..ae66142 100644 --- a/src/socket/__tests__/socketmanager.test.js +++ b/src/socket/__tests__/socketmanager.test.js @@ -40,8 +40,8 @@ jest.unstable_mockModule('../../database/redis.js', () => ({ }, })); -jest.unstable_mockModule('../../lock/lockmanager.js', () => ({ - LockManager: jest.fn(), +jest.unstable_mockModule('../../activity/activitymanager.js', () => ({ + ActivityManager: jest.fn() })); jest.unstable_mockModule('../../templates/templatemanager.js', () => ({ diff --git a/src/socket/__tests__/socketuser.test.js b/src/socket/__tests__/socketuser.test.js index 67b2bd3..4592c45 100644 --- a/src/socket/__tests__/socketuser.test.js +++ b/src/socket/__tests__/socketuser.test.js @@ -18,11 +18,14 @@ jest.unstable_mockModule('../../utils.js', () => ({ generateHostOTP: jest.fn() })); -jest.unstable_mockModule('../../lock/lockmanager.js', () => ({ - LockManager: jest.fn().mockImplementation(() => ({ - lockObject: jest.fn(), - unlockObject: jest.fn(), - getObjectLock: jest.fn() +jest.unstable_mockModule('../../activity/activitymanager.js', () => ({ + ActivityManager: jest.fn().mockImplementation(() => ({ + setActivity: jest.fn(), + clearActivity: jest.fn(), + getObjectActivities: jest.fn(), + subscribeToObjectActivity: jest.fn(), + removeObjectActivityListener: jest.fn(), + removeAllListeners: jest.fn() })) })); @@ -128,7 +131,10 @@ describe('SocketUser', () => { 'authenticate', expect.any(Function) ); - expect(mockSocket.on).toHaveBeenCalledWith('lock', expect.any(Function)); + expect(mockSocket.on).toHaveBeenCalledWith( + 'setActivity', + expect.any(Function) + ); expect(mockSocket.on).toHaveBeenCalledWith( 'disconnect', expect.any(Function) @@ -183,26 +189,26 @@ describe('SocketUser', () => { }); }); - describe('lock event handlers', () => { + describe('activity event handlers', () => { beforeEach(() => { socketUser.user = { _id: 'user-id' }; }); - it('handleLockEvent should call lockManager.lockObject', async () => { - const data = { _id: 'obj-1', type: 'printer' }; - await socketUser.handleLockEvent(data); + it('handleSetActivityEvent should call activityManager.setActivity', async () => { + const data = { _id: 'obj-1', type: 'printer', mode: 'viewing' }; + await socketUser.handleSetActivityEvent(data); - expect(socketUser.lockManager.lockObject).toHaveBeenCalledWith({ + expect(socketUser.activityManager.setActivity).toHaveBeenCalledWith({ ...data, user: 'user-id' }); }); - it('handleUnlockEvent should call lockManager.unlockObject', async () => { - const data = { _id: 'obj-1' }; - await socketUser.handleUnlockEvent(data); + it('handleClearActivityEvent should call activityManager.clearActivity', async () => { + const data = { _id: 'obj-1', type: 'printer' }; + await socketUser.handleClearActivityEvent(data); - expect(socketUser.lockManager.unlockObject).toHaveBeenCalledWith({ + expect(socketUser.activityManager.clearActivity).toHaveBeenCalledWith({ ...data, user: 'user-id' }); diff --git a/src/socket/socketmanager.js b/src/socket/socketmanager.js index 32ddfdd..4e52da0 100644 --- a/src/socket/socketmanager.js +++ b/src/socket/socketmanager.js @@ -4,7 +4,6 @@ import log4js from 'log4js'; // Load configuration import { loadConfig } from '../config.js'; import { SocketUser } from './socketuser.js'; -import { LockManager } from '../lock/lockmanager.js'; import { UpdateManager } from '../updates/updatemanager.js'; import { TemplateManager } from '../templates/templatemanager.js'; import { SocketHost } from './sockethost.js'; @@ -53,7 +52,7 @@ export class SocketManager { } async addUser(socket) { - const socketUser = new SocketUser(socket, this, this.lockManager); + const socketUser = new SocketUser(socket, this); this.socketUsers.set(socketUser.id, socketUser); logger.info('External user connected. Socket ID:', socket.id); // Handle disconnection @@ -64,7 +63,7 @@ export class SocketManager { } async addHost(socket) { - const socketHost = new SocketHost(socket, this, this.lockManager); + const socketHost = new SocketHost(socket, this); this.socketHosts.set(socketHost.id, socketHost); logger.info('External host connected. Socket ID:', socket.id); // Handle disconnection diff --git a/src/socket/socketuser.js b/src/socket/socketuser.js index a8649c3..48335b4 100644 --- a/src/socket/socketuser.js +++ b/src/socket/socketuser.js @@ -3,7 +3,7 @@ import log4js from 'log4js'; import { loadConfig } from '../config.js'; import { createAuthMiddleware, KeycloakAuth } from '../auth/auth.js'; import { generateHostOTP } from '../utils.js'; -import { LockManager } from '../lock/lockmanager.js'; +import { ActivityManager } from '../activity/activitymanager.js'; import { UpdateManager } from '../updates/updatemanager.js'; import { ActionManager } from '../actions/actionmanager.js'; import { EventManager } from '../events/eventmanager.js'; @@ -25,7 +25,8 @@ export class SocketUser { this.id = null; this.user = null; this.socketManager = socketManager; - this.lockManager = new LockManager(this); + this.activityManager = new ActivityManager(this); + this.trackedActivities = new Set(); this.updateManager = new UpdateManager(this); this.actionManager = new ActionManager(this); this.eventManager = new EventManager(this); @@ -41,9 +42,17 @@ export class SocketUser { setupSocketEventHandlers() { this.socket.use(createAuthMiddleware(this)); this.socket.on('authenticate', this.handleAuthenticateEvent.bind(this)); - this.socket.on('lock', this.handleLockEvent.bind(this)); - this.socket.on('unlock', this.handleUnlockEvent.bind(this)); - this.socket.on('getLock', this.handleGetLockEvent.bind(this)); + this.socket.on('setActivity', this.handleSetActivityEvent.bind(this)); + this.socket.on('clearActivity', this.handleClearActivityEvent.bind(this)); + this.socket.on('getActivities', this.handleGetActivitiesEvent.bind(this)); + this.socket.on( + 'subscribeToObjectActivity', + this.handleSubscribeToObjectActivityEvent.bind(this) + ); + this.socket.on( + 'unsubscribeObjectActivity', + this.handleUnsubscribeObjectActivityEvent.bind(this) + ); this.socket.on( 'subscribeToObjectTypeUpdate', this.handleSubscribeToObjectTypeUpdateEvent.bind(this) @@ -113,6 +122,7 @@ export class SocketUser { logger.info('User authenticated and valid.'); this.user = result.user; + console.log('user', this.user); this.id = this.user._id.toString(); this.authenticated = true; await this.notificationManager.subscribe(); @@ -123,61 +133,115 @@ export class SocketUser { } } - async handleLockEvent(data) { - // data: { _id: string, params?: object } - if (!data || !data._id) { - this.socket.emit('lock_result', { + trackActivity(type, id) { + this.trackedActivities.add(`${type}|${id}`); + } + + untrackActivity(type, id) { + this.trackedActivities.delete(`${type}|${id}`); + } + + async handleSetActivityEvent(data, callback) { + const respond = result => { + if (typeof callback === 'function') { + callback(result); + } else { + this.socket.emit('setActivity_result', result); + } + }; + + if (!data || !data._id || !data.type || !data.mode) { + respond({ success: false, - error: 'Invalid lock event data' + error: 'Invalid setActivity event data' }); return; } data = { ...data, user: this.user._id.toString() }; try { - await this.lockManager.lockObject(data); + const result = await this.activityManager.setActivity(data); + if (result?.success === false) { + respond(result); + return; + } + + this.trackActivity(data.type, data._id); + respond(result); } catch (err) { - logger.error('Lock event error:', err); - this.socket.emit('lock_result', { success: false, error: err.message }); + logger.error('Set activity event error:', err); + respond({ + success: false, + error: err.message + }); } } - async handleUnlockEvent(data) { - // data: { _id: string } - if (!data || !data._id) { - this.socket.emit('unlock_result', { + async handleClearActivityEvent(data) { + if (!data || !data._id || !data.type) { + this.socket.emit('clearActivity_result', { success: false, - error: 'Invalid unlock event data' + error: 'Invalid clearActivity event data' }); return; } data = { ...data, user: this.user._id.toString() }; try { - await this.lockManager.unlockObject(data); + await this.activityManager.clearActivity(data); + this.untrackActivity(data.type, data._id); } catch (err) { - logger.error('Unlock event error:', err); - this.socket.emit('unlock_result', { success: false, error: err.message }); + logger.error('Clear activity event error:', err); + this.socket.emit('clearActivity_result', { + success: false, + error: err.message + }); } } - async handleGetLockEvent(data, callback) { - // data: { _id: string } - if (!data || !data._id) { + async handleGetActivitiesEvent(data, callback) { + if (!data || !data._id || !data.type) { callback({ - error: 'Invalid getLock event data' + error: 'Invalid getActivities event data' }); return; } try { - const lockEvent = await this.lockManager.getObjectLock(data); - callback(lockEvent); + const activities = await this.activityManager.getObjectActivities(data); + callback({ activities }); } catch (err) { - logger.error('GetLock event error:', err); + logger.error('GetActivities event error:', err); callback({ error: err.message }); } } + async handleSubscribeToObjectActivityEvent(data, callback) { + if (!data || !data._id || !data.objectType) { + if (typeof callback === 'function') { + callback({ + success: false, + error: 'Invalid subscribeToObjectActivity event data' + }); + } + return; + } + + const result = await this.activityManager.subscribeToObjectActivity( + data._id, + data.objectType + ); + if (typeof callback === 'function') { + callback(result); + } + } + + async handleUnsubscribeObjectActivityEvent(data) { + await this.activityManager.removeObjectActivityListener( + data._id, + data.objectType + ); + } + async handleSubscribeToObjectTypeUpdateEvent(data, callback) { await this.updateManager.subscribeToObjectNew(data.objectType, data.filter); await this.updateManager.subscribeToObjectDelete( @@ -305,6 +369,26 @@ export class SocketUser { } async handleDisconnect() { + if (this.user?._id) { + const userId = this.user._id.toString(); + for (const activityKey of this.trackedActivities) { + const separatorIndex = activityKey.indexOf('|'); + const type = activityKey.slice(0, separatorIndex); + const id = activityKey.slice(separatorIndex + 1); + try { + await this.activityManager.clearActivity({ + _id: id, + type, + user: userId + }); + } catch (err) { + logger.error('Error clearing activity on disconnect:', err); + } + } + this.trackedActivities.clear(); + } + + await this.activityManager.removeAllListeners(); await this.actionManager.removeAllListeners(); await this.updateManager.removeAllListeners(); await this.eventManager.removeAllListeners();