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": {
|
"development": {
|
||||||
"server": {
|
"server": {
|
||||||
"port": 9090,
|
"port": 9090,
|
||||||
"logLevel": "debug"
|
"logLevel": "trace"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"enabled": true,
|
"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 { userModel } from './management/user.schema.js';
|
||||||
import { noteTypeModel } from './management/notetype.schema.js';
|
import { noteTypeModel } from './management/notetype.schema.js';
|
||||||
import { noteModel } from './misc/note.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 { documentSizeModel } from './management/documentsize.schema.js';
|
||||||
import { documentTemplateModel } from './management/documenttemplate.schema.js';
|
import { documentTemplateModel } from './management/documenttemplate.schema.js';
|
||||||
import { hostModel } from './management/host.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' },
|
USR: { model: userModel, idField: '_id', type: 'user', referenceField: '_reference' },
|
||||||
NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' },
|
NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' },
|
||||||
NTE: { model: noteModel, idField: '_id', type: 'note', 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: {
|
DSZ: {
|
||||||
model: documentSizeModel,
|
model: documentSizeModel,
|
||||||
idField: '_id',
|
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', () => ({
|
jest.unstable_mockModule('log4js', () => ({
|
||||||
default: {
|
default: {
|
||||||
getLogger: () => ({
|
getLogger: () => ({
|
||||||
@ -130,6 +137,7 @@ describe('SocketUser', () => {
|
|||||||
expect(socketUser.authenticated).toBe(true);
|
expect(socketUser.authenticated).toBe(true);
|
||||||
expect(socketUser.user).toEqual(mockUser);
|
expect(socketUser.user).toEqual(mockUser);
|
||||||
expect(socketUser.id).toBe('user-id-obj');
|
expect(socketUser.id).toBe('user-id-obj');
|
||||||
|
expect(socketUser.notificationManager.subscribe).toHaveBeenCalled();
|
||||||
expect(callback).toHaveBeenCalledWith({ valid: true, user: mockUser });
|
expect(callback).toHaveBeenCalledWith({ valid: true, user: mockUser });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -192,6 +200,7 @@ describe('SocketUser', () => {
|
|||||||
expect(socketUser.actionManager.removeAllListeners).toHaveBeenCalled();
|
expect(socketUser.actionManager.removeAllListeners).toHaveBeenCalled();
|
||||||
expect(socketUser.eventManager.removeAllListeners).toHaveBeenCalled();
|
expect(socketUser.eventManager.removeAllListeners).toHaveBeenCalled();
|
||||||
expect(socketUser.statsManager.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 { ActionManager } from '../actions/actionmanager.js';
|
||||||
import { EventManager } from '../events/eventmanager.js';
|
import { EventManager } from '../events/eventmanager.js';
|
||||||
import { StatsManager } from '../stats/statsmanager.js';
|
import { StatsManager } from '../stats/statsmanager.js';
|
||||||
|
import { NotificationManager } from '../notification/notificationmanager.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
@ -27,6 +28,7 @@ export class SocketUser {
|
|||||||
this.actionManager = new ActionManager(this);
|
this.actionManager = new ActionManager(this);
|
||||||
this.eventManager = new EventManager(this);
|
this.eventManager = new EventManager(this);
|
||||||
this.statsManager = new StatsManager(this);
|
this.statsManager = new StatsManager(this);
|
||||||
|
this.notificationManager = new NotificationManager(this);
|
||||||
this.templateManager = socketManager.templateManager;
|
this.templateManager = socketManager.templateManager;
|
||||||
this.keycloakAuth = new KeycloakAuth();
|
this.keycloakAuth = new KeycloakAuth();
|
||||||
this.setupSocketEventHandlers();
|
this.setupSocketEventHandlers();
|
||||||
@ -97,6 +99,7 @@ export class SocketUser {
|
|||||||
this.user = result.user;
|
this.user = result.user;
|
||||||
this.id = this.user._id.toString();
|
this.id = this.user._id.toString();
|
||||||
this.authenticated = true;
|
this.authenticated = true;
|
||||||
|
await this.notificationManager.subscribe();
|
||||||
} else {
|
} else {
|
||||||
logger.warn('User is not authenticated.');
|
logger.warn('User is not authenticated.');
|
||||||
}
|
}
|
||||||
@ -246,6 +249,7 @@ export class SocketUser {
|
|||||||
await this.actionManager.removeAllListeners();
|
await this.actionManager.removeAllListeners();
|
||||||
await this.eventManager.removeAllListeners();
|
await this.eventManager.removeAllListeners();
|
||||||
await this.statsManager.removeAllListeners();
|
await this.statsManager.removeAllListeners();
|
||||||
|
await this.notificationManager.removeAllListeners();
|
||||||
logger.info('External user disconnected:', this.socket.user?.username);
|
logger.info('External user disconnected:', this.socket.user?.username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user