Implemented notifications.
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
This commit is contained in:
parent
ce49b257a6
commit
7a398c79a1
@ -2,7 +2,7 @@
|
||||
"development": {
|
||||
"server": {
|
||||
"port": 9090,
|
||||
"logLevel": "debug"
|
||||
"logLevel": "trace"
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
|
||||
51
src/database/schemas/misc/notification.schema.js
Normal file
51
src/database/schemas/misc/notification.schema.js
Normal 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);
|
||||
44
src/database/schemas/misc/usernotifier.schema.js
Normal file
44
src/database/schemas/misc/usernotifier.schema.js
Normal 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);
|
||||
@ -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',
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user