Improved notifications.
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit

This commit is contained in:
Tom Butcher 2026-03-01 19:21:05 +00:00
parent 6e3b900423
commit a9c4b29f9f
5 changed files with 75 additions and 16 deletions

View File

@ -8,7 +8,7 @@ import NodeCache from 'node-cache';
import { userModel } from './database/schemas/management/user.schema.js'; import { userModel } from './database/schemas/management/user.schema.js';
import { getObject } from './database/database.js'; import { getObject } from './database/database.js';
import { hostModel } from './database/schemas/management/host.schema.js'; import { hostModel } from './database/schemas/management/host.schema.js';
import { getSession } from './services/misc/auth.js'; import { getSession, lookupUserByToken } from './services/misc/auth.js';
const logger = log4js.getLogger('Keycloak'); const logger = log4js.getLogger('Keycloak');
logger.level = config.server.logLevel || 'info'; logger.level = config.server.logLevel || 'info';
@ -50,7 +50,7 @@ const lookupUser = async (preferredUsername) => {
/** /**
* Middleware to check if the user is authenticated. * Middleware to check if the user is authenticated.
* Supports: 1) Bearer token (Redis session), 2) x-host-id + x-auth-code (host auth) * Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer), 3) x-host-id + x-auth-code (host auth)
*/ */
const isAuthenticated = async (req, res, next) => { const isAuthenticated = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization; const authHeader = req.headers.authorization || req.headers.Authorization;
@ -64,6 +64,14 @@ const isAuthenticated = async (req, res, next) => {
req.session = session; req.session = session;
return next(); return next();
} }
// Try email-render JWT (short-lived token for Puppeteer email notifications)
const user = await lookupUserByToken(token);
if (user) {
req.user = user;
req.session = { user };
return next();
}
} catch (error) { } catch (error) {
logger.error('Session lookup error:', error.message); logger.error('Session lookup error:', error.message);
} }
@ -77,7 +85,7 @@ const isAuthenticated = async (req, res, next) => {
return next(); return next();
} }
} }
logger.debug('Not authenticated', { hostId, authCode }, 'req.headers', req.headers);
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
}; };

View File

@ -41,8 +41,18 @@ const logger = log4js.getLogger('MailWorker');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
async function sendEmail(payload) { async function sendEmail(payload) {
const { email, title, message, type, metadata, smtpConfig, urlClient, createdAt, updatedAt, authCode } = const {
payload; email,
title,
message,
type,
metadata,
smtpConfig,
urlClient,
createdAt,
updatedAt,
authCode,
} = payload;
if (!email || !smtpConfig?.host) { if (!email || !smtpConfig?.host) {
logger.warn('Missing email or SMTP config, skipping...'); logger.warn('Missing email or SMTP config, skipping...');
@ -56,11 +66,9 @@ async function sendEmail(payload) {
email: email || '', email: email || '',
createdAt: createdAt || new Date(), createdAt: createdAt || new Date(),
updatedAt: updatedAt || new Date(), updatedAt: updatedAt || new Date(),
authCode: authCode || '',
metadata: JSON.stringify(metadata || {}), metadata: JSON.stringify(metadata || {}),
}); });
if (authCode) {
params.set('authCode', authCode);
}
const templateUrl = `${baseUrl(urlClient)}/email/notification?${params.toString()}`; const templateUrl = `${baseUrl(urlClient)}/email/notification?${params.toString()}`;
logger.debug('Rendering template...'); logger.debug('Rendering template...');
@ -70,14 +78,24 @@ async function sendEmail(payload) {
let browser; let browser;
try { try {
browser = await puppeteer.launch({ browser = await puppeteer.launch({
headless: true, headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'], args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-features=SameSiteByDefaultCookies',
],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto(templateUrl, { waitUntil: 'networkidle0', timeout: 15000 }); page.on('console', (msg) => {
const text = msg.text();
const type = msg.type();
logger.trace(`Puppeteer [${type}]: ${text}`);
});
await page.goto(templateUrl, { waitUntil: 'networkidle0', timeout: 30000 });
await page.waitForSelector('#email-notification-root[data-rendered="true"]', { timeout: 5000 }); await page.waitForSelector('#email-notification-root[data-rendered="true"]', { timeout: 5000 });
// Wait for Ant Design CSS-in-JS to finish injecting styles // Wait for Ant Design CSS-in-JS to finish injecting styles
await new Promise((r) => setTimeout(r, 300)); logger.debug('Waiting for 1.5 seconds for page to render...');
await new Promise((r) => setTimeout(r, 1500));
html = await page.evaluate(() => { html = await page.evaluate(() => {
const root = document.getElementById('email-notification-root'); const root = document.getElementById('email-notification-root');
if (!root) return document.documentElement.outerHTML; if (!root) return document.documentElement.outerHTML;

View File

@ -39,7 +39,7 @@ const lookupUserByEmailRenderToken = async (token) => {
return cachedUser; return cachedUser;
} }
const decodedToken = jwt.decode(token); const decodedToken = jwt.verify(token, config.auth.sessionSecret);
if (!decodedToken || !decodedToken.preferred_username) { if (!decodedToken || !decodedToken.preferred_username) {
logger.trace('Invalid token or missing preferred_username'); logger.trace('Invalid token or missing preferred_username');
return null; return null;
@ -325,12 +325,14 @@ export const logoutRouteHandler = async (req, res) => {
export const validateTokenMiddleware = async (req, res, next) => { export const validateTokenMiddleware = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization; const authHeader = req.headers.authorization || req.headers.Authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.debug('No auth header or not bearer token');
return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' }); return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' });
} }
const token = authHeader.substring(7); const token = authHeader.substring(7);
const session = await getSession(token); const session = await getSession(token);
if (!session) { if (!session) {
logger.debug('Session not found');
return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' }); return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' });
} }

View File

@ -13,6 +13,7 @@ import {
getModelHistory, getModelHistory,
} from '../../database/database.js'; } from '../../database/database.js';
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import { newNoteNotification } from '../../utils.js';
const logger = log4js.getLogger('Notes'); const logger = log4js.getLogger('Notes');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
@ -127,6 +128,9 @@ export const newNoteRouteHandler = async (req, res) => {
newData, newData,
user: req.user, user: req.user,
}); });
await newNoteNotification({ ...result, ...newData }, req.user);
if (result.error) { if (result.error) {
logger.error('No note created:', result.error); logger.error('No note created:', result.error);
return res.status(result.code).send(result); return res.status(result.code).send(result);

View File

@ -434,8 +434,12 @@ async function editNotification(oldValue, newValue, parentId, parentType, user)
old: changedOldValues, old: changedOldValues,
new: changedNewValues, new: changedNewValues,
objectType: parentType, objectType: parentType,
object: { _id: parentId }, object: { _id: String(parentId ?? '') },
user: { _id: user._id, firstName: user.firstName, lastName: user.lastName }, user: {
_id: String(user?._id ?? ''),
firstName: user.firstName,
lastName: user.lastName,
},
} }
); );
} }
@ -461,7 +465,7 @@ async function deleteAuditLog(deleteValue, parentId, parentType, user) {
async function deleteNotification(object, parentId, parentType, user) { async function deleteNotification(object, parentId, parentType, user) {
const model = getModelByName(parentType); const model = getModelByName(parentType);
const objectName = oldValue?.name ?? newValue?.name ?? model?.label ?? parentType; const objectName = object?.name || model?.label || parentType;
await notfiyObjectUserNotifiers( await notfiyObjectUserNotifiers(
parentId, parentId,
parentType, parentType,
@ -477,6 +481,28 @@ async function deleteNotification(object, parentId, parentType, user) {
); );
} }
async function newNoteNotification(note, user) {
const model = getModelByName(note.parentType);
const objectName = model?.label ?? note.parentType;
await notfiyObjectUserNotifiers(
note.parent,
note.parentType,
`New note added to ${objectName.toLowerCase()} by ${user?.firstName ?? 'unknown'} ${user?.lastName ?? ''}`,
`A new note has been created.`,
'newNote',
{
objectType: note.parentType,
object: { _id: String(note.parent ?? '') },
note: note,
user: {
_id: String(user?._id ?? ''),
firstName: user?.firstName,
lastName: user?.lastName,
},
}
);
}
async function getAuditLogs(idOrIds) { async function getAuditLogs(idOrIds) {
if (Array.isArray(idOrIds)) { if (Array.isArray(idOrIds)) {
return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner'); return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner');
@ -875,6 +901,7 @@ export {
getAuditLogs, getAuditLogs,
flatternObjectIds, flatternObjectIds,
expandObjectIds, expandObjectIds,
newNoteNotification,
distributeUpdate, distributeUpdate,
distributeStats, distributeStats,
distributeNew, distributeNew,