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:
Timo
2026-01-16 18:46:40 +01:00
commit 2c1ec69a79
45 changed files with 5941 additions and 0 deletions

View 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;