diff --git a/config.json b/config.json index 66e68da..275f89d 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "development": { "server": { "port": 9090, - "logLevel": "debug" + "logLevel": "trace" }, "auth": { "enabled": true, diff --git a/src/database/schemas/misc/notification.schema.js b/src/database/schemas/misc/notification.schema.js new file mode 100644 index 0000000..aff1168 --- /dev/null +++ b/src/database/schemas/misc/notification.schema.js @@ -0,0 +1,51 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const notificationSchema = new mongoose.Schema({ + _reference: { type: String, default: () => generateId()() }, + user: { + type: Schema.Types.ObjectId, + ref: 'user', + required: true, + }, + title: { + type: String, + required: true, + }, + message: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + default: 'info', + }, + read: { + type: Boolean, + default: false, + }, + metadata: { + type: Object, + required: false, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + updatedAt: { + type: Date, + required: true, + default: Date.now, + }, +}); + +notificationSchema.virtual('id').get(function () { + return this._id; +}); + +notificationSchema.set('toJSON', { virtuals: true }); + +export const notificationModel = mongoose.model('notification', notificationSchema); diff --git a/src/database/schemas/misc/usernotifier.schema.js b/src/database/schemas/misc/usernotifier.schema.js new file mode 100644 index 0000000..eb9cd3a --- /dev/null +++ b/src/database/schemas/misc/usernotifier.schema.js @@ -0,0 +1,44 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const userNotifierSchema = new mongoose.Schema({ + _reference: { type: String, default: () => generateId()() }, + user: { + type: Schema.Types.ObjectId, + ref: 'user', + required: true, + }, + email: { + type: Boolean, + required: true, + default: false, + }, + object: { + type: Schema.Types.ObjectId, + refPath: 'objectType', + required: true, + }, + objectType: { + type: String, + required: true, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + updatedAt: { + type: Date, + required: true, + default: Date.now, + }, +}); + +userNotifierSchema.virtual('id').get(function () { + return this._id; +}); + +userNotifierSchema.set('toJSON', { virtuals: true }); + +export const userNotifierModel = mongoose.model('userNotifier', userNotifierSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 21315df..1db6e79 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -16,6 +16,8 @@ import { auditLogModel } from './management/auditlog.schema.js'; import { userModel } from './management/user.schema.js'; import { noteTypeModel } from './management/notetype.schema.js'; import { noteModel } from './misc/note.schema.js'; +import { notificationModel } from './misc/notification.schema.js'; +import { userNotifierModel } from './misc/usernotifier.schema.js'; import { documentSizeModel } from './management/documentsize.schema.js'; import { documentTemplateModel } from './management/documenttemplate.schema.js'; import { hostModel } from './management/host.schema.js'; @@ -55,6 +57,18 @@ export const models = { USR: { model: userModel, idField: '_id', type: 'user', referenceField: '_reference' }, NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' }, NTE: { model: noteModel, idField: '_id', type: 'note', referenceField: '_reference' }, + NTF: { + model: notificationModel, + idField: '_id', + type: 'notification', + referenceField: '_reference', + }, + ONF: { + model: userNotifierModel, + idField: '_id', + type: 'userNotifier', + referenceField: '_reference', + }, DSZ: { model: documentSizeModel, idField: '_id', diff --git a/src/notification/notificationmanager.js b/src/notification/notificationmanager.js index e69de29..da1aa81 100644 --- a/src/notification/notificationmanager.js +++ b/src/notification/notificationmanager.js @@ -0,0 +1,80 @@ +import log4js from 'log4js'; +import { loadConfig } from '../config.js'; +import { natsServer } from '../database/nats.js'; + +const config = loadConfig(); + +// Setup logger +const logger = log4js.getLogger('Notification Manager'); +logger.level = config.server.logLevel; + +/** + * NotificationManager subscribes to NATS subject notifications.${userId} for the + * authenticated user. The API publishes notification JSON to this subject; we + * forward each notification to the socket client. + */ +export class NotificationManager { + constructor(socketClient) { + this.socketClient = socketClient; + this.subject = null; + this.subscribed = false; + } + + /** + * Subscribe to NATS subject notifications.${userId}. The subject outputs + * notification JSON which we emit to the socket client. + * Call this when the user authenticates. + */ + async subscribe() { + if (!this.socketClient.user || !this.socketClient.authenticated) { + logger.debug('Cannot subscribe: user not authenticated'); + return { success: false, error: 'User not authenticated' }; + } + + if (this.subscribed) { + logger.debug('Already subscribed to user notifications'); + return { success: true }; + } + + const userId = + this.socketClient.user._id?.toString?.() || this.socketClient.id; + if (!userId) { + logger.warn('No user id available for notification subscription'); + return { success: false, error: 'No user id' }; + } + + try { + const subject = `notifications.${userId}`; + await natsServer.subscribe( + subject, + this.socketClient.socketId, + (key, notificationJson) => { + logger.trace('Notification received for user:', userId); + this.socketClient.socket.emit('notification', notificationJson); + } + ); + + this.subject = subject; + this.subscribed = true; + logger.debug('Subscribed to notifications for user:', userId); + return { success: true }; + } catch (error) { + logger.error('Failed to subscribe to user notifications:', error); + return { success: false, error: error?.message }; + } + } + + async removeAllListeners() { + logger.debug('Removing notification listener...'); + if (this.subject) { + await natsServer.removeSubscription( + this.subject, + this.socketClient.socketId + ); + this.subject = null; + } + this.subscribed = false; + logger.debug('Removed notification listener'); + return { success: true }; + } +} diff --git a/src/socket/__tests__/socketuser.test.js b/src/socket/__tests__/socketuser.test.js index 544a3e8..cc06dc7 100644 --- a/src/socket/__tests__/socketuser.test.js +++ b/src/socket/__tests__/socketuser.test.js @@ -60,6 +60,13 @@ jest.unstable_mockModule('../../stats/statsmanager.js', () => ({ })) })); +jest.unstable_mockModule('../../notification/notificationmanager.js', () => ({ + NotificationManager: jest.fn().mockImplementation(() => ({ + subscribe: jest.fn().mockResolvedValue({ success: true }), + removeAllListeners: jest.fn().mockResolvedValue({ success: true }) + })) +})); + jest.unstable_mockModule('log4js', () => ({ default: { getLogger: () => ({ @@ -130,6 +137,7 @@ describe('SocketUser', () => { expect(socketUser.authenticated).toBe(true); expect(socketUser.user).toEqual(mockUser); expect(socketUser.id).toBe('user-id-obj'); + expect(socketUser.notificationManager.subscribe).toHaveBeenCalled(); expect(callback).toHaveBeenCalledWith({ valid: true, user: mockUser }); }); @@ -192,6 +200,7 @@ describe('SocketUser', () => { expect(socketUser.actionManager.removeAllListeners).toHaveBeenCalled(); expect(socketUser.eventManager.removeAllListeners).toHaveBeenCalled(); expect(socketUser.statsManager.removeAllListeners).toHaveBeenCalled(); + expect(socketUser.notificationManager.removeAllListeners).toHaveBeenCalled(); }); }); }); diff --git a/src/socket/socketuser.js b/src/socket/socketuser.js index 3662565..9f07609 100644 --- a/src/socket/socketuser.js +++ b/src/socket/socketuser.js @@ -8,6 +8,7 @@ import { UpdateManager } from '../updates/updatemanager.js'; import { ActionManager } from '../actions/actionmanager.js'; import { EventManager } from '../events/eventmanager.js'; import { StatsManager } from '../stats/statsmanager.js'; +import { NotificationManager } from '../notification/notificationmanager.js'; const config = loadConfig(); @@ -27,6 +28,7 @@ export class SocketUser { this.actionManager = new ActionManager(this); this.eventManager = new EventManager(this); this.statsManager = new StatsManager(this); + this.notificationManager = new NotificationManager(this); this.templateManager = socketManager.templateManager; this.keycloakAuth = new KeycloakAuth(); this.setupSocketEventHandlers(); @@ -97,6 +99,7 @@ export class SocketUser { this.user = result.user; this.id = this.user._id.toString(); this.authenticated = true; + await this.notificationManager.subscribe(); } else { logger.warn('User is not authenticated.'); } @@ -246,6 +249,7 @@ export class SocketUser { await this.actionManager.removeAllListeners(); await this.eventManager.removeAllListeners(); await this.statsManager.removeAllListeners(); + await this.notificationManager.removeAllListeners(); logger.info('External user disconnected:', this.socket.user?.username); } }