import { Router, Response } from 'express'; import { z } from 'zod'; import db from '../db'; import { AuthRequest } from '../middleware/auth'; import { checkLimiter } from '../middleware/rateLimiter'; import { MonitorFrequency, Monitor } from '../types'; import { checkMonitor, scheduleMonitor, unscheduleMonitor, rescheduleMonitor } from '../services/monitor'; const router = Router(); const createMonitorSchema = z.object({ url: z.string().url(), name: z.string().optional(), frequency: z.number().int().positive(), elementSelector: z.string().optional(), ignoreRules: z .array( z.object({ type: z.enum(['css', 'regex', 'text']), value: z.string(), }) ) .optional(), keywordRules: z .array( z.object({ keyword: z.string(), type: z.enum(['appears', 'disappears', 'count']), threshold: z.number().optional(), caseSensitive: z.boolean().optional(), }) ) .optional(), }); const updateMonitorSchema = z.object({ name: z.string().optional(), frequency: z.number().int().positive().optional(), status: z.enum(['active', 'paused', 'error']).optional(), elementSelector: z.string().optional(), ignoreRules: z .array( z.object({ type: z.enum(['css', 'regex', 'text']), value: z.string(), }) ) .optional(), keywordRules: z .array( z.object({ keyword: z.string(), type: z.enum(['appears', 'disappears', 'count']), threshold: z.number().optional(), caseSensitive: z.boolean().optional(), }) ) .optional(), }); // Get plan limits function getPlanLimits(plan: string) { const limits = { free: { maxMonitors: parseInt(process.env.MAX_MONITORS_FREE || '5'), minFrequency: parseInt(process.env.MIN_FREQUENCY_FREE || '60'), }, pro: { maxMonitors: parseInt(process.env.MAX_MONITORS_PRO || '50'), minFrequency: parseInt(process.env.MIN_FREQUENCY_PRO || '5'), }, business: { maxMonitors: parseInt(process.env.MAX_MONITORS_BUSINESS || '200'), minFrequency: parseInt(process.env.MIN_FREQUENCY_BUSINESS || '1'), }, enterprise: { maxMonitors: 999999, minFrequency: 1, }, }; return limits[plan as keyof typeof limits] || limits.free; } // List monitors router.get('/', async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } const monitors = await db.monitors.findByUserId(req.user.userId); // Attach recent snapshots to each monitor for sparklines const monitorsWithSnapshots = await Promise.all(monitors.map(async (monitor) => { // Get last 20 snapshots for sparkline const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20); return { ...monitor, recentSnapshots }; })); res.json({ monitors: monitorsWithSnapshots }); } catch (error) { console.error('List monitors error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to list monitors' }); } }); // Get monitor by ID router.get('/:id', async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } const monitor = await db.monitors.findById(req.params.id); if (!monitor) { res.status(404).json({ error: 'not_found', message: 'Monitor not found' }); return; } if (monitor.userId !== req.user.userId) { res.status(403).json({ error: 'forbidden', message: 'Access denied' }); return; } res.json({ monitor }); } catch (error) { console.error('Get monitor error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to get monitor' }); } }); // Create monitor router.post('/', async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } const input = createMonitorSchema.parse(req.body); // Check plan limits (fetch fresh user data) const currentUser = await db.users.findById(req.user.userId); const plan = currentUser?.plan || req.user.plan; const limits = getPlanLimits(plan); const currentCount = await db.monitors.countByUserId(req.user.userId); if (currentCount >= limits.maxMonitors) { res.status(403).json({ error: 'limit_exceeded', message: `Your ${plan} plan allows max ${limits.maxMonitors} monitors`, }); return; } if (input.frequency < limits.minFrequency) { res.status(403).json({ error: 'invalid_frequency', message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`, }); return; } // Extract domain from URL for name if not provided const name = input.name || new URL(input.url).hostname; const monitor = await db.monitors.create({ userId: req.user.userId, url: input.url, name, frequency: input.frequency as MonitorFrequency, status: 'active', elementSelector: input.elementSelector, ignoreRules: input.ignoreRules, keywordRules: input.keywordRules, }); // Schedule recurring checks try { await scheduleMonitor(monitor); console.log(`Monitor ${monitor.id} scheduled successfully`); } catch (err) { console.error('Failed to schedule monitor:', err); } // Perform first check immediately checkMonitor(monitor.id).catch((err) => console.error('Initial check failed:', err) ); res.status(201).json({ monitor }); } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'validation_error', message: 'Invalid input', details: error.errors, }); return; } console.error('Create monitor error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to create monitor' }); } }); // Update monitor router.put('/:id', async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } const monitor = await db.monitors.findById(req.params.id); if (!monitor) { res.status(404).json({ error: 'not_found', message: 'Monitor not found' }); return; } if (monitor.userId !== req.user.userId) { res.status(403).json({ error: 'forbidden', message: 'Access denied' }); return; } const input = updateMonitorSchema.parse(req.body); // Check frequency limit if being updated if (input.frequency) { // Fetch fresh user data to get current plan const currentUser = await db.users.findById(req.user.userId); const plan = currentUser?.plan || req.user.plan; const limits = getPlanLimits(plan); if (input.frequency < limits.minFrequency) { res.status(403).json({ error: 'invalid_frequency', message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`, }); return; } } const updateData: Partial = { ...input, frequency: input.frequency as MonitorFrequency | undefined, }; const updated = await db.monitors.update(req.params.id, updateData); if (!updated) { res.status(500).json({ error: 'update_failed', message: 'Failed to update monitor' }); return; } // Reschedule if frequency changed or status changed to/from active const needsRescheduling = input.frequency !== undefined || (input.status && (input.status === 'active' || monitor.status === 'active')); if (needsRescheduling) { try { if (updated.status === 'active') { await rescheduleMonitor(updated); console.log(`Monitor ${updated.id} rescheduled`); } else { await unscheduleMonitor(updated.id); console.log(`Monitor ${updated.id} unscheduled (status: ${updated.status})`); } } catch (err) { console.error('Failed to reschedule monitor:', err); } } res.json({ monitor: updated }); } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'validation_error', message: 'Invalid input', details: error.errors, }); return; } console.error('Update monitor error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to update monitor' }); } }); // Delete monitor router.delete('/:id', async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } const monitor = await db.monitors.findById(req.params.id); if (!monitor) { res.status(404).json({ error: 'not_found', message: 'Monitor not found' }); return; } if (monitor.userId !== req.user.userId) { res.status(403).json({ error: 'forbidden', message: 'Access denied' }); return; } // Unschedule before deleting try { await unscheduleMonitor(req.params.id); console.log(`Monitor ${req.params.id} unscheduled before deletion`); } catch (err) { console.error('Failed to unschedule monitor:', err); } await db.monitors.delete(req.params.id); res.json({ message: 'Monitor deleted successfully' }); } catch (error) { console.error('Delete monitor error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to delete monitor' }); } }); // Trigger manual check router.post('/:id/check', checkLimiter, async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } const monitor = await db.monitors.findById(req.params.id); if (!monitor) { res.status(404).json({ error: 'not_found', message: 'Monitor not found' }); return; } if (monitor.userId !== req.user.userId) { res.status(403).json({ error: 'forbidden', message: 'Access denied' }); return; } // Await the check so user gets immediate feedback try { await checkMonitor(monitor.id); // Get the latest snapshot to return to the user const latestSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id); const updatedMonitor = await db.monitors.findById(monitor.id); res.json({ message: 'Check completed successfully', monitor: updatedMonitor, snapshot: latestSnapshot ? { id: latestSnapshot.id, changed: latestSnapshot.changed, changePercentage: latestSnapshot.changePercentage, httpStatus: latestSnapshot.httpStatus, responseTime: latestSnapshot.responseTime, createdAt: latestSnapshot.createdAt, errorMessage: latestSnapshot.errorMessage, } : null, }); } catch (checkError: any) { console.error('Check failed:', checkError); res.status(500).json({ error: 'check_failed', message: checkError.message || 'Failed to check monitor' }); } } catch (error) { console.error('Trigger check error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to trigger check' }); } }); // Get monitor history (snapshots) router.get('/:id/history', async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } const monitor = await db.monitors.findById(req.params.id); if (!monitor) { res.status(404).json({ error: 'not_found', message: 'Monitor not found' }); return; } if (monitor.userId !== req.user.userId) { res.status(403).json({ error: 'forbidden', message: 'Access denied' }); return; } const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); const snapshots = await db.snapshots.findByMonitorId(req.params.id, limit); res.json({ snapshots }); } catch (error) { console.error('Get history error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to get history' }); } }); // Get specific snapshot router.get( '/:id/history/:snapshotId', async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } const monitor = await db.monitors.findById(req.params.id); if (!monitor) { res.status(404).json({ error: 'not_found', message: 'Monitor not found' }); return; } if (monitor.userId !== req.user.userId) { res.status(403).json({ error: 'forbidden', message: 'Access denied' }); return; } const snapshot = await db.snapshots.findById(req.params.snapshotId); if (!snapshot || snapshot.monitorId !== req.params.id) { res.status(404).json({ error: 'not_found', message: 'Snapshot not found' }); return; } res.json({ snapshot }); } catch (error) { console.error('Get snapshot error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to get snapshot' }); } } ); // Export monitor audit trail (JSON or CSV) router.get('/:id/export', async (req: AuthRequest, res: Response): Promise => { try { if (!req.user) { res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' }); return; } // Check if user has export feature (PRO+) const user = await db.users.findById(req.user.userId); if (!user) { res.status(404).json({ error: 'not_found', message: 'User not found' }); return; } // Allow export for all users for now, but in production check plan // if (!hasFeature(user.plan, 'audit_export')) { // res.status(403).json({ error: 'forbidden', message: 'Export feature requires Pro plan' }); // return; // } const monitor = await db.monitors.findById(req.params.id); if (!monitor) { res.status(404).json({ error: 'not_found', message: 'Monitor not found' }); return; } if (monitor.userId !== req.user.userId) { res.status(403).json({ error: 'forbidden', message: 'Access denied' }); return; } const format = (req.query.format as string)?.toLowerCase() || 'json'; const fromDate = req.query.from ? new Date(req.query.from as string) : undefined; const toDate = req.query.to ? new Date(req.query.to as string) : undefined; // Get all snapshots (up to 1000) let snapshots = await db.snapshots.findByMonitorId(monitor.id, 1000); // Filter by date range if provided if (fromDate) { snapshots = snapshots.filter(s => new Date(s.createdAt) >= fromDate); } if (toDate) { snapshots = snapshots.filter(s => new Date(s.createdAt) <= toDate); } // Get alerts for this monitor const allAlerts = await db.alerts.findByUserId(req.user.userId, 1000); const monitorAlerts = allAlerts.filter(a => a.monitorId === monitor.id); // Filter alerts by date range if provided let filteredAlerts = monitorAlerts; if (fromDate) { filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) >= fromDate); } if (toDate) { filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) <= toDate); } const exportData = { monitor: { id: monitor.id, name: monitor.name, url: monitor.url, frequency: monitor.frequency, status: monitor.status, createdAt: monitor.createdAt, }, exportedAt: new Date().toISOString(), dateRange: { from: fromDate?.toISOString() || 'start', to: toDate?.toISOString() || 'now', }, summary: { totalChecks: snapshots.length, changesDetected: snapshots.filter(s => s.changed).length, errorsDetected: snapshots.filter(s => s.errorMessage).length, totalAlerts: filteredAlerts.length, }, checks: snapshots.map(s => ({ id: s.id, timestamp: s.createdAt, changed: s.changed, changePercentage: s.changePercentage, httpStatus: s.httpStatus, responseTime: s.responseTime, errorMessage: s.errorMessage, })), alerts: filteredAlerts.map(a => ({ id: a.id, type: a.type, title: a.title, summary: a.summary, channels: a.channels, createdAt: a.createdAt, deliveredAt: a.deliveredAt, })), }; if (format === 'csv') { // Generate CSV const csvLines: string[] = []; // Header csvLines.push('Type,Timestamp,Changed,Change %,HTTP Status,Response Time (ms),Error,Alert Type,Alert Title'); // Checks for (const check of exportData.checks) { csvLines.push([ 'check', check.timestamp, check.changed ? 'true' : 'false', check.changePercentage?.toFixed(2) || '', check.httpStatus, check.responseTime, `"${(check.errorMessage || '').replace(/"/g, '""')}"`, '', '', ].join(',')); } // Alerts for (const alert of exportData.alerts) { csvLines.push([ 'alert', alert.createdAt, '', '', '', '', '', alert.type, `"${(alert.title || '').replace(/"/g, '""')}"`, ].join(',')); } const csv = csvLines.join('\n'); const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.csv`; res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(csv); } else { // JSON format const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.json`; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.json(exportData); } } catch (error) { console.error('Export error:', error); res.status(500).json({ error: 'server_error', message: 'Failed to export audit trail' }); } }); export default router;