Add ActivityManager and integrate activity tracking functionality
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good

- 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.
This commit is contained in:
Tom Butcher 2026-07-26 00:43:24 +01:00
parent e80022513f
commit 49226290b6
8 changed files with 657 additions and 391 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -40,8 +40,8 @@ jest.unstable_mockModule('../../database/redis.js', () => ({
}, },
})); }));
jest.unstable_mockModule('../../lock/lockmanager.js', () => ({ jest.unstable_mockModule('../../activity/activitymanager.js', () => ({
LockManager: jest.fn(), ActivityManager: jest.fn()
})); }));
jest.unstable_mockModule('../../templates/templatemanager.js', () => ({ jest.unstable_mockModule('../../templates/templatemanager.js', () => ({

View File

@ -18,11 +18,14 @@ jest.unstable_mockModule('../../utils.js', () => ({
generateHostOTP: jest.fn() generateHostOTP: jest.fn()
})); }));
jest.unstable_mockModule('../../lock/lockmanager.js', () => ({ jest.unstable_mockModule('../../activity/activitymanager.js', () => ({
LockManager: jest.fn().mockImplementation(() => ({ ActivityManager: jest.fn().mockImplementation(() => ({
lockObject: jest.fn(), setActivity: jest.fn(),
unlockObject: jest.fn(), clearActivity: jest.fn(),
getObjectLock: jest.fn() getObjectActivities: jest.fn(),
subscribeToObjectActivity: jest.fn(),
removeObjectActivityListener: jest.fn(),
removeAllListeners: jest.fn()
})) }))
})); }));
@ -128,7 +131,10 @@ describe('SocketUser', () => {
'authenticate', 'authenticate',
expect.any(Function) expect.any(Function)
); );
expect(mockSocket.on).toHaveBeenCalledWith('lock', expect.any(Function)); expect(mockSocket.on).toHaveBeenCalledWith(
'setActivity',
expect.any(Function)
);
expect(mockSocket.on).toHaveBeenCalledWith( expect(mockSocket.on).toHaveBeenCalledWith(
'disconnect', 'disconnect',
expect.any(Function) expect.any(Function)
@ -183,26 +189,26 @@ describe('SocketUser', () => {
}); });
}); });
describe('lock event handlers', () => { describe('activity event handlers', () => {
beforeEach(() => { beforeEach(() => {
socketUser.user = { _id: 'user-id' }; socketUser.user = { _id: 'user-id' };
}); });
it('handleLockEvent should call lockManager.lockObject', async () => { it('handleSetActivityEvent should call activityManager.setActivity', async () => {
const data = { _id: 'obj-1', type: 'printer' }; const data = { _id: 'obj-1', type: 'printer', mode: 'viewing' };
await socketUser.handleLockEvent(data); await socketUser.handleSetActivityEvent(data);
expect(socketUser.lockManager.lockObject).toHaveBeenCalledWith({ expect(socketUser.activityManager.setActivity).toHaveBeenCalledWith({
...data, ...data,
user: 'user-id' user: 'user-id'
}); });
}); });
it('handleUnlockEvent should call lockManager.unlockObject', async () => { it('handleClearActivityEvent should call activityManager.clearActivity', async () => {
const data = { _id: 'obj-1' }; const data = { _id: 'obj-1', type: 'printer' };
await socketUser.handleUnlockEvent(data); await socketUser.handleClearActivityEvent(data);
expect(socketUser.lockManager.unlockObject).toHaveBeenCalledWith({ expect(socketUser.activityManager.clearActivity).toHaveBeenCalledWith({
...data, ...data,
user: 'user-id' user: 'user-id'
}); });

View File

@ -4,7 +4,6 @@ import log4js from 'log4js';
// Load configuration // Load configuration
import { loadConfig } from '../config.js'; import { loadConfig } from '../config.js';
import { SocketUser } from './socketuser.js'; import { SocketUser } from './socketuser.js';
import { LockManager } from '../lock/lockmanager.js';
import { UpdateManager } from '../updates/updatemanager.js'; import { UpdateManager } from '../updates/updatemanager.js';
import { TemplateManager } from '../templates/templatemanager.js'; import { TemplateManager } from '../templates/templatemanager.js';
import { SocketHost } from './sockethost.js'; import { SocketHost } from './sockethost.js';
@ -53,7 +52,7 @@ export class SocketManager {
} }
async addUser(socket) { async addUser(socket) {
const socketUser = new SocketUser(socket, this, this.lockManager); const socketUser = new SocketUser(socket, this);
this.socketUsers.set(socketUser.id, socketUser); this.socketUsers.set(socketUser.id, socketUser);
logger.info('External user connected. Socket ID:', socket.id); logger.info('External user connected. Socket ID:', socket.id);
// Handle disconnection // Handle disconnection
@ -64,7 +63,7 @@ export class SocketManager {
} }
async addHost(socket) { async addHost(socket) {
const socketHost = new SocketHost(socket, this, this.lockManager); const socketHost = new SocketHost(socket, this);
this.socketHosts.set(socketHost.id, socketHost); this.socketHosts.set(socketHost.id, socketHost);
logger.info('External host connected. Socket ID:', socket.id); logger.info('External host connected. Socket ID:', socket.id);
// Handle disconnection // Handle disconnection

View File

@ -3,7 +3,7 @@ import log4js from 'log4js';
import { loadConfig } from '../config.js'; import { loadConfig } from '../config.js';
import { createAuthMiddleware, KeycloakAuth } from '../auth/auth.js'; import { createAuthMiddleware, KeycloakAuth } from '../auth/auth.js';
import { generateHostOTP } from '../utils.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 { UpdateManager } from '../updates/updatemanager.js';
import { ActionManager } from '../actions/actionmanager.js'; import { ActionManager } from '../actions/actionmanager.js';
import { EventManager } from '../events/eventmanager.js'; import { EventManager } from '../events/eventmanager.js';
@ -25,7 +25,8 @@ export class SocketUser {
this.id = null; this.id = null;
this.user = null; this.user = null;
this.socketManager = socketManager; this.socketManager = socketManager;
this.lockManager = new LockManager(this); this.activityManager = new ActivityManager(this);
this.trackedActivities = new Set();
this.updateManager = new UpdateManager(this); this.updateManager = new UpdateManager(this);
this.actionManager = new ActionManager(this); this.actionManager = new ActionManager(this);
this.eventManager = new EventManager(this); this.eventManager = new EventManager(this);
@ -41,9 +42,17 @@ export class SocketUser {
setupSocketEventHandlers() { setupSocketEventHandlers() {
this.socket.use(createAuthMiddleware(this)); this.socket.use(createAuthMiddleware(this));
this.socket.on('authenticate', this.handleAuthenticateEvent.bind(this)); this.socket.on('authenticate', this.handleAuthenticateEvent.bind(this));
this.socket.on('lock', this.handleLockEvent.bind(this)); this.socket.on('setActivity', this.handleSetActivityEvent.bind(this));
this.socket.on('unlock', this.handleUnlockEvent.bind(this)); this.socket.on('clearActivity', this.handleClearActivityEvent.bind(this));
this.socket.on('getLock', this.handleGetLockEvent.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( this.socket.on(
'subscribeToObjectTypeUpdate', 'subscribeToObjectTypeUpdate',
this.handleSubscribeToObjectTypeUpdateEvent.bind(this) this.handleSubscribeToObjectTypeUpdateEvent.bind(this)
@ -113,6 +122,7 @@ export class SocketUser {
logger.info('User authenticated and valid.'); logger.info('User authenticated and valid.');
this.user = result.user; this.user = result.user;
console.log('user', this.user);
this.id = this.user._id.toString(); this.id = this.user._id.toString();
this.authenticated = true; this.authenticated = true;
await this.notificationManager.subscribe(); await this.notificationManager.subscribe();
@ -123,61 +133,115 @@ export class SocketUser {
} }
} }
async handleLockEvent(data) { trackActivity(type, id) {
// data: { _id: string, params?: object } this.trackedActivities.add(`${type}|${id}`);
if (!data || !data._id) { }
this.socket.emit('lock_result', {
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, success: false,
error: 'Invalid lock event data' error: 'Invalid setActivity event data'
}); });
return; return;
} }
data = { ...data, user: this.user._id.toString() }; data = { ...data, user: this.user._id.toString() };
try { 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) { } catch (err) {
logger.error('Lock event error:', err); logger.error('Set activity event error:', err);
this.socket.emit('lock_result', { success: false, error: err.message }); respond({
success: false,
error: err.message
});
} }
} }
async handleUnlockEvent(data) { async handleClearActivityEvent(data) {
// data: { _id: string } if (!data || !data._id || !data.type) {
if (!data || !data._id) { this.socket.emit('clearActivity_result', {
this.socket.emit('unlock_result', {
success: false, success: false,
error: 'Invalid unlock event data' error: 'Invalid clearActivity event data'
}); });
return; return;
} }
data = { ...data, user: this.user._id.toString() }; data = { ...data, user: this.user._id.toString() };
try { try {
await this.lockManager.unlockObject(data); await this.activityManager.clearActivity(data);
this.untrackActivity(data.type, data._id);
} catch (err) { } catch (err) {
logger.error('Unlock event error:', err); logger.error('Clear activity event error:', err);
this.socket.emit('unlock_result', { success: false, error: err.message }); this.socket.emit('clearActivity_result', {
success: false,
error: err.message
});
} }
} }
async handleGetLockEvent(data, callback) { async handleGetActivitiesEvent(data, callback) {
// data: { _id: string } if (!data || !data._id || !data.type) {
if (!data || !data._id) {
callback({ callback({
error: 'Invalid getLock event data' error: 'Invalid getActivities event data'
}); });
return; return;
} }
try { try {
const lockEvent = await this.lockManager.getObjectLock(data); const activities = await this.activityManager.getObjectActivities(data);
callback(lockEvent); callback({ activities });
} catch (err) { } catch (err) {
logger.error('GetLock event error:', err); logger.error('GetActivities event error:', err);
callback({ callback({
error: err.message 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) { async handleSubscribeToObjectTypeUpdateEvent(data, callback) {
await this.updateManager.subscribeToObjectNew(data.objectType, data.filter); await this.updateManager.subscribeToObjectNew(data.objectType, data.filter);
await this.updateManager.subscribeToObjectDelete( await this.updateManager.subscribeToObjectDelete(
@ -305,6 +369,26 @@ export class SocketUser {
} }
async handleDisconnect() { 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.actionManager.removeAllListeners();
await this.updateManager.removeAllListeners(); await this.updateManager.removeAllListeners();
await this.eventManager.removeAllListeners(); await this.eventManager.removeAllListeners();