mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
fcbdd59ec2
Adds a nullable node_id column to notification_routes (null = any node, integer = fire only when the alert originates from that specific node). This fixes a multi-node fleet defect where a route scoped to "my-app" would fire on every node that hosts a stack with that name. Backend changes: - DatabaseService: idempotent migration adds node_id INTEGER NULL and a composite index on (node_id, enabled, priority); the two statements are in separate try-catch blocks so the index is always created even when the column was added in an earlier run - NotificationService: route matcher now pre-filters by node_id before checking stack_patterns (== null matches any node) - notifications route: POST/PUT accept optional node_id, validated to be null or the local node's ID; NodeRegistry guards against cross-node misroutes Frontend changes: - NotificationRoutingSection: node scope Select field uses useNodes() from NodeContext (no extra API call) to populate the local node option - Route cards show a node badge when node_id is set Tests: 3 new tests covering node-match, node-mismatch, and null-scope; all 75 files (1413 tests) passing.
282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import { NotificationService } from '../services/NotificationService';
|
|
import { NodeRegistry } from '../services/NodeRegistry';
|
|
import { authMiddleware } from '../middleware/auth';
|
|
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
|
import {
|
|
NOTIFICATION_CHANNEL_TYPES,
|
|
validateHttpsUrl,
|
|
cleanStackPatterns,
|
|
} from '../helpers/notificationChannels';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
|
|
function parseRouteId(req: Request, res: Response): number | null {
|
|
const id = parseInt(req.params.id as string, 10);
|
|
if (isNaN(id)) {
|
|
res.status(400).json({ error: 'Invalid route ID' });
|
|
return null;
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function validateNodeId(nodeId: unknown, res: Response): number | null | false {
|
|
if (nodeId === undefined || nodeId === null) return null;
|
|
if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) {
|
|
res.status(400).json({ error: 'node_id must be an integer or null' });
|
|
return false;
|
|
}
|
|
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
|
if (nodeId !== localNodeId) {
|
|
res.status(400).json({ error: 'node_id must match the local node or be null' });
|
|
return false;
|
|
}
|
|
return nodeId;
|
|
}
|
|
|
|
export const notificationsRouter = Router();
|
|
|
|
notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const nodeId = req.nodeId ?? 0;
|
|
const category = typeof req.query.category === 'string' ? req.query.category : undefined;
|
|
const history = DatabaseService.getInstance().getNotificationHistory(nodeId, 50, category);
|
|
res.json(history);
|
|
} catch {
|
|
res.status(500).json({ error: 'Failed to fetch notifications' });
|
|
}
|
|
});
|
|
|
|
notificationsRouter.post('/read', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const nodeId = req.nodeId ?? 0;
|
|
DatabaseService.getInstance().markAllNotificationsRead(nodeId);
|
|
res.json({ success: true });
|
|
} catch {
|
|
res.status(500).json({ error: 'Failed to mark notifications read' });
|
|
}
|
|
});
|
|
|
|
notificationsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const id = parseInt(req.params.id as string, 10);
|
|
if (isNaN(id)) { res.status(400).json({ error: 'Invalid notification ID' }); return; }
|
|
const nodeId = req.nodeId ?? 0;
|
|
DatabaseService.getInstance().deleteNotification(nodeId, id);
|
|
res.json({ success: true });
|
|
} catch {
|
|
res.status(500).json({ error: 'Failed to delete notification' });
|
|
}
|
|
});
|
|
|
|
notificationsRouter.delete('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const nodeId = req.nodeId ?? 0;
|
|
DatabaseService.getInstance().deleteAllNotifications(nodeId);
|
|
res.json({ success: true });
|
|
} catch {
|
|
res.status(500).json({ error: 'Failed to clear notifications' });
|
|
}
|
|
});
|
|
|
|
notificationsRouter.post('/test', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const { type, url } = req.body;
|
|
if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
|
|
res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
|
return;
|
|
}
|
|
const urlErr = validateHttpsUrl(url);
|
|
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
|
|
await NotificationService.getInstance().testDispatch(type, url);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
|
|
}
|
|
});
|
|
|
|
export const notificationRoutesRouter = Router();
|
|
|
|
notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireAdmiral(req, res)) return;
|
|
try {
|
|
const routes = DatabaseService.getInstance().getNotificationRoutes();
|
|
res.json(routes);
|
|
} catch (error) {
|
|
console.error('Failed to fetch notification routes:', error);
|
|
res.status(500).json({ error: 'Failed to fetch notification routes' });
|
|
}
|
|
});
|
|
|
|
notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireAdmiral(req, res)) return;
|
|
try {
|
|
const { name, node_id: rawNodeId, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
|
|
|
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
res.status(400).json({ error: 'Name is required' });
|
|
return;
|
|
}
|
|
if (name.trim().length > 100) {
|
|
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
|
|
return;
|
|
}
|
|
const nodeIdResult = validateNodeId(rawNodeId, res);
|
|
if (nodeIdResult === false) return;
|
|
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
|
res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' });
|
|
return;
|
|
}
|
|
const cleanedPatterns = cleanStackPatterns(stack_patterns);
|
|
if (cleanedPatterns.length === 0) {
|
|
res.status(400).json({ error: 'stack_patterns must contain at least one non-empty stack name' });
|
|
return;
|
|
}
|
|
if (!(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(channel_type)) {
|
|
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
|
return;
|
|
}
|
|
const channelUrlErr = validateHttpsUrl(channel_url);
|
|
if (channelUrlErr) { res.status(400).json({ error: `channel_url ${channelUrlErr}` }); return; }
|
|
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
|
|
res.status(400).json({ error: 'priority must be a finite number' });
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const route = DatabaseService.getInstance().createNotificationRoute({
|
|
name: name.trim(),
|
|
node_id: nodeIdResult,
|
|
stack_patterns: cleanedPatterns,
|
|
channel_type,
|
|
channel_url: channel_url.trim(),
|
|
priority: typeof priority === 'number' ? priority : 0,
|
|
enabled: enabled !== false,
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
console.log(`[Routes] Route "${route.name}" created (id=${route.id})`);
|
|
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${route.name}" created with patterns=[${cleanedPatterns}], channel=${channel_type}`);
|
|
res.status(201).json(route);
|
|
} catch (error) {
|
|
console.error('Failed to create notification route:', error);
|
|
res.status(500).json({ error: 'Failed to create notification route' });
|
|
}
|
|
});
|
|
|
|
notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireAdmiral(req, res)) return;
|
|
try {
|
|
const id = parseRouteId(req, res);
|
|
if (id === null) return;
|
|
|
|
const existing = DatabaseService.getInstance().getNotificationRoute(id);
|
|
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
|
|
|
|
const { name, node_id: rawNodeId, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
|
|
|
|
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
|
res.status(400).json({ error: 'Name must be a non-empty string' });
|
|
return;
|
|
}
|
|
if (name !== undefined && name.trim().length > 100) {
|
|
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
|
|
return;
|
|
}
|
|
let validatedNodeId: number | null | undefined;
|
|
if ('node_id' in req.body) {
|
|
const result = validateNodeId(rawNodeId, res);
|
|
if (result === false) return;
|
|
validatedNodeId = result;
|
|
}
|
|
let cleanedPatterns: string[] | undefined;
|
|
if (stack_patterns !== undefined) {
|
|
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
|
res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' });
|
|
return;
|
|
}
|
|
cleanedPatterns = cleanStackPatterns(stack_patterns);
|
|
if (cleanedPatterns.length === 0) {
|
|
res.status(400).json({ error: 'stack_patterns must contain at least one non-empty stack name' });
|
|
return;
|
|
}
|
|
}
|
|
if (channel_type !== undefined && !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(channel_type)) {
|
|
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
|
return;
|
|
}
|
|
if (channel_url !== undefined) {
|
|
const urlErr = validateHttpsUrl(channel_url);
|
|
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
|
|
}
|
|
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
|
|
res.status(400).json({ error: 'priority must be a finite number' });
|
|
return;
|
|
}
|
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
|
res.status(400).json({ error: 'enabled must be a boolean' });
|
|
return;
|
|
}
|
|
|
|
const updates: Record<string, unknown> = { updated_at: Date.now() };
|
|
if (name !== undefined) updates.name = name.trim();
|
|
if (validatedNodeId !== undefined) updates.node_id = validatedNodeId;
|
|
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
|
|
if (channel_type !== undefined) updates.channel_type = channel_type;
|
|
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
|
|
if (priority !== undefined) updates.priority = priority;
|
|
if (enabled !== undefined) updates.enabled = enabled;
|
|
|
|
const db = DatabaseService.getInstance();
|
|
db.updateNotificationRoute(id, updates);
|
|
const updated = db.getNotificationRoute(id);
|
|
console.log(`[Routes] Route ${id} updated`);
|
|
if (isDebugEnabled()) console.log(`[Routes:diag] Route ${id} update fields: ${Object.keys(updates).filter(k => k !== 'updated_at')}`);
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error('Failed to update notification route:', error);
|
|
res.status(500).json({ error: 'Failed to update notification route' });
|
|
}
|
|
});
|
|
|
|
notificationRoutesRouter.delete('/:id', authMiddleware, (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireAdmiral(req, res)) return;
|
|
try {
|
|
const id = parseRouteId(req, res);
|
|
if (id === null) return;
|
|
|
|
const changes = DatabaseService.getInstance().deleteNotificationRoute(id);
|
|
if (changes === 0) { res.status(404).json({ error: 'Route not found' }); return; }
|
|
console.log(`[Routes] Route ${id} deleted`);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to delete notification route:', error);
|
|
res.status(500).json({ error: 'Failed to delete notification route' });
|
|
}
|
|
});
|
|
|
|
notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireAdmiral(req, res)) return;
|
|
try {
|
|
const id = parseRouteId(req, res);
|
|
if (id === null) return;
|
|
|
|
const route = DatabaseService.getInstance().getNotificationRoute(id);
|
|
if (!route) { res.status(404).json({ error: 'Route not found' }); return; }
|
|
|
|
if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${route.channel_url})`);
|
|
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
|
|
}
|
|
});
|
|
|