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

32
backend/.env.example Normal file
View File

@@ -0,0 +1,32 @@
# Server
PORT=3001
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/website_monitor
# Redis
REDIS_URL=redis://localhost:6379
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=7d
# Email (Sendgrid/SMTP)
EMAIL_FROM=noreply@websitemonitor.com
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
# App
APP_URL=http://localhost:3000
API_URL=http://localhost:3001
# Rate Limiting
MAX_MONITORS_FREE=5
MAX_MONITORS_PRO=50
MAX_MONITORS_BUSINESS=200
MIN_FREQUENCY_FREE=60
MIN_FREQUENCY_PRO=5
MIN_FREQUENCY_BUSINESS=1

49
backend/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "website-monitor-backend",
"version": "1.0.0",
"description": "Backend API for Website Change Detection Monitor",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"migrate": "tsx src/db/migrate.ts",
"test": "jest",
"lint": "eslint src --ext .ts"
},
"keywords": ["website", "monitor", "change-detection"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"bullmq": "^5.1.0",
"ioredis": "^5.3.2",
"axios": "^1.6.5",
"cheerio": "^1.0.0-rc.12",
"diff": "^5.1.0",
"zod": "^3.22.4",
"nodemailer": "^6.9.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.5",
"@types/pg": "^8.10.9",
"@types/node": "^20.10.6",
"@types/nodemailer": "^6.4.14",
"@types/diff": "^5.0.9",
"typescript": "^5.3.3",
"tsx": "^4.7.0",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.11"
}
}

284
backend/src/db/index.ts Normal file
View File

@@ -0,0 +1,284 @@
import { Pool, QueryResult } from 'pg';
import { User, Monitor, Snapshot, Alert } from '../types';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('Unexpected database error:', err);
process.exit(-1);
});
export const query = async <T = any>(
text: string,
params?: any[]
): Promise<QueryResult<T>> => {
const start = Date.now();
const result = await pool.query<T>(text, params);
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`Slow query (${duration}ms):`, text);
}
return result;
};
export const getClient = () => pool.connect();
// User queries
export const db = {
users: {
async create(email: string, passwordHash: string): Promise<User> {
const result = await query<User>(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
[email, passwordHash]
);
return result.rows[0];
},
async findById(id: string): Promise<User | null> {
const result = await query<User>(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0] || null;
},
async findByEmail(email: string): Promise<User | null> {
const result = await query<User>(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] || null;
},
async update(id: string, updates: Partial<User>): Promise<User | null> {
const fields = Object.keys(updates);
const values = Object.values(updates);
const setClause = fields.map((field, i) => `${field} = $${i + 2}`).join(', ');
const result = await query<User>(
`UPDATE users SET ${setClause} WHERE id = $1 RETURNING *`,
[id, ...values]
);
return result.rows[0] || null;
},
async updateLastLogin(id: string): Promise<void> {
await query('UPDATE users SET last_login_at = NOW() WHERE id = $1', [id]);
},
},
monitors: {
async create(data: Omit<Monitor, 'id' | 'createdAt' | 'updatedAt' | 'consecutiveErrors'>): Promise<Monitor> {
const result = await query<Monitor>(
`INSERT INTO monitors (
user_id, url, name, frequency, status, element_selector,
ignore_rules, keyword_rules
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[
data.userId,
data.url,
data.name,
data.frequency,
data.status,
data.elementSelector || null,
data.ignoreRules ? JSON.stringify(data.ignoreRules) : null,
data.keywordRules ? JSON.stringify(data.keywordRules) : null,
]
);
return result.rows[0];
},
async findById(id: string): Promise<Monitor | null> {
const result = await query<Monitor>(
'SELECT * FROM monitors WHERE id = $1',
[id]
);
return result.rows[0] || null;
},
async findByUserId(userId: string): Promise<Monitor[]> {
const result = await query<Monitor>(
'SELECT * FROM monitors WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return result.rows;
},
async countByUserId(userId: string): Promise<number> {
const result = await query<{ count: string }>(
'SELECT COUNT(*) as count FROM monitors WHERE user_id = $1',
[userId]
);
return parseInt(result.rows[0].count);
},
async findActiveMonitors(): Promise<Monitor[]> {
const result = await query<Monitor>(
'SELECT * FROM monitors WHERE status = $1',
['active']
);
return result.rows;
},
async update(id: string, updates: Partial<Monitor>): Promise<Monitor | null> {
const fields: string[] = [];
const values: any[] = [];
let paramCount = 2;
Object.entries(updates).forEach(([key, value]) => {
if (value !== undefined) {
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
if (key === 'ignoreRules' || key === 'keywordRules') {
fields.push(`${snakeKey} = $${paramCount}`);
values.push(JSON.stringify(value));
} else {
fields.push(`${snakeKey} = $${paramCount}`);
values.push(value);
}
paramCount++;
}
});
if (fields.length === 0) return null;
const result = await query<Monitor>(
`UPDATE monitors SET ${fields.join(', ')} WHERE id = $1 RETURNING *`,
[id, ...values]
);
return result.rows[0] || null;
},
async delete(id: string): Promise<boolean> {
const result = await query('DELETE FROM monitors WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
},
async updateLastChecked(id: string, changed: boolean): Promise<void> {
if (changed) {
await query(
'UPDATE monitors SET last_checked_at = NOW(), last_changed_at = NOW(), consecutive_errors = 0 WHERE id = $1',
[id]
);
} else {
await query(
'UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = 0 WHERE id = $1',
[id]
);
}
},
async incrementErrors(id: string): Promise<void> {
await query(
'UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = consecutive_errors + 1 WHERE id = $1',
[id]
);
},
},
snapshots: {
async create(data: Omit<Snapshot, 'id' | 'createdAt'>): Promise<Snapshot> {
const result = await query<Snapshot>(
`INSERT INTO snapshots (
monitor_id, html_content, text_content, content_hash, screenshot_url,
http_status, response_time, changed, change_percentage, error_message
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[
data.monitorId,
data.htmlContent,
data.textContent,
data.contentHash,
data.screenshotUrl || null,
data.httpStatus,
data.responseTime,
data.changed,
data.changePercentage || null,
data.errorMessage || null,
]
);
return result.rows[0];
},
async findByMonitorId(monitorId: string, limit = 50): Promise<Snapshot[]> {
const result = await query<Snapshot>(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT $2',
[monitorId, limit]
);
return result.rows;
},
async findLatestByMonitorId(monitorId: string): Promise<Snapshot | null> {
const result = await query<Snapshot>(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1',
[monitorId]
);
return result.rows[0] || null;
},
async findById(id: string): Promise<Snapshot | null> {
const result = await query<Snapshot>(
'SELECT * FROM snapshots WHERE id = $1',
[id]
);
return result.rows[0] || null;
},
async deleteOldSnapshots(monitorId: string, keepCount: number): Promise<void> {
await query(
`DELETE FROM snapshots
WHERE monitor_id = $1
AND id NOT IN (
SELECT id FROM snapshots
WHERE monitor_id = $1
ORDER BY created_at DESC
LIMIT $2
)`,
[monitorId, keepCount]
);
},
},
alerts: {
async create(data: Omit<Alert, 'id' | 'createdAt' | 'deliveredAt' | 'readAt'>): Promise<Alert> {
const result = await query<Alert>(
`INSERT INTO alerts (
monitor_id, snapshot_id, user_id, type, title, summary, channels
) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[
data.monitorId,
data.snapshotId,
data.userId,
data.type,
data.title,
data.summary || null,
JSON.stringify(data.channels),
]
);
return result.rows[0];
},
async findByUserId(userId: string, limit = 50): Promise<Alert[]> {
const result = await query<Alert>(
'SELECT * FROM alerts WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows;
},
async markAsDelivered(id: string): Promise<void> {
await query('UPDATE alerts SET delivered_at = NOW() WHERE id = $1', [id]);
},
async markAsRead(id: string): Promise<void> {
await query('UPDATE alerts SET read_at = NOW() WHERE id = $1', [id]);
},
},
};
export default db;

37
backend/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Pool } from 'pg';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
dotenv.config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function runMigration() {
console.log('🔄 Running database migrations...');
try {
const client = await pool.connect();
try {
const schemaPath = path.join(__dirname, 'schema.sql');
const schemaSql = fs.readFileSync(schemaPath, 'utf-8');
console.log('📝 Executing schema...');
await client.query(schemaSql);
console.log('✅ Migrations completed successfully!');
} finally {
client.release();
}
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
} finally {
await pool.end();
}
}
runMigration();

93
backend/src/db/schema.sql Normal file
View File

@@ -0,0 +1,93 @@
-- Database schema for Website Change Detection Monitor
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
plan VARCHAR(20) DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'business', 'enterprise')),
stripe_customer_id VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
last_login_at TIMESTAMP,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_plan ON users(plan);
-- Monitors table
CREATE TABLE IF NOT EXISTS monitors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
name VARCHAR(255) NOT NULL,
frequency INTEGER NOT NULL DEFAULT 60 CHECK (frequency > 0),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'paused', 'error')),
element_selector TEXT,
ignore_rules JSONB,
keyword_rules JSONB,
last_checked_at TIMESTAMP,
last_changed_at TIMESTAMP,
consecutive_errors INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_monitors_user_id ON monitors(user_id);
CREATE INDEX idx_monitors_status ON monitors(status);
CREATE INDEX idx_monitors_last_checked_at ON monitors(last_checked_at);
-- Snapshots table
CREATE TABLE IF NOT EXISTS snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
html_content TEXT,
text_content TEXT,
content_hash VARCHAR(64) NOT NULL,
screenshot_url TEXT,
http_status INTEGER NOT NULL,
response_time INTEGER,
changed BOOLEAN DEFAULT false,
change_percentage DECIMAL(5,2),
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_snapshots_monitor_id ON snapshots(monitor_id);
CREATE INDEX idx_snapshots_created_at ON snapshots(created_at);
CREATE INDEX idx_snapshots_changed ON snapshots(changed);
-- Alerts table
CREATE TABLE IF NOT EXISTS alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
snapshot_id UUID NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL CHECK (type IN ('change', 'error', 'keyword')),
title VARCHAR(255) NOT NULL,
summary TEXT,
channels JSONB NOT NULL DEFAULT '["email"]',
delivered_at TIMESTAMP,
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_alerts_user_id ON alerts(user_id);
CREATE INDEX idx_alerts_monitor_id ON alerts(monitor_id);
CREATE INDEX idx_alerts_created_at ON alerts(created_at);
CREATE INDEX idx_alerts_read_at ON alerts(read_at);
-- Update timestamps trigger
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_monitors_updated_at BEFORE UPDATE ON monitors
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

77
backend/src/index.ts Normal file
View File

@@ -0,0 +1,77 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth';
import monitorRoutes from './routes/monitors';
import { authMiddleware } from './middleware/auth';
// Load environment variables
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors({
origin: process.env.APP_URL || 'http://localhost:3000',
credentials: true,
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
next();
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
});
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/monitors', authMiddleware, monitorRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'not_found',
message: 'Endpoint not found',
path: req.path,
});
});
// Error handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'server_error',
message: 'An unexpected error occurred',
});
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`🔗 API URL: http://localhost:${PORT}`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('SIGINT received, shutting down gracefully...');
process.exit(0);
});

View File

@@ -0,0 +1,56 @@
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../utils/auth';
import { JWTPayload } from '../types';
export interface AuthRequest extends Request {
user?: JWTPayload;
}
export function authMiddleware(
req: AuthRequest,
res: Response,
next: NextFunction
): void {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'unauthorized',
message: 'No token provided',
});
return;
}
const token = authHeader.substring(7);
const payload = verifyToken(token);
req.user = payload;
next();
} catch (error) {
res.status(401).json({
error: 'unauthorized',
message: 'Invalid or expired token',
});
}
}
export function optionalAuthMiddleware(
req: AuthRequest,
res: Response,
next: NextFunction
): void {
try {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const payload = verifyToken(token);
req.user = payload;
}
next();
} catch (error) {
next();
}
}

143
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,143 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import db from '../db';
import {
hashPassword,
comparePassword,
generateToken,
validateEmail,
validatePassword,
} from '../utils/auth';
const router = Router();
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
// Register
router.post('/register', async (req: Request, res: Response): Promise<void> => {
try {
const { email, password } = registerSchema.parse(req.body);
if (!validateEmail(email)) {
res.status(400).json({
error: 'invalid_email',
message: 'Invalid email format',
});
return;
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
res.status(400).json({
error: 'invalid_password',
message: 'Password does not meet requirements',
details: passwordValidation.errors,
});
return;
}
const existingUser = await db.users.findByEmail(email);
if (existingUser) {
res.status(409).json({
error: 'user_exists',
message: 'User with this email already exists',
});
return;
}
const passwordHash = await hashPassword(password);
const user = await db.users.create(email, passwordHash);
const token = generateToken(user);
res.status(201).json({
token,
user: {
id: user.id,
email: user.email,
plan: user.plan,
createdAt: user.createdAt,
},
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Register error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to register user',
});
}
});
// Login
router.post('/login', async (req: Request, res: Response): Promise<void> => {
try {
const { email, password } = loginSchema.parse(req.body);
const user = await db.users.findByEmail(email);
if (!user) {
res.status(401).json({
error: 'invalid_credentials',
message: 'Invalid email or password',
});
return;
}
const isValidPassword = await comparePassword(password, user.passwordHash);
if (!isValidPassword) {
res.status(401).json({
error: 'invalid_credentials',
message: 'Invalid email or password',
});
return;
}
await db.users.updateLastLogin(user.id);
const token = generateToken(user);
res.json({
token,
user: {
id: user.id,
email: user.email,
plan: user.plan,
createdAt: user.createdAt,
lastLoginAt: new Date(),
},
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Login error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to login',
});
}
});
export default router;

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;

View File

@@ -0,0 +1,209 @@
import nodemailer from 'nodemailer';
import { Monitor, User, Snapshot } from '../types';
import { KeywordMatch } from './differ';
import db from '../db';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
const EMAIL_FROM = process.env.EMAIL_FROM || 'noreply@websitemonitor.com';
export async function sendChangeAlert(
monitor: Monitor,
user: User,
snapshot: Snapshot,
changePercentage: number
): Promise<void> {
try {
const diffUrl = `${APP_URL}/monitors/${monitor.id}/history/${snapshot.id}`;
const mailOptions = {
from: EMAIL_FROM,
to: user.email,
subject: `Change detected: ${monitor.name}`,
html: `
<h2>Change Detected</h2>
<p>A change was detected on your monitored page: <strong>${monitor.name}</strong></p>
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
<p><strong>Change Percentage:</strong> ${changePercentage.toFixed(2)}%</p>
<p><strong>Detected At:</strong> ${new Date().toLocaleString()}</p>
</div>
<p>
<a href="${diffUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Changes
</a>
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
You're receiving this because you set up monitoring for this page.
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`,
};
await transporter.sendMail(mailOptions);
// Create alert record
await db.alerts.create({
monitorId: monitor.id,
snapshotId: snapshot.id,
userId: user.id,
type: 'change',
title: `Change detected: ${monitor.name}`,
summary: `${changePercentage.toFixed(2)}% of the page changed`,
channels: ['email'],
});
console.log(`[Alert] Change alert sent to ${user.email} for monitor ${monitor.name}`);
} catch (error) {
console.error('[Alert] Failed to send change alert:', error);
}
}
export async function sendErrorAlert(
monitor: Monitor,
user: User,
errorMessage: string
): Promise<void> {
try {
const monitorUrl = `${APP_URL}/monitors/${monitor.id}`;
const mailOptions = {
from: EMAIL_FROM,
to: user.email,
subject: `Error monitoring: ${monitor.name}`,
html: `
<h2>Monitoring Error</h2>
<p>We encountered an error while monitoring: <strong>${monitor.name}</strong></p>
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
<p><strong>Error:</strong> ${errorMessage}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
</div>
<p>We'll keep trying to check this page. If the problem persists, you may want to verify the URL or check if the site is blocking automated requests.</p>
<p>
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Monitor Settings
</a>
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`,
};
await transporter.sendMail(mailOptions);
// Create snapshot for error (to track it)
const snapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: '',
textContent: '',
contentHash: '',
httpStatus: 0,
responseTime: 0,
changed: false,
errorMessage,
});
// Create alert record
await db.alerts.create({
monitorId: monitor.id,
snapshotId: snapshot.id,
userId: user.id,
type: 'error',
title: `Error monitoring: ${monitor.name}`,
summary: errorMessage,
channels: ['email'],
});
console.log(`[Alert] Error alert sent to ${user.email} for monitor ${monitor.name}`);
} catch (error) {
console.error('[Alert] Failed to send error alert:', error);
}
}
export async function sendKeywordAlert(
monitor: Monitor,
user: User,
match: KeywordMatch
): Promise<void> {
try {
const monitorUrl = `${APP_URL}/monitors/${monitor.id}`;
let message = '';
switch (match.type) {
case 'appeared':
message = `The keyword "${match.keyword}" appeared on the page`;
break;
case 'disappeared':
message = `The keyword "${match.keyword}" disappeared from the page`;
break;
case 'count_changed':
message = `The keyword "${match.keyword}" count changed from ${match.previousCount} to ${match.currentCount}`;
break;
}
const mailOptions = {
from: EMAIL_FROM,
to: user.email,
subject: `Keyword alert: ${monitor.name}`,
html: `
<h2>Keyword Alert</h2>
<p>A keyword you're watching changed on: <strong>${monitor.name}</strong></p>
<div style="background: #d1ecf1; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #17a2b8;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
<p><strong>Alert:</strong> ${message}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
</div>
<p>
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Monitor
</a>
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`,
};
await transporter.sendMail(mailOptions);
// Get latest snapshot
const snapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
if (snapshot) {
// Create alert record
await db.alerts.create({
monitorId: monitor.id,
snapshotId: snapshot.id,
userId: user.id,
type: 'keyword',
title: `Keyword alert: ${monitor.name}`,
summary: message,
channels: ['email'],
});
}
console.log(`[Alert] Keyword alert sent to ${user.email} for monitor ${monitor.name}`);
} catch (error) {
console.error('[Alert] Failed to send keyword alert:', error);
}
}

View File

@@ -0,0 +1,181 @@
import { diffLines, diffWords, Change } from 'diff';
import * as cheerio from 'cheerio';
import { IgnoreRule, KeywordRule } from '../types';
export interface DiffResult {
changed: boolean;
changePercentage: number;
additions: number;
deletions: number;
diff: Change[];
}
export interface KeywordMatch {
keyword: string;
type: 'appeared' | 'disappeared' | 'count_changed';
previousCount?: number;
currentCount?: number;
}
export function applyIgnoreRules(html: string, rules?: IgnoreRule[]): string {
if (!rules || rules.length === 0) return html;
let processedHtml = html;
const $ = cheerio.load(html);
for (const rule of rules) {
switch (rule.type) {
case 'css':
// Remove elements matching CSS selector
$(rule.value).remove();
processedHtml = $.html();
break;
case 'regex':
// Remove text matching regex
const regex = new RegExp(rule.value, 'gi');
processedHtml = processedHtml.replace(regex, '');
break;
case 'text':
// Remove exact text matches
processedHtml = processedHtml.replace(
new RegExp(rule.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'),
''
);
break;
}
}
return processedHtml;
}
export function applyCommonNoiseFilters(html: string): string {
const $ = cheerio.load(html);
// Common cookie banner selectors
const cookieSelectors = [
'[class*="cookie"]',
'[id*="cookie"]',
'[class*="consent"]',
'[id*="consent"]',
'[class*="gdpr"]',
'[id*="gdpr"]',
];
cookieSelectors.forEach((selector) => {
$(selector).remove();
});
let processedHtml = $.html();
// Remove common timestamp patterns
const timestampPatterns = [
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?/gi, // ISO timestamps
/\d{1,2}\/\d{1,2}\/\d{4}/gi, // MM/DD/YYYY
/\d{1,2}-\d{1,2}-\d{4}/gi, // MM-DD-YYYY
/Last updated:?\s*\d+/gi,
/Updated:?\s*\d+/gi,
];
timestampPatterns.forEach((pattern) => {
processedHtml = processedHtml.replace(pattern, '');
});
return processedHtml;
}
export function compareDiffs(
previousText: string,
currentText: string
): DiffResult {
const diff = diffLines(previousText, currentText);
let additions = 0;
let deletions = 0;
let totalLines = 0;
diff.forEach((part) => {
const lines = part.value.split('\n').filter((line) => line.trim()).length;
totalLines += lines;
if (part.added) {
additions += lines;
} else if (part.removed) {
deletions += lines;
}
});
const changedLines = additions + deletions;
const changePercentage = totalLines > 0 ? (changedLines / totalLines) * 100 : 0;
return {
changed: additions > 0 || deletions > 0,
changePercentage: Math.min(changePercentage, 100),
additions,
deletions,
diff,
};
}
export function checkKeywords(
previousText: string,
currentText: string,
rules?: KeywordRule[]
): KeywordMatch[] {
if (!rules || rules.length === 0) return [];
const matches: KeywordMatch[] = [];
for (const rule of rules) {
const prevMatches = rule.caseSensitive
? (previousText.match(new RegExp(rule.keyword, 'g')) || []).length
: (previousText.match(new RegExp(rule.keyword, 'gi')) || []).length;
const currMatches = rule.caseSensitive
? (currentText.match(new RegExp(rule.keyword, 'g')) || []).length
: (currentText.match(new RegExp(rule.keyword, 'gi')) || []).length;
switch (rule.type) {
case 'appears':
if (prevMatches === 0 && currMatches > 0) {
matches.push({
keyword: rule.keyword,
type: 'appeared',
currentCount: currMatches,
});
}
break;
case 'disappears':
if (prevMatches > 0 && currMatches === 0) {
matches.push({
keyword: rule.keyword,
type: 'disappeared',
previousCount: prevMatches,
});
}
break;
case 'count':
const threshold = rule.threshold || 1;
if (Math.abs(currMatches - prevMatches) >= threshold) {
matches.push({
keyword: rule.keyword,
type: 'count_changed',
previousCount: prevMatches,
currentCount: currMatches,
});
}
break;
}
}
return matches;
}
export function calculateChangeSeverity(changePercentage: number): 'minor' | 'medium' | 'major' {
if (changePercentage > 50) return 'major';
if (changePercentage > 10) return 'medium';
return 'minor';
}

View File

@@ -0,0 +1,128 @@
import axios, { AxiosResponse } from 'axios';
import * as cheerio from 'cheerio';
import crypto from 'crypto';
export interface FetchResult {
html: string;
text: string;
hash: string;
status: number;
responseTime: number;
error?: string;
}
export async function fetchPage(
url: string,
elementSelector?: string
): Promise<FetchResult> {
const startTime = Date.now();
try {
// Validate URL
new URL(url);
const response: AxiosResponse = await axios.get(url, {
timeout: 30000,
maxRedirects: 5,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
Connection: 'keep-alive',
'Upgrade-Insecure-Requests': '1',
},
validateStatus: (status) => status < 500,
});
const responseTime = Date.now() - startTime;
let html = response.data;
// If element selector is provided, extract only that element
if (elementSelector) {
const $ = cheerio.load(html);
const element = $(elementSelector);
if (element.length === 0) {
throw new Error(`Element not found: ${elementSelector}`);
}
html = element.html() || '';
}
// Extract text content
const $ = cheerio.load(html);
const text = $.text().trim();
// Generate hash
const hash = crypto.createHash('sha256').update(html).digest('hex');
return {
html,
text,
hash,
status: response.status,
responseTime,
};
} catch (error: any) {
const responseTime = Date.now() - startTime;
if (error.response) {
return {
html: '',
text: '',
hash: '',
status: error.response.status,
responseTime,
error: `HTTP ${error.response.status}: ${error.response.statusText}`,
};
}
if (error.code === 'ENOTFOUND') {
return {
html: '',
text: '',
hash: '',
status: 0,
responseTime,
error: 'Domain not found',
};
}
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
return {
html: '',
text: '',
hash: '',
status: 0,
responseTime,
error: 'Request timeout',
};
}
return {
html: '',
text: '',
hash: '',
status: 0,
responseTime,
error: error.message || 'Unknown error',
};
}
}
export function extractTextFromHtml(html: string): string {
const $ = cheerio.load(html);
// Remove script and style elements
$('script').remove();
$('style').remove();
return $.text().trim();
}
export function calculateHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex');
}

View File

@@ -0,0 +1,158 @@
import db from '../db';
import { Monitor } from '../types';
import { fetchPage } from './fetcher';
import {
applyIgnoreRules,
applyCommonNoiseFilters,
compareDiffs,
checkKeywords,
} from './differ';
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
export async function checkMonitor(monitorId: string): Promise<void> {
console.log(`[Monitor] Checking monitor ${monitorId}`);
try {
const monitor = await db.monitors.findById(monitorId);
if (!monitor) {
console.error(`[Monitor] Monitor ${monitorId} not found`);
return;
}
if (monitor.status !== 'active') {
console.log(`[Monitor] Monitor ${monitorId} is not active, skipping`);
return;
}
// Fetch page with retries
let fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
// Retry on failure (max 3 attempts)
if (fetchResult.error) {
console.log(`[Monitor] Fetch failed, retrying... (1/3)`);
await new Promise((resolve) => setTimeout(resolve, 2000));
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
if (fetchResult.error) {
console.log(`[Monitor] Fetch failed, retrying... (2/3)`);
await new Promise((resolve) => setTimeout(resolve, 2000));
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
}
}
// If still failing after retries
if (fetchResult.error) {
console.error(`[Monitor] Failed to fetch ${monitor.url}: ${fetchResult.error}`);
// Create error snapshot
await db.snapshots.create({
monitorId: monitor.id,
htmlContent: '',
textContent: '',
contentHash: '',
httpStatus: fetchResult.status,
responseTime: fetchResult.responseTime,
changed: false,
errorMessage: fetchResult.error,
});
await db.monitors.incrementErrors(monitor.id);
// Send error alert if consecutive errors > 3
if (monitor.consecutiveErrors >= 2) {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendErrorAlert(monitor, user, fetchResult.error);
}
}
return;
}
// Apply noise filters
let processedHtml = applyCommonNoiseFilters(fetchResult.html);
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
// Get previous snapshot
const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
let changed = false;
let changePercentage = 0;
if (previousSnapshot) {
// Apply same filters to previous content for fair comparison
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
// Compare
const diffResult = compareDiffs(previousHtml, processedHtml);
changed = diffResult.changed;
changePercentage = diffResult.changePercentage;
console.log(
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}`
);
// Check keywords
if (monitor.keywordRules && monitor.keywordRules.length > 0) {
const keywordMatches = checkKeywords(
previousHtml,
processedHtml,
monitor.keywordRules
);
if (keywordMatches.length > 0) {
console.log(`[Monitor] Keyword matches found:`, keywordMatches);
const user = await db.users.findById(monitor.userId);
if (user) {
for (const match of keywordMatches) {
await sendKeywordAlert(monitor, user, match);
}
}
}
}
} else {
// First check - consider it as "changed" to create baseline
changed = true;
console.log(`[Monitor] First check for ${monitor.name}, creating baseline`);
}
// Create snapshot
const snapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: fetchResult.html,
textContent: fetchResult.text,
contentHash: fetchResult.hash,
httpStatus: fetchResult.status,
responseTime: fetchResult.responseTime,
changed,
changePercentage: changed ? changePercentage : undefined,
});
// Update monitor
await db.monitors.updateLastChecked(monitor.id, changed);
// Send alert if changed and not first check
if (changed && previousSnapshot) {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendChangeAlert(monitor, user, snapshot, changePercentage);
}
}
// Clean up old snapshots (keep last 50)
await db.snapshots.deleteOldSnapshots(monitor.id, 50);
console.log(`[Monitor] Check completed for ${monitor.name}`);
} catch (error) {
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
await db.monitors.incrementErrors(monitorId);
}
}
export async function scheduleMonitor(monitor: Monitor): Promise<void> {
// This will be implemented when we add the job queue
console.log(`[Monitor] Scheduling monitor ${monitor.id} with frequency ${monitor.frequency}m`);
}

101
backend/src/types/index.ts Normal file
View File

@@ -0,0 +1,101 @@
export type UserPlan = 'free' | 'pro' | 'business' | 'enterprise';
export type MonitorStatus = 'active' | 'paused' | 'error';
export type MonitorFrequency = 1 | 5 | 30 | 60 | 360 | 1440; // minutes
export type AlertType = 'change' | 'error' | 'keyword';
export type AlertChannel = 'email' | 'slack' | 'webhook';
export interface User {
id: string;
email: string;
passwordHash: string;
plan: UserPlan;
stripeCustomerId?: string;
createdAt: Date;
lastLoginAt?: Date;
}
export interface IgnoreRule {
type: 'css' | 'regex' | 'text';
value: string;
}
export interface KeywordRule {
keyword: string;
type: 'appears' | 'disappears' | 'count';
threshold?: number;
caseSensitive?: boolean;
}
export interface Monitor {
id: string;
userId: string;
url: string;
name: string;
frequency: MonitorFrequency;
status: MonitorStatus;
elementSelector?: string;
ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[];
lastCheckedAt?: Date;
lastChangedAt?: Date;
consecutiveErrors: number;
createdAt: Date;
updatedAt: Date;
}
export interface Snapshot {
id: string;
monitorId: string;
htmlContent: string;
textContent: string;
contentHash: string;
screenshotUrl?: string;
httpStatus: number;
responseTime: number;
changed: boolean;
changePercentage?: number;
errorMessage?: string;
createdAt: Date;
}
export interface Alert {
id: string;
monitorId: string;
snapshotId: string;
userId: string;
type: AlertType;
title: string;
summary?: string;
channels: AlertChannel[];
deliveredAt?: Date;
readAt?: Date;
createdAt: Date;
}
export interface JWTPayload {
userId: string;
email: string;
plan: UserPlan;
}
export interface CreateMonitorInput {
url: string;
name?: string;
frequency: MonitorFrequency;
elementSelector?: string;
ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[];
}
export interface UpdateMonitorInput {
name?: string;
frequency?: MonitorFrequency;
status?: MonitorStatus;
elementSelector?: string;
ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[];
}

64
backend/src/utils/auth.ts Normal file
View File

@@ -0,0 +1,64 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { JWTPayload, User } from '../types';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
export async function comparePassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function generateToken(user: User): string {
const payload: JWTPayload = {
userId: user.id,
email: user.email,
plan: user.plan,
};
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
export function verifyToken(token: string): JWTPayload {
return jwt.verify(token, JWT_SECRET) as JWTPayload;
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function validatePassword(password: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number');
}
return {
valid: errors.length === 0,
errors,
};
}

24
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}