Initial implementation of Website Change Detection Monitor MVP
Features implemented: - Backend API with Express + TypeScript - User authentication (register/login with JWT) - Monitor CRUD operations with plan-based limits - Automated change detection engine - Email alert system - Frontend with Next.js + TypeScript - Dashboard with monitor management - Login/register pages - Monitor history viewer - PostgreSQL database schema - Docker setup for local development Technical stack: - Backend: Express, TypeScript, PostgreSQL, Redis (ready) - Frontend: Next.js 14, React Query, Tailwind CSS - Database: PostgreSQL with migrations - Services: Page fetching, diff detection, email alerts Documentation: - README with full setup instructions - SETUP guide for quick start - PROJECT_STATUS with current capabilities - Complete technical specifications Ready for local testing and feature expansion. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
371
backend/src/routes/monitors.ts
Normal file
371
backend/src/routes/monitors.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { CreateMonitorInput, UpdateMonitorInput } from '../types';
|
||||
import { checkMonitor } 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<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitors = await db.monitors.findByUserId(req.user.userId);
|
||||
|
||||
res.json({ monitors });
|
||||
} 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<void> => {
|
||||
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<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = createMonitorSchema.parse(req.body);
|
||||
|
||||
// Check plan limits
|
||||
const limits = getPlanLimits(req.user.plan);
|
||||
const currentCount = await db.monitors.countByUserId(req.user.userId);
|
||||
|
||||
if (currentCount >= limits.maxMonitors) {
|
||||
res.status(403).json({
|
||||
error: 'limit_exceeded',
|
||||
message: `Your ${req.user.plan} plan allows max ${limits.maxMonitors} monitors`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.frequency < limits.minFrequency) {
|
||||
res.status(403).json({
|
||||
error: 'invalid_frequency',
|
||||
message: `Your ${req.user.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,
|
||||
status: 'active',
|
||||
elementSelector: input.elementSelector,
|
||||
ignoreRules: input.ignoreRules,
|
||||
keywordRules: input.keywordRules,
|
||||
});
|
||||
|
||||
// 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<void> => {
|
||||
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) {
|
||||
const limits = getPlanLimits(req.user.plan);
|
||||
if (input.frequency < limits.minFrequency) {
|
||||
res.status(403).json({
|
||||
error: 'invalid_frequency',
|
||||
message: `Your ${req.user.plan} plan requires minimum ${limits.minFrequency} minute frequency`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db.monitors.update(req.params.id, input);
|
||||
|
||||
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<void> => {
|
||||
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 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', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
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;
|
||||
}
|
||||
|
||||
// Trigger check (don't wait for it)
|
||||
checkMonitor(monitor.id).catch((err) => console.error('Manual check failed:', err));
|
||||
|
||||
res.json({ message: 'Check triggered successfully' });
|
||||
} 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<void> => {
|
||||
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<void> => {
|
||||
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 default router;
|
||||
Reference in New Issue
Block a user