Implemented notifications.
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-03-01 01:42:38 +00:00
parent ce49b257a6
commit 7a398c79a1
7 changed files with 203 additions and 1 deletions

View File

@ -2,7 +2,7 @@
"development": {
"server": {
"port": 9090,
"logLevel": "debug"
"logLevel": "trace"
},
"auth": {
"enabled": true,

View File

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

View File

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

View File

@ -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',

View File

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

View File

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

View File

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