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:
284
backend/src/db/index.ts
Normal file
284
backend/src/db/index.ts
Normal 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
37
backend/src/db/migrate.ts
Normal 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
93
backend/src/db/schema.sql
Normal 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
77
backend/src/index.ts
Normal 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);
|
||||
});
|
||||
56
backend/src/middleware/auth.ts
Normal file
56
backend/src/middleware/auth.ts
Normal 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
143
backend/src/routes/auth.ts
Normal 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;
|
||||
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;
|
||||
209
backend/src/services/alerter.ts
Normal file
209
backend/src/services/alerter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
181
backend/src/services/differ.ts
Normal file
181
backend/src/services/differ.ts
Normal 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';
|
||||
}
|
||||
128
backend/src/services/fetcher.ts
Normal file
128
backend/src/services/fetcher.ts
Normal 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');
|
||||
}
|
||||
158
backend/src/services/monitor.ts
Normal file
158
backend/src/services/monitor.ts
Normal 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
101
backend/src/types/index.ts
Normal 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
64
backend/src/utils/auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user