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

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();