This commit is contained in:
2026-01-19 08:32:44 +01:00
parent b4f6a83da0
commit 818779ab07
125 changed files with 32456 additions and 21017 deletions

70
backend/src/config.ts Normal file
View File

@@ -0,0 +1,70 @@
import { UserPlan } from './types';
/**
* Plan-based limits and configuration
*/
export const PLAN_LIMITS = {
free: {
retentionDays: 7,
maxMonitors: 3,
minFrequency: 60, // minutes
features: ['email_alerts', 'basic_noise_filtering'],
},
pro: {
retentionDays: 90,
maxMonitors: 20,
minFrequency: 5,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export'],
},
business: {
retentionDays: 365,
maxMonitors: 100,
minFrequency: 1,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members'],
},
enterprise: {
retentionDays: 730, // 2 years
maxMonitors: Infinity,
minFrequency: 1,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members', 'custom_integrations', 'sla'],
},
} as const;
/**
* Get the retention period in days for a user plan
*/
export function getRetentionDays(plan: UserPlan): number {
return PLAN_LIMITS[plan]?.retentionDays || PLAN_LIMITS.free.retentionDays;
}
/**
* Get the maximum number of monitors for a user plan
*/
export function getMaxMonitors(plan: UserPlan): number {
return PLAN_LIMITS[plan]?.maxMonitors || PLAN_LIMITS.free.maxMonitors;
}
/**
* Check if a plan has a specific feature
*/
export function hasFeature(plan: UserPlan, feature: string): boolean {
const planConfig = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
return planConfig.features.includes(feature as any);
}
/**
* Webhook retry configuration
*/
export const WEBHOOK_CONFIG = {
maxRetries: 3,
retryDelayMs: 1000,
timeoutMs: 10000,
};
/**
* App configuration
*/
export const APP_CONFIG = {
appUrl: process.env.APP_URL || 'http://localhost:3000',
emailFrom: process.env.EMAIL_FROM || 'noreply@websitemonitor.com',
};

View File

@@ -1,284 +1,438 @@
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;
import { Pool, QueryResult, QueryResultRow } from 'pg';
import { User, Monitor, Snapshot, Alert } from '../types';
// Convert snake_case database keys to camelCase TypeScript properties
function toCamelCase<T>(obj: any): T {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) return obj.map(item => toCamelCase<any>(item)) as T;
if (typeof obj !== 'object') return obj;
const result: any = {};
for (const key in obj) {
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
let value = obj[key];
// Parse JSON fields that are stored as strings in the database
if ((key === 'ignore_rules' || key === 'keyword_rules') && typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
// Keep as-is if parsing fails
}
}
result[camelKey] = value;
}
return result as T;
}
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 extends QueryResultRow = 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(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
[email, passwordHash]
);
return toCamelCase<User>(result.rows[0]);
},
async findById(id: string): Promise<User | null> {
const result = await query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0] ? toCamelCase<User>(result.rows[0]) : null;
},
async findByEmail(email: string): Promise<User | null> {
const result = await query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] ? toCamelCase<User>(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]);
},
async updatePassword(id: string, passwordHash: string): Promise<void> {
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, id]);
},
async updateNotificationSettings(
id: string,
settings: {
emailEnabled?: boolean;
webhookUrl?: string | null;
webhookEnabled?: boolean;
slackWebhookUrl?: string | null;
slackEnabled?: boolean;
}
): Promise<void> {
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (settings.emailEnabled !== undefined) {
updates.push(`email_enabled = $${paramIndex++}`);
values.push(settings.emailEnabled);
}
if (settings.webhookUrl !== undefined) {
updates.push(`webhook_url = $${paramIndex++}`);
values.push(settings.webhookUrl);
}
if (settings.webhookEnabled !== undefined) {
updates.push(`webhook_enabled = $${paramIndex++}`);
values.push(settings.webhookEnabled);
}
if (settings.slackWebhookUrl !== undefined) {
updates.push(`slack_webhook_url = $${paramIndex++}`);
values.push(settings.slackWebhookUrl);
}
if (settings.slackEnabled !== undefined) {
updates.push(`slack_enabled = $${paramIndex++}`);
values.push(settings.slackEnabled);
}
if (updates.length > 0) {
values.push(id);
await query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
values
);
}
},
async delete(id: string): Promise<boolean> {
const result = await query('DELETE FROM users WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
},
async verifyEmail(email: string): Promise<void> {
await query(
'UPDATE users SET email_verified = true, email_verified_at = NOW() WHERE email = $1',
[email]
);
},
},
monitors: {
async create(data: Omit<Monitor, 'id' | 'createdAt' | 'updatedAt' | 'consecutiveErrors'>): Promise<Monitor> {
const result = await query(
`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 toCamelCase<Monitor>(result.rows[0]);
},
async findById(id: string): Promise<Monitor | null> {
const result = await query(
'SELECT * FROM monitors WHERE id = $1',
[id]
);
return result.rows[0] ? toCamelCase<Monitor>(result.rows[0]) : null;
},
async findByUserId(userId: string): Promise<Monitor[]> {
const result = await query(
'SELECT * FROM monitors WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return result.rows.map(row => toCamelCase<Monitor>(row));
},
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(
'SELECT * FROM monitors WHERE status = $1',
['active']
);
return result.rows.map(row => toCamelCase<Monitor>(row));
},
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(
`UPDATE monitors SET ${fields.join(', ')} WHERE id = $1 RETURNING *`,
[id, ...values]
);
return result.rows[0] ? toCamelCase<Monitor>(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, status = 'active' WHERE id = $1",
[id]
);
} else {
await query(
"UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = 0, status = 'active' WHERE id = $1",
[id]
);
}
},
async incrementErrors(id: string): Promise<void> {
await query(
"UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = consecutive_errors + 1, status = CASE WHEN consecutive_errors >= 0 THEN 'error' ELSE status END WHERE id = $1",
[id]
);
},
},
snapshots: {
async create(data: Omit<Snapshot, 'id' | 'createdAt'>): Promise<Snapshot> {
const result = await query(
`INSERT INTO snapshots (
monitor_id, html_content, text_content, content_hash, screenshot_url,
http_status, response_time, changed, change_percentage, error_message,
importance_score, summary
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[
data.monitorId,
data.htmlContent,
data.textContent,
data.contentHash,
data.screenshotUrl || null,
data.httpStatus,
data.responseTime,
data.changed,
data.changePercentage || null,
data.errorMessage || null,
data.importanceScore ?? 0,
data.summary || null,
]
);
return toCamelCase<Snapshot>(result.rows[0]);
},
async findByMonitorId(monitorId: string, limit = 50): Promise<Snapshot[]> {
const result = await query(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT $2',
[monitorId, limit]
);
return result.rows.map(row => toCamelCase<Snapshot>(row));
},
async findLatestByMonitorId(monitorId: string): Promise<Snapshot | null> {
const result = await query(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1',
[monitorId]
);
return result.rows[0] ? toCamelCase<Snapshot>(result.rows[0]) : null;
},
async findById(id: string): Promise<Snapshot | null> {
const result = await query(
'SELECT * FROM snapshots WHERE id = $1',
[id]
);
return result.rows[0] ? toCamelCase<Snapshot>(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]
);
},
async deleteOldSnapshotsByAge(monitorId: string, retentionDays: number): Promise<void> {
await query(
`DELETE FROM snapshots
WHERE monitor_id = $1
AND created_at < NOW() - INTERVAL '1 day' * $2`,
[monitorId, retentionDays]
);
},
},
alerts: {
async create(data: Omit<Alert, 'id' | 'createdAt' | 'deliveredAt' | 'readAt'>): Promise<Alert> {
const result = await query(
`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 toCamelCase<Alert>(result.rows[0]);
},
async findByUserId(userId: string, limit = 50): Promise<Alert[]> {
const result = await query(
'SELECT * FROM alerts WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows.map(row => toCamelCase<Alert>(row));
},
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]);
},
async updateChannels(id: string, channels: string[]): Promise<void> {
await query('UPDATE alerts SET channels = $1 WHERE id = $2', [JSON.stringify(channels), id]);
},
},
webhookLogs: {
async create(data: {
userId: string;
monitorId?: string;
alertId?: string;
webhookType: 'webhook' | 'slack';
url: string;
payload?: any;
statusCode?: number;
responseBody?: string;
success: boolean;
errorMessage?: string;
attempt?: number;
}): Promise<{ id: string }> {
const result = await query(
`INSERT INTO webhook_logs (
user_id, monitor_id, alert_id, webhook_type, url, payload,
status_code, response_body, success, error_message, attempt
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
[
data.userId,
data.monitorId || null,
data.alertId || null,
data.webhookType,
data.url,
data.payload ? JSON.stringify(data.payload) : null,
data.statusCode || null,
data.responseBody || null,
data.success,
data.errorMessage || null,
data.attempt || 1,
]
);
return { id: result.rows[0].id };
},
async findByUserId(userId: string, limit = 100): Promise<any[]> {
const result = await query(
'SELECT * FROM webhook_logs WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows.map(row => toCamelCase<any>(row));
},
async findFailedByUserId(userId: string, limit = 50): Promise<any[]> {
const result = await query(
'SELECT * FROM webhook_logs WHERE user_id = $1 AND success = false ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows.map(row => toCamelCase<any>(row));
},
},
};
export default db;

View File

@@ -1,37 +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();
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();

View File

@@ -0,0 +1,12 @@
-- Migration: Add notification settings to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_enabled BOOLEAN DEFAULT true,
ADD COLUMN IF NOT EXISTS webhook_url TEXT,
ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT,
ADD COLUMN IF NOT EXISTS slack_enabled BOOLEAN DEFAULT false;
-- Add index for webhook lookups
CREATE INDEX IF NOT EXISTS idx_users_webhook_enabled ON users(webhook_enabled) WHERE webhook_enabled = true;
CREATE INDEX IF NOT EXISTS idx_users_slack_enabled ON users(slack_enabled) WHERE slack_enabled = true;

View File

@@ -0,0 +1,8 @@
-- Migration: Add email verification to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP;
-- Add index for quick lookups
CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified);

View File

@@ -0,0 +1,23 @@
-- Migration: Add webhook delivery logs table
-- For tracking webhook/slack delivery attempts and debugging
CREATE TABLE IF NOT EXISTS webhook_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
monitor_id UUID REFERENCES monitors(id) ON DELETE SET NULL,
alert_id UUID REFERENCES alerts(id) ON DELETE SET NULL,
webhook_type VARCHAR(20) NOT NULL CHECK (webhook_type IN ('webhook', 'slack')),
url TEXT NOT NULL,
payload JSONB,
status_code INTEGER,
response_body TEXT,
success BOOLEAN NOT NULL,
error_message TEXT,
attempt INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_user_id ON webhook_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_monitor_id ON webhook_logs(monitor_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_success ON webhook_logs(success) WHERE success = false;

View File

@@ -0,0 +1,11 @@
-- Add summary column to snapshots table
-- This stores human-readable summaries of changes (e.g., "3 text blocks changed, 2 new links added")
ALTER TABLE snapshots
ADD COLUMN summary TEXT;
-- Add index for faster queries when filtering by summary existence
CREATE INDEX idx_snapshots_summary ON snapshots(summary) WHERE summary IS NOT NULL;
-- Comment
COMMENT ON COLUMN snapshots.summary IS 'Human-readable change summary generated by simple HTML parsing or AI';

View File

@@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend/src/db/migrations

View File

@@ -1,93 +1,123 @@
-- 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();
-- 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),
email_enabled BOOLEAN DEFAULT true,
webhook_url TEXT,
webhook_enabled BOOLEAN DEFAULT false,
slack_webhook_url TEXT,
slack_enabled BOOLEAN DEFAULT false,
email_verified BOOLEAN DEFAULT false,
email_verified_at TIMESTAMP,
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);
CREATE INDEX IF NOT EXISTS idx_users_webhook_enabled ON users(webhook_enabled) WHERE webhook_enabled = true;
CREATE INDEX IF NOT EXISTS idx_users_slack_enabled ON users(slack_enabled) WHERE slack_enabled = true;
-- 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();
-- Webhook delivery logs table
CREATE TABLE IF NOT EXISTS webhook_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
monitor_id UUID REFERENCES monitors(id) ON DELETE SET NULL,
alert_id UUID REFERENCES alerts(id) ON DELETE SET NULL,
webhook_type VARCHAR(20) NOT NULL CHECK (webhook_type IN ('webhook', 'slack')),
url TEXT NOT NULL,
payload JSONB,
status_code INTEGER,
response_body TEXT,
success BOOLEAN NOT NULL,
error_message TEXT,
attempt INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_user_id ON webhook_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_monitor_id ON webhook_logs(monitor_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at);

View File

@@ -1,77 +1,94 @@
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);
});
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import authRoutes from './routes/auth';
import monitorRoutes from './routes/monitors';
import settingsRoutes from './routes/settings';
import { authMiddleware } from './middleware/auth';
import { apiLimiter, authLimiter } from './middleware/rateLimiter';
import { startWorker, shutdownScheduler, getSchedulerStats } from './services/scheduler';
const app = express();
const PORT = process.env.PORT || 3002;
// Middleware
app.use(cors({
origin: [process.env.APP_URL || 'http://localhost:3000', 'http://localhost:3020', 'http://localhost:3021'],
credentials: true,
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Apply general rate limiter to all API routes
app.use('/api/', apiLimiter);
// Request logging
app.use((req, _res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
next();
});
// Health check
app.get('/health', async (_req, res) => {
const schedulerStats = await getSchedulerStats();
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
scheduler: schedulerStats,
});
});
import testRoutes from './routes/test';
// Routes
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/monitors', authMiddleware, monitorRoutes);
app.use('/api/settings', authMiddleware, settingsRoutes);
app.use('/test', testRoutes);
// 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 Bull queue worker
const worker = startWorker();
console.log('📋 Bull queue worker initialized');
// 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', async () => {
console.log('SIGTERM received, shutting down gracefully...');
await worker.close();
await shutdownScheduler();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully...');
await worker.close();
await shutdownScheduler();
process.exit(0);
});

View File

@@ -1,56 +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();
}
}
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();
}
}

View File

@@ -0,0 +1,29 @@
import rateLimit from 'express-rate-limit';
// General API rate limit
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: { error: 'rate_limit_exceeded', message: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
// Strict rate limit for auth endpoints (prevent brute force)
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: { error: 'rate_limit_exceeded', message: 'Too many authentication attempts, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful logins
});
// Moderate rate limit for monitor checks
export const checkLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 20, // Limit each IP to 20 manual checks per 5 minutes
message: { error: 'rate_limit_exceeded', message: 'Too many manual checks, please wait before trying again.' },
standardHeaders: true,
legacyHeaders: false,
});

View File

@@ -1,143 +1,368 @@
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;
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import db from '../db';
import {
hashPassword,
comparePassword,
generateToken,
validateEmail,
validatePassword,
generatePasswordResetToken,
verifyPasswordResetToken,
generateEmailVerificationToken,
verifyEmailVerificationToken,
} from '../utils/auth';
import { sendPasswordResetEmail, sendEmailVerification } from '../services/alerter';
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);
// Generate verification token and send email
const verificationToken = generateEmailVerificationToken(email);
const verificationUrl = `${process.env.APP_URL || 'http://localhost:3000'}/verify-email/${verificationToken}`;
try {
await sendEmailVerification(email, verificationUrl);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
// Continue with registration even if email fails
}
const token = generateToken(user);
res.status(201).json({
token,
user: {
id: user.id,
email: user.email,
plan: user.plan,
emailVerified: user.emailVerified || false,
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',
});
}
});
// Forgot Password
router.post('/forgot-password', async (req: Request, res: Response): Promise<void> => {
try {
const { email } = z.object({ email: z.string().email() }).parse(req.body);
const user = await db.users.findByEmail(email);
// Always return success to prevent email enumeration attacks
if (!user) {
res.json({ message: 'If that email is registered, you will receive a password reset link' });
return;
}
// Generate reset token
const resetToken = generatePasswordResetToken(email);
// Send reset email
const resetUrl = `${process.env.APP_URL || 'http://localhost:3000'}/reset-password/${resetToken}`;
try {
await sendPasswordResetEmail(user.email, resetUrl);
} catch (emailError) {
console.error('Failed to send password reset email:', emailError);
// Still return success to user, but log the error
}
res.json({ message: 'If that email is registered, you will receive a password reset link' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid email',
details: error.errors,
});
return;
}
console.error('Forgot password error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to process password reset request',
});
}
});
// Reset Password
router.post('/reset-password', async (req: Request, res: Response): Promise<void> => {
try {
const { token, newPassword } = z.object({
token: z.string(),
newPassword: z.string().min(8),
}).parse(req.body);
// Verify token
let email: string;
try {
const decoded = verifyPasswordResetToken(token);
email = decoded.email;
} catch (error) {
res.status(400).json({
error: 'invalid_token',
message: 'Invalid or expired reset token',
});
return;
}
// Validate new password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
res.status(400).json({
error: 'invalid_password',
message: 'Password does not meet requirements',
details: passwordValidation.errors,
});
return;
}
// Find user
const user = await db.users.findByEmail(email);
if (!user) {
res.status(404).json({
error: 'user_not_found',
message: 'User not found',
});
return;
}
// Update password
const newPasswordHash = await hashPassword(newPassword);
await db.users.updatePassword(user.id, newPasswordHash);
res.json({ message: 'Password reset successfully' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Reset password error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to reset password',
});
}
});
// Verify Email
router.post('/verify-email', async (req: Request, res: Response): Promise<void> => {
try {
const { token } = z.object({ token: z.string() }).parse(req.body);
// Verify token
let email: string;
try {
const decoded = verifyEmailVerificationToken(token);
email = decoded.email;
} catch (error) {
res.status(400).json({
error: 'invalid_token',
message: 'Invalid or expired verification token',
});
return;
}
// Find user
const user = await db.users.findByEmail(email);
if (!user) {
res.status(404).json({
error: 'user_not_found',
message: 'User not found',
});
return;
}
// Check if already verified
if (user.emailVerified) {
res.json({ message: 'Email already verified' });
return;
}
// Mark email as verified
await db.users.verifyEmail(email);
res.json({ message: 'Email verified successfully' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Verify email error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to verify email',
});
}
});
// Resend Verification Email
router.post('/resend-verification', async (req: Request, res: Response): Promise<void> => {
try {
const { email } = z.object({ email: z.string().email() }).parse(req.body);
const user = await db.users.findByEmail(email);
// Always return success to prevent email enumeration
if (!user || user.emailVerified) {
res.json({ message: 'If that email needs verification, a new link has been sent' });
return;
}
// Generate new verification token
const verificationToken = generateEmailVerificationToken(email);
const verificationUrl = `${process.env.APP_URL || 'http://localhost:3000'}/verify-email/${verificationToken}`;
try {
await sendEmailVerification(email, verificationUrl);
} catch (emailError) {
console.error('Failed to resend verification email:', emailError);
}
res.json({ message: 'If that email needs verification, a new link has been sent' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid email',
details: error.errors,
});
return;
}
console.error('Resend verification error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to resend verification email',
});
}
});
export default router;

View File

@@ -1,371 +1,614 @@
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;
import { Router, Response } from 'express';
import { z } from 'zod';
import db from '../db';
import { AuthRequest } from '../middleware/auth';
import { checkLimiter } from '../middleware/rateLimiter';
import { MonitorFrequency, Monitor } from '../types';
import { checkMonitor, scheduleMonitor, unscheduleMonitor, rescheduleMonitor } 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);
// Attach recent snapshots to each monitor for sparklines
const monitorsWithSnapshots = await Promise.all(monitors.map(async (monitor) => {
// Get last 20 snapshots for sparkline
const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20);
return {
...monitor,
recentSnapshots
};
}));
res.json({ monitors: monitorsWithSnapshots });
} 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 (fetch fresh user data)
const currentUser = await db.users.findById(req.user.userId);
const plan = currentUser?.plan || req.user.plan;
const limits = getPlanLimits(plan);
const currentCount = await db.monitors.countByUserId(req.user.userId);
if (currentCount >= limits.maxMonitors) {
res.status(403).json({
error: 'limit_exceeded',
message: `Your ${plan} plan allows max ${limits.maxMonitors} monitors`,
});
return;
}
if (input.frequency < limits.minFrequency) {
res.status(403).json({
error: 'invalid_frequency',
message: `Your ${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 as MonitorFrequency,
status: 'active',
elementSelector: input.elementSelector,
ignoreRules: input.ignoreRules,
keywordRules: input.keywordRules,
});
// Schedule recurring checks
try {
await scheduleMonitor(monitor);
console.log(`Monitor ${monitor.id} scheduled successfully`);
} catch (err) {
console.error('Failed to schedule monitor:', err);
}
// 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) {
// Fetch fresh user data to get current plan
const currentUser = await db.users.findById(req.user.userId);
const plan = currentUser?.plan || req.user.plan;
const limits = getPlanLimits(plan);
if (input.frequency < limits.minFrequency) {
res.status(403).json({
error: 'invalid_frequency',
message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`,
});
return;
}
}
const updateData: Partial<Monitor> = {
...input,
frequency: input.frequency as MonitorFrequency | undefined,
};
const updated = await db.monitors.update(req.params.id, updateData);
if (!updated) {
res.status(500).json({ error: 'update_failed', message: 'Failed to update monitor' });
return;
}
// Reschedule if frequency changed or status changed to/from active
const needsRescheduling =
input.frequency !== undefined ||
(input.status && (input.status === 'active' || monitor.status === 'active'));
if (needsRescheduling) {
try {
if (updated.status === 'active') {
await rescheduleMonitor(updated);
console.log(`Monitor ${updated.id} rescheduled`);
} else {
await unscheduleMonitor(updated.id);
console.log(`Monitor ${updated.id} unscheduled (status: ${updated.status})`);
}
} catch (err) {
console.error('Failed to reschedule monitor:', err);
}
}
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;
}
// Unschedule before deleting
try {
await unscheduleMonitor(req.params.id);
console.log(`Monitor ${req.params.id} unscheduled before deletion`);
} catch (err) {
console.error('Failed to unschedule monitor:', err);
}
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', checkLimiter, 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 the check so user gets immediate feedback
try {
await checkMonitor(monitor.id);
// Get the latest snapshot to return to the user
const latestSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
const updatedMonitor = await db.monitors.findById(monitor.id);
res.json({
message: 'Check completed successfully',
monitor: updatedMonitor,
snapshot: latestSnapshot ? {
id: latestSnapshot.id,
changed: latestSnapshot.changed,
changePercentage: latestSnapshot.changePercentage,
httpStatus: latestSnapshot.httpStatus,
responseTime: latestSnapshot.responseTime,
createdAt: latestSnapshot.createdAt,
errorMessage: latestSnapshot.errorMessage,
} : null,
});
} catch (checkError: any) {
console.error('Check failed:', checkError);
res.status(500).json({
error: 'check_failed',
message: checkError.message || 'Failed to check monitor'
});
}
} 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 monitor audit trail (JSON or CSV)
router.get('/:id/export', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
// Check if user has export feature (PRO+)
const user = await db.users.findById(req.user.userId);
if (!user) {
res.status(404).json({ error: 'not_found', message: 'User not found' });
return;
}
// Allow export for all users for now, but in production check plan
// if (!hasFeature(user.plan, 'audit_export')) {
// res.status(403).json({ error: 'forbidden', message: 'Export feature requires Pro plan' });
// 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 format = (req.query.format as string)?.toLowerCase() || 'json';
const fromDate = req.query.from ? new Date(req.query.from as string) : undefined;
const toDate = req.query.to ? new Date(req.query.to as string) : undefined;
// Get all snapshots (up to 1000)
let snapshots = await db.snapshots.findByMonitorId(monitor.id, 1000);
// Filter by date range if provided
if (fromDate) {
snapshots = snapshots.filter(s => new Date(s.createdAt) >= fromDate);
}
if (toDate) {
snapshots = snapshots.filter(s => new Date(s.createdAt) <= toDate);
}
// Get alerts for this monitor
const allAlerts = await db.alerts.findByUserId(req.user.userId, 1000);
const monitorAlerts = allAlerts.filter(a => a.monitorId === monitor.id);
// Filter alerts by date range if provided
let filteredAlerts = monitorAlerts;
if (fromDate) {
filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) >= fromDate);
}
if (toDate) {
filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) <= toDate);
}
const exportData = {
monitor: {
id: monitor.id,
name: monitor.name,
url: monitor.url,
frequency: monitor.frequency,
status: monitor.status,
createdAt: monitor.createdAt,
},
exportedAt: new Date().toISOString(),
dateRange: {
from: fromDate?.toISOString() || 'start',
to: toDate?.toISOString() || 'now',
},
summary: {
totalChecks: snapshots.length,
changesDetected: snapshots.filter(s => s.changed).length,
errorsDetected: snapshots.filter(s => s.errorMessage).length,
totalAlerts: filteredAlerts.length,
},
checks: snapshots.map(s => ({
id: s.id,
timestamp: s.createdAt,
changed: s.changed,
changePercentage: s.changePercentage,
httpStatus: s.httpStatus,
responseTime: s.responseTime,
errorMessage: s.errorMessage,
})),
alerts: filteredAlerts.map(a => ({
id: a.id,
type: a.type,
title: a.title,
summary: a.summary,
channels: a.channels,
createdAt: a.createdAt,
deliveredAt: a.deliveredAt,
})),
};
if (format === 'csv') {
// Generate CSV
const csvLines: string[] = [];
// Header
csvLines.push('Type,Timestamp,Changed,Change %,HTTP Status,Response Time (ms),Error,Alert Type,Alert Title');
// Checks
for (const check of exportData.checks) {
csvLines.push([
'check',
check.timestamp,
check.changed ? 'true' : 'false',
check.changePercentage?.toFixed(2) || '',
check.httpStatus,
check.responseTime,
`"${(check.errorMessage || '').replace(/"/g, '""')}"`,
'',
'',
].join(','));
}
// Alerts
for (const alert of exportData.alerts) {
csvLines.push([
'alert',
alert.createdAt,
'',
'',
'',
'',
'',
alert.type,
`"${(alert.title || '').replace(/"/g, '""')}"`,
].join(','));
}
const csv = csvLines.join('\n');
const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.csv`;
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(csv);
} else {
// JSON format
const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.json`;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.json(exportData);
}
} catch (error) {
console.error('Export error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to export audit trail' });
}
});
export default router;

View File

@@ -0,0 +1,185 @@
import { Router, Response } from 'express';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import db from '../db';
import { AuthRequest } from '../middleware/auth';
const router = Router();
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
});
const updateNotificationsSchema = z.object({
emailEnabled: z.boolean().optional(),
webhookUrl: z.string().url().optional().nullable(),
webhookEnabled: z.boolean().optional(),
slackWebhookUrl: z.string().url().optional().nullable(),
slackEnabled: z.boolean().optional(),
});
// Get user settings
router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const user = await db.users.findById(req.user.userId);
if (!user) {
res.status(404).json({ error: 'not_found', message: 'User not found' });
return;
}
// Return user settings (exclude password hash)
res.json({
settings: {
email: user.email,
plan: user.plan,
stripeCustomerId: user.stripeCustomerId,
emailEnabled: user.emailEnabled ?? true,
webhookUrl: user.webhookUrl,
webhookEnabled: user.webhookEnabled ?? false,
slackWebhookUrl: user.slackWebhookUrl,
slackEnabled: user.slackEnabled ?? false,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
},
});
} catch (error) {
console.error('Get settings error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to get settings' });
}
});
// Change password
router.post('/change-password', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const input = changePasswordSchema.parse(req.body);
const user = await db.users.findById(req.user.userId);
if (!user) {
res.status(404).json({ error: 'not_found', message: 'User not found' });
return;
}
// Verify current password
const isValidPassword = await bcrypt.compare(input.currentPassword, user.passwordHash);
if (!isValidPassword) {
res.status(401).json({ error: 'invalid_password', message: 'Current password is incorrect' });
return;
}
// Hash new password
const newPasswordHash = await bcrypt.hash(input.newPassword, 10);
// Update password
await db.users.updatePassword(req.user.userId, newPasswordHash);
res.json({ message: 'Password changed successfully' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Change password error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to change password' });
}
});
// Update notification preferences
router.put('/notifications', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const input = updateNotificationsSchema.parse(req.body);
await db.users.updateNotificationSettings(req.user.userId, {
emailEnabled: input.emailEnabled,
webhookUrl: input.webhookUrl,
webhookEnabled: input.webhookEnabled,
slackWebhookUrl: input.slackWebhookUrl,
slackEnabled: input.slackEnabled,
});
res.json({ message: 'Notification settings updated successfully' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Update notifications error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to update notifications' });
}
});
// Delete account
router.delete('/account', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const { password } = req.body;
if (!password) {
res.status(400).json({ error: 'validation_error', message: 'Password is required' });
return;
}
const user = await db.users.findById(req.user.userId);
if (!user) {
res.status(404).json({ error: 'not_found', message: 'User not found' });
return;
}
// Verify password before deletion
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
res.status(401).json({ error: 'invalid_password', message: 'Password is incorrect' });
return;
}
// Delete all user's monitors (cascades to snapshots and alerts)
const monitors = await db.monitors.findByUserId(req.user.userId);
for (const monitor of monitors) {
await db.monitors.delete(monitor.id);
}
// Delete user
await db.users.delete(req.user.userId);
res.json({ message: 'Account deleted successfully' });
} catch (error) {
console.error('Delete account error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to delete account' });
}
});
export default router;

View File

@@ -0,0 +1,65 @@
import { Router, Response } from 'express';
const router = Router();
router.get('/dynamic', (_req, res) => {
const now = new Date();
const timeString = now.toLocaleTimeString();
const randomValue = Math.floor(Math.random() * 1000);
// Toggle status based on seconds (even/odd) to guarantee change
const isNormal = now.getSeconds() % 2 === 0;
const statusMessage = isNormal
? "System Status: NORMAL - Everything is running smoothly."
: "System Status: WARNING - High load detected on server node!";
const statusColor = isNormal ? "green" : "red";
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Dynamic Test Page</title>
<style>
body { font-family: sans-serif; padding: 20px; }
.time { font-size: 2em; color: #0066cc; }
.status { font-size: 1.5em; color: ${statusColor}; font-weight: bold; padding: 20px; border: 2px solid ${statusColor}; margin: 20px 0; }
</style>
</head>
<body>
<h1>Website Monitor Test</h1>
<div class="status">${statusMessage}</div>
<div class="content">
<p>Current Time: <span class="time">${timeString}</span></p>
<p>Random Value: <span class="random">${randomValue}</span></p>
<p>This page content flips every second to simulate a real website change.</p>
<div style="background: #f0f9ff; padding: 15px; margin-top: 20px; border-left: 4px solid #0066cc;">
<h3>New Feature Update</h3>
<p>We have deployed a new importance scoring update!</p>
</div>
</div>
</body>
</html>
`;
res.send(html);
});
// Test endpoint that returns a 404 error for testing incident display
router.get('/error', (_req, res: Response) => {
res.status(404).send(`
<!DOCTYPE html>
<html>
<head>
<title>404 Not Found</title>
</head>
<body>
<h1>404 - Page Not Found</h1>
<p>This page intentionally returns a 404 error for testing.</p>
</body>
</html>
`);
});
export default router;

View File

@@ -1,209 +1,582 @@
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);
}
}
import * as nodemailer from 'nodemailer';
import { Monitor, User, Snapshot, AlertChannel } from '../types';
import { KeywordMatch } from './differ';
import db from '../db';
import { APP_CONFIG, WEBHOOK_CONFIG, hasFeature } from '../config';
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,
},
});
// ============================================
// Slack Integration
// ============================================
interface SlackMessage {
title: string;
text: string;
url?: string;
color?: 'good' | 'warning' | 'danger';
}
/**
* Send a notification to a Slack webhook
*/
export async function sendSlackNotification(
webhookUrl: string,
message: SlackMessage,
userId: string,
monitorId?: string,
alertId?: string
): Promise<boolean> {
const payload = {
attachments: [
{
color: message.color || '#007bff',
title: message.title,
title_link: message.url,
text: message.text,
footer: 'Website Monitor',
ts: Math.floor(Date.now() / 1000),
},
],
};
let attempt = 1;
let lastError: string | undefined;
while (attempt <= WEBHOOK_CONFIG.maxRetries) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), WEBHOOK_CONFIG.timeoutMs);
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
const responseBody = await response.text();
// Log the attempt
await db.webhookLogs.create({
userId,
monitorId,
alertId,
webhookType: 'slack',
url: webhookUrl,
payload,
statusCode: response.status,
responseBody: responseBody.substring(0, 1000),
success: response.ok,
errorMessage: response.ok ? undefined : `HTTP ${response.status}`,
attempt,
});
if (response.ok) {
console.log(`[Slack] Notification sent successfully`);
return true;
}
lastError = `HTTP ${response.status}: ${responseBody}`;
} catch (error: any) {
lastError = error.message || 'Unknown error';
// Log failed attempt
await db.webhookLogs.create({
userId,
monitorId,
alertId,
webhookType: 'slack',
url: webhookUrl,
payload,
success: false,
errorMessage: lastError,
attempt,
});
}
if (attempt < WEBHOOK_CONFIG.maxRetries) {
await new Promise(resolve => setTimeout(resolve, WEBHOOK_CONFIG.retryDelayMs * attempt));
}
attempt++;
}
console.error(`[Slack] Failed after ${WEBHOOK_CONFIG.maxRetries} attempts: ${lastError}`);
return false;
}
// ============================================
// Webhook Integration
// ============================================
interface WebhookPayload {
event: 'change' | 'error' | 'keyword';
monitor: {
id: string;
name: string;
url: string;
};
details: {
changePercentage?: number;
errorMessage?: string;
keywordMatch?: KeywordMatch;
};
timestamp: string;
viewUrl: string;
}
/**
* Send a notification to a generic webhook
*/
export async function sendWebhookNotification(
webhookUrl: string,
payload: WebhookPayload,
userId: string,
monitorId?: string,
alertId?: string
): Promise<boolean> {
let attempt = 1;
let lastError: string | undefined;
while (attempt <= WEBHOOK_CONFIG.maxRetries) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), WEBHOOK_CONFIG.timeoutMs);
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'WebsiteMonitor/1.0',
'X-Webhook-Event': payload.event,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
let responseBody = '';
try {
responseBody = await response.text();
} catch {
// Ignore response body parsing errors
}
// Log the attempt
await db.webhookLogs.create({
userId,
monitorId,
alertId,
webhookType: 'webhook',
url: webhookUrl,
payload: payload as any,
statusCode: response.status,
responseBody: responseBody.substring(0, 1000),
success: response.ok,
errorMessage: response.ok ? undefined : `HTTP ${response.status}`,
attempt,
});
if (response.ok) {
console.log(`[Webhook] Notification sent successfully`);
return true;
}
lastError = `HTTP ${response.status}`;
} catch (error: any) {
lastError = error.message || 'Unknown error';
// Log failed attempt
await db.webhookLogs.create({
userId,
monitorId,
alertId,
webhookType: 'webhook',
url: webhookUrl,
payload: payload as any,
success: false,
errorMessage: lastError,
attempt,
});
}
if (attempt < WEBHOOK_CONFIG.maxRetries) {
await new Promise(resolve => setTimeout(resolve, WEBHOOK_CONFIG.retryDelayMs * attempt));
}
attempt++;
}
console.error(`[Webhook] Failed after ${WEBHOOK_CONFIG.maxRetries} attempts: ${lastError}`);
return false;
}
// ============================================
// Unified Alert Dispatcher
// ============================================
interface AlertData {
title: string;
summary: string;
htmlContent: string;
viewUrl: string;
color?: 'good' | 'warning' | 'danger';
changePercentage?: number;
errorMessage?: string;
keywordMatch?: KeywordMatch;
}
/**
* Dispatch an alert to all configured channels for a user
*/
async function dispatchAlert(
user: User,
monitor: Monitor,
snapshot: Snapshot | null,
alertType: 'change' | 'error' | 'keyword',
data: AlertData
): Promise<AlertChannel[]> {
const usedChannels: AlertChannel[] = [];
// Create alert record first
let alertId: string | undefined;
if (snapshot) {
const alert = await db.alerts.create({
monitorId: monitor.id,
snapshotId: snapshot.id,
userId: user.id,
type: alertType,
title: data.title,
summary: data.summary,
channels: ['email'], // Will be updated after dispatch
});
alertId = alert.id;
}
// 1. Email (always available)
if (user.emailEnabled !== false) {
try {
await transporter.sendMail({
from: APP_CONFIG.emailFrom,
to: user.email,
subject: data.title,
html: data.htmlContent,
});
usedChannels.push('email');
console.log(`[Alert] Email sent to ${user.email}`);
} catch (error) {
console.error(`[Alert] Failed to send email:`, error);
}
}
// 2. Slack (PRO+ feature)
if (user.slackEnabled && user.slackWebhookUrl && hasFeature(user.plan, 'slack_integration')) {
const success = await sendSlackNotification(
user.slackWebhookUrl,
{
title: data.title,
text: data.summary,
url: data.viewUrl,
color: data.color || 'warning',
},
user.id,
monitor.id,
alertId
);
if (success) {
usedChannels.push('slack');
}
}
// 3. Webhook (PRO+ feature)
if (user.webhookEnabled && user.webhookUrl && hasFeature(user.plan, 'webhook_integration')) {
const webhookPayload: WebhookPayload = {
event: alertType,
monitor: {
id: monitor.id,
name: monitor.name,
url: monitor.url,
},
details: {
changePercentage: data.changePercentage,
errorMessage: data.errorMessage,
keywordMatch: data.keywordMatch,
},
timestamp: new Date().toISOString(),
viewUrl: data.viewUrl,
};
const success = await sendWebhookNotification(
user.webhookUrl,
webhookPayload,
user.id,
monitor.id,
alertId
);
if (success) {
usedChannels.push('webhook');
}
}
// Update alert with used channels
if (alertId && usedChannels.length > 0) {
await db.alerts.updateChannels(alertId, usedChannels);
await db.alerts.markAsDelivered(alertId);
}
return usedChannels;
}
// ============================================
// Alert Functions (Public API)
// ============================================
export async function sendChangeAlert(
monitor: Monitor,
user: User,
snapshot: Snapshot,
changePercentage: number
): Promise<void> {
const diffUrl = `${APP_CONFIG.appUrl}/monitors/${monitor.id}/history/${snapshot.id}`;
const htmlContent = `
<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>
${snapshot.summary ? `<p><strong>What Changed:</strong> ${snapshot.summary}</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_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`;
await dispatchAlert(user, monitor, snapshot, 'change', {
title: `Change detected: ${monitor.name}`,
summary: snapshot.summary || `${changePercentage.toFixed(2)}% of the page changed`,
htmlContent,
viewUrl: diffUrl,
color: changePercentage > 50 ? 'danger' : changePercentage > 10 ? 'warning' : 'good',
changePercentage,
});
}
export async function sendErrorAlert(
monitor: Monitor,
user: User,
errorMessage: string
): Promise<void> {
const monitorUrl = `${APP_CONFIG.appUrl}/monitors/${monitor.id}`;
const htmlContent = `
<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_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`;
// 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,
});
await dispatchAlert(user, monitor, snapshot, 'error', {
title: `Error monitoring: ${monitor.name}`,
summary: errorMessage,
htmlContent,
viewUrl: monitorUrl,
color: 'danger',
errorMessage,
});
}
export async function sendKeywordAlert(
monitor: Monitor,
user: User,
match: KeywordMatch
): Promise<void> {
const monitorUrl = `${APP_CONFIG.appUrl}/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 htmlContent = `
<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_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`;
// Get latest snapshot
const snapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
await dispatchAlert(user, monitor, snapshot, 'keyword', {
title: `Keyword alert: ${monitor.name}`,
summary: message,
htmlContent,
viewUrl: monitorUrl,
color: match.type === 'appeared' ? 'good' : 'warning',
keywordMatch: match,
});
}
// ============================================
// Email-only Functions (Auth flows)
// ============================================
export async function sendPasswordResetEmail(
email: string,
resetUrl: string
): Promise<void> {
try {
const mailOptions = {
from: APP_CONFIG.emailFrom,
to: email,
subject: 'Password Reset Request',
html: `
<h2>Password Reset Request</h2>
<p>You requested to reset your password for your Website Monitor account.</p>
<p>Click the button below to reset your password. This link will expire in 1 hour.</p>
<p>
<a href="${resetUrl}" style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 20px 0;">
Reset Password
</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="background: #f5f5f5; padding: 10px; border-radius: 5px; word-break: break-all;">
${resetUrl}
</p>
<p style="color: #666; margin-top: 30px;">
If you didn't request a password reset, you can safely ignore this email.
Your password will not be changed.
</p>
<p style="color: #999; font-size: 12px; margin-top: 30px;">
This is an automated email. Please do not reply.
</p>
`,
};
await transporter.sendMail(mailOptions);
console.log(`[Alert] Password reset email sent to ${email}`);
} catch (error) {
console.error('[Alert] Failed to send password reset email:', error);
throw error;
}
}
export async function sendEmailVerification(
email: string,
verificationUrl: string
): Promise<void> {
try {
const mailOptions = {
from: APP_CONFIG.emailFrom,
to: email,
subject: 'Verify Your Email - Website Monitor',
html: `
<h2>Welcome to Website Monitor!</h2>
<p>Thank you for signing up. Please verify your email address to activate your account.</p>
<p>Click the button below to verify your email:</p>
<p>
<a href="${verificationUrl}" style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 20px 0;">
Verify Email Address
</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="background: #f5f5f5; padding: 10px; border-radius: 5px; word-break: break-all;">
${verificationUrl}
</p>
<p style="color: #666; margin-top: 30px;">
This verification link will expire in 24 hours.
</p>
<p style="color: #999; font-size: 12px; margin-top: 30px;">
If you didn't create an account, you can safely ignore this email.
</p>
`,
};
await transporter.sendMail(mailOptions);
console.log(`[Alert] Verification email sent to ${email}`);
} catch (error) {
console.error('[Alert] Failed to send verification email:', error);
throw error;
}
}

View File

@@ -1,181 +1,199 @@
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';
}
import { diffLines, 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();
// Enhanced timestamp patterns to catch more formats
const timestampPatterns = [
// ISO timestamps
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?/gi,
// Date formats
/\d{1,2}\/\d{1,2}\/\d{4}/gi, // MM/DD/YYYY
/\d{1,2}-\d{1,2}-\d{4}/gi, // MM-DD-YYYY
/\d{4}\/\d{1,2}\/\d{1,2}/gi, // YYYY/MM/DD
/\d{4}-\d{1,2}-\d{1,2}/gi, // YYYY-MM-DD
// Time formats
/\d{1,2}:\d{2}:\d{2}/gi, // HH:MM:SS
/\d{1,2}:\d{2}\s?(AM|PM|am|pm)/gi, // HH:MM AM/PM
// Common date patterns with month names
/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}/gi,
/\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{4}/gi,
// Timestamps with labels
/Last updated:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
/Updated:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
/Modified:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
/Posted:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
/Published:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
// Unix timestamps (10 or 13 digits)
/\b\d{10,13}\b/g,
// Relative times
/\d+\s+(second|minute|hour|day|week|month|year)s?\s+ago/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,282 @@
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
import nodemailer from 'nodemailer';
import db from '../db';
// Redis connection (reuse from main scheduler)
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
maxRetriesPerRequest: null,
});
// Digest queue
export const digestQueue = new Queue('change-digests', {
connection: redisConnection,
defaultJobOptions: {
removeOnComplete: 10,
removeOnFail: 10,
},
});
// Email transporter (same config as alerter)
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';
interface DigestChange {
monitorId: string;
monitorName: string;
monitorUrl: string;
changePercentage: number;
changedAt: Date;
importanceScore: number;
}
interface DigestUser {
id: string;
email: string;
digestInterval: 'daily' | 'weekly' | 'none';
lastDigestAt: Date | null;
}
/**
* Get users who need a digest email
*/
async function getUsersForDigest(interval: 'daily' | 'weekly'): Promise<DigestUser[]> {
const cutoffHours = interval === 'daily' ? 24 : 168; // 24h or 7 days
const result = await db.query(
`SELECT id, email,
COALESCE(notification_preferences->>'digestInterval', 'none') as "digestInterval",
last_digest_at as "lastDigestAt"
FROM users
WHERE COALESCE(notification_preferences->>'digestInterval', 'none') = $1
AND (last_digest_at IS NULL OR last_digest_at < NOW() - INTERVAL '${cutoffHours} hours')`,
[interval]
);
return result.rows;
}
/**
* Get changes for a user since their last digest
*/
async function getChangesForUser(userId: string, since: Date): Promise<DigestChange[]> {
const result = await db.query(
`SELECT
m.id as "monitorId",
m.name as "monitorName",
m.url as "monitorUrl",
s.change_percentage as "changePercentage",
s.checked_at as "changedAt",
COALESCE(s.importance_score, 50) as "importanceScore"
FROM monitors m
JOIN snapshots s ON s.monitor_id = m.id
WHERE m.user_id = $1
AND s.has_changes = true
AND s.checked_at > $2
ORDER BY s.importance_score DESC, s.checked_at DESC
LIMIT 50`,
[userId, since]
);
return result.rows;
}
/**
* Generate HTML for the digest email
*/
function generateDigestHtml(changes: DigestChange[], interval: string): string {
const periodText = interval === 'daily' ? 'today' : 'this week';
if (changes.length === 0) {
return `
<div style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">📊 Your Change Digest</h2>
<p style="color: #666;">No changes detected ${periodText}. All quiet on your monitors!</p>
<p style="color: #999; font-size: 12px;">Visit <a href="${APP_URL}/monitors">your dashboard</a> to manage your monitors.</p>
</div>
`;
}
// Group by importance
const highImportance = changes.filter(c => c.importanceScore >= 70);
const mediumImportance = changes.filter(c => c.importanceScore >= 40 && c.importanceScore < 70);
const lowImportance = changes.filter(c => c.importanceScore < 40);
let html = `
<div style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">📊 Your Change Digest</h2>
<p style="color: #666;">Here's what changed ${periodText}:</p>
<div style="background: #f5f5f0; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<strong style="color: #333;">${changes.length} changes</strong> detected across your monitors
</div>
`;
if (highImportance.length > 0) {
html += `
<h3 style="color: #e74c3c; margin-top: 20px;">🔴 High Priority (${highImportance.length})</h3>
${generateChangesList(highImportance)}
`;
}
if (mediumImportance.length > 0) {
html += `
<h3 style="color: #f39c12; margin-top: 20px;">🟡 Medium Priority (${mediumImportance.length})</h3>
${generateChangesList(mediumImportance)}
`;
}
if (lowImportance.length > 0) {
html += `
<h3 style="color: #27ae60; margin-top: 20px;">🟢 Low Priority (${lowImportance.length})</h3>
${generateChangesList(lowImportance)}
`;
}
html += `
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px;">
<a href="${APP_URL}/settings">Manage digest settings</a> |
<a href="${APP_URL}/monitors">View all monitors</a>
</p>
</div>
`;
return html;
}
function generateChangesList(changes: DigestChange[]): string {
return `
<table style="width: 100%; border-collapse: collapse;">
${changes.map(c => `
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 10px 0;">
<strong style="color: #333;">${c.monitorName}</strong>
<br>
<span style="color: #999; font-size: 12px;">${c.monitorUrl}</span>
</td>
<td style="padding: 10px 0; text-align: right;">
<span style="background: ${c.changePercentage > 50 ? '#e74c3c' : c.changePercentage > 10 ? '#f39c12' : '#27ae60'}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
${c.changePercentage.toFixed(1)}% changed
</span>
</td>
</tr>
`).join('')}
</table>
`;
}
/**
* Send digest email to user
*/
async function sendDigestEmail(user: DigestUser, changes: DigestChange[]): Promise<void> {
const subject = changes.length > 0
? `📊 ${changes.length} change${changes.length > 1 ? 's' : ''} detected on your monitors`
: '📊 Your monitor digest - All quiet!';
const html = generateDigestHtml(changes, user.digestInterval);
await transporter.sendMail({
from: EMAIL_FROM,
to: user.email,
subject,
html,
});
// Update last digest timestamp
await db.query(
'UPDATE users SET last_digest_at = NOW() WHERE id = $1',
[user.id]
);
console.log(`[Digest] Sent ${user.digestInterval} digest to ${user.email} with ${changes.length} changes`);
}
/**
* Process all pending digests
*/
export async function processDigests(interval: 'daily' | 'weekly'): Promise<void> {
console.log(`[Digest] Processing ${interval} digests...`);
const users = await getUsersForDigest(interval);
console.log(`[Digest] Found ${users.length} users for ${interval} digest`);
for (const user of users) {
try {
const since = user.lastDigestAt || new Date(Date.now() - (interval === 'daily' ? 24 : 168) * 60 * 60 * 1000);
const changes = await getChangesForUser(user.id, since);
await sendDigestEmail(user, changes);
} catch (error) {
console.error(`[Digest] Error sending digest to ${user.email}:`, error);
}
}
}
/**
* Schedule digest jobs (call on server start)
*/
export async function scheduleDigestJobs(): Promise<void> {
// Daily digest at 9 AM
await digestQueue.add(
'daily-digest',
{ interval: 'daily' },
{
jobId: 'daily-digest',
repeat: {
pattern: '0 9 * * *', // Every day at 9 AM
},
}
);
// Weekly digest on Mondays at 9 AM
await digestQueue.add(
'weekly-digest',
{ interval: 'weekly' },
{
jobId: 'weekly-digest',
repeat: {
pattern: '0 9 * * 1', // Every Monday at 9 AM
},
}
);
console.log('[Digest] Scheduled daily and weekly digest jobs');
}
/**
* Start digest worker
*/
export function startDigestWorker(): Worker {
const worker = new Worker(
'change-digests',
async (job) => {
const { interval } = job.data;
await processDigests(interval);
},
{
connection: redisConnection,
concurrency: 1,
}
);
worker.on('completed', (job) => {
console.log(`[Digest] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[Digest] Job ${job?.id} failed:`, err.message);
});
console.log('[Digest] Worker started');
return worker;
}

View File

@@ -1,128 +1,129 @@
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');
}
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,
responseType: 'text', // Force text response to avoid auto-parsing JSON
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 = typeof response.data === 'string' ? response.data : JSON.stringify(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,190 @@
import db from '../db';
interface ImportanceFactors {
changePercentage: number; // 0-100
keywordMatches: number; // Anzahl wichtiger Keywords
isMainContent: boolean; // Haupt- vs. Sidebar-Content
isRecurringPattern: boolean; // Wiederkehrendes Muster (z.B. täglich)
contentLength: number; // Länge des geänderten Contents
}
/**
* Calculate importance score for a change (0-100)
* Higher scores indicate more significant changes
*/
export function calculateImportanceScore(factors: ImportanceFactors): number {
let score = 0;
// 1. Change Percentage (max 40 points)
// - Small changes (<5%) = low importance
// - Medium changes (5-20%) = medium importance
// - Large changes (>20%) = high importance
if (factors.changePercentage < 5) {
score += factors.changePercentage * 2; // 0-10 points
} else if (factors.changePercentage < 20) {
score += 10 + (factors.changePercentage - 5) * 1.5; // 10-32.5 points
} else {
score += Math.min(32.5 + (factors.changePercentage - 20) * 0.5, 40); // 32.5-40 points
}
// 2. Keyword matches (max 30 points)
// Each keyword match adds importance
score += Math.min(factors.keywordMatches * 10, 30);
// 3. Main content bonus (20 points)
// Changes in main content area are more important than sidebar/footer
if (factors.isMainContent) {
score += 20;
}
// 4. Content length consideration (max 10 points)
// Longer changes tend to be more significant
if (factors.contentLength > 500) {
score += 10;
} else if (factors.contentLength > 100) {
score += 5;
} else if (factors.contentLength > 50) {
score += 2;
}
// 5. Recurring pattern penalty (-15 points)
// If changes happen at the same time pattern, they're likely automated/less important
if (factors.isRecurringPattern) {
score -= 15;
}
// Clamp to 0-100
return Math.max(0, Math.min(100, Math.round(score)));
}
/**
* Check if monitor has a recurring change pattern
* (changes occurring at similar times/intervals)
*/
export async function detectRecurringPattern(monitorId: string): Promise<boolean> {
try {
// Get last 10 changes for this monitor
const result = await db.query(
`SELECT checked_at
FROM snapshots
WHERE monitor_id = $1 AND has_changes = true
ORDER BY checked_at DESC
LIMIT 10`,
[monitorId]
);
if (result.rows.length < 3) {
return false; // Not enough data to detect pattern
}
const timestamps = result.rows.map((r: any) => new Date(r.checked_at));
// Check for same-hour pattern (changes always at similar hour)
const hours = timestamps.map((t: Date) => t.getHours());
const hourCounts: Record<number, number> = {};
hours.forEach((h: number) => {
hourCounts[h] = (hourCounts[h] || 0) + 1;
});
// If more than 60% of changes happen at the same hour, it's a pattern
const maxHourCount = Math.max(...Object.values(hourCounts));
if (maxHourCount / timestamps.length > 0.6) {
return true;
}
// Check for regular interval pattern
if (timestamps.length >= 3) {
const intervals: number[] = [];
for (let i = 0; i < timestamps.length - 1; i++) {
intervals.push(timestamps[i].getTime() - timestamps[i + 1].getTime());
}
// Check if intervals are consistent (within 10% variance)
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const variance = intervals.map(i => Math.abs(i - avgInterval) / avgInterval);
const avgVariance = variance.reduce((a, b) => a + b, 0) / variance.length;
if (avgVariance < 0.1) {
return true; // Changes happen at regular intervals
}
}
return false;
} catch (error) {
console.error('[Importance] Error detecting pattern:', error);
return false;
}
}
/**
* Simple heuristic to detect if change is in main content area
* Based on common HTML patterns
*/
export function isMainContentChange(htmlDiff: string): boolean {
const mainContentPatterns = [
'<main', '</main>',
'<article', '</article>',
'class="content"', 'class="main"',
'id="content"', 'id="main"',
'<h1', '<h2', '<h3',
'<p>', '</p>'
];
const sidebarPatterns = [
'<aside', '</aside>',
'<nav', '</nav>',
'<footer', '</footer>',
'<header', '</header>',
'class="sidebar"', 'class="nav"',
'class="footer"', 'class="header"'
];
const lowerDiff = htmlDiff.toLowerCase();
let mainContentScore = 0;
let sidebarScore = 0;
mainContentPatterns.forEach(pattern => {
if (lowerDiff.includes(pattern.toLowerCase())) {
mainContentScore++;
}
});
sidebarPatterns.forEach(pattern => {
if (lowerDiff.includes(pattern.toLowerCase())) {
sidebarScore++;
}
});
return mainContentScore >= sidebarScore;
}
/**
* Get importance level label from score
*/
export function getImportanceLevel(score: number): 'high' | 'medium' | 'low' {
if (score >= 70) return 'high';
if (score >= 40) return 'medium';
return 'low';
}
/**
* Calculate importance for a specific change
*/
export async function calculateChangeImportance(
monitorId: string,
changePercentage: number,
keywordMatches: number,
diffContent: string
): Promise<number> {
const isRecurring = await detectRecurringPattern(monitorId);
const isMainContent = isMainContentChange(diffContent);
return calculateImportanceScore({
changePercentage,
keywordMatches,
isMainContent,
isRecurringPattern: isRecurring,
contentLength: diffContent.length
});
}

View File

@@ -1,158 +1,216 @@
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`);
}
import db from '../db';
import { Monitor, Snapshot } from '../types';
import { fetchPage } from './fetcher';
import {
applyIgnoreRules,
applyCommonNoiseFilters,
compareDiffs,
checkKeywords,
} from './differ';
import { calculateChangeImportance } from './importance';
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
import { generateSimpleSummary, generateAISummary } from './summarizer';
export interface CheckResult {
snapshot: Snapshot;
alertSent: boolean;
}
export async function checkMonitor(monitorId: string): Promise<CheckResult | 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' && monitor.status !== 'error') {
console.log(`[Monitor] Monitor ${monitorId} is not active or error, skipping (status: ${monitor.status})`);
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);
}
}
// Check for HTTP error status
if (!fetchResult.error && fetchResult.status >= 400) {
fetchResult.error = `HTTP ${fetchResult.status}`;
}
// If still failing after retries
if (fetchResult.error) {
console.error(`[Monitor] Failed to fetch ${monitor.url}: ${fetchResult.error}`);
// Create error snapshot
const failedSnapshot = 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 { snapshot: failedSnapshot, alertSent: false };
}
// Apply noise filters
console.log(`[Monitor] Ignore rules for ${monitor.name}:`, JSON.stringify(monitor.ignoreRules));
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;
let diffResult: ReturnType<typeof compareDiffs> | undefined;
if (previousSnapshot) {
// Apply same filters to previous content for fair comparison
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
// Compare
diffResult = compareDiffs(previousHtml, processedHtml);
changed = diffResult.changed;
changePercentage = diffResult.changePercentage;
console.log(
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}, Additions=${diffResult.additions}, Deletions=${diffResult.deletions}`
);
// 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`);
}
// Generate human-readable summary (Hybrid approach)
let summary: string | undefined;
if (changed && previousSnapshot && diffResult) {
// Hybrid logic: AI for changes (≥5%), simple for very small changes
if (changePercentage >= 5) {
console.log(`[Monitor] Change (${changePercentage}%), using AI summary`);
try {
summary = await generateAISummary(diffResult.diff, changePercentage);
} catch (error) {
console.error('[Monitor] AI summary failed, falling back to simple summary:', error);
summary = generateSimpleSummary(
diffResult.diff,
previousSnapshot.htmlContent,
fetchResult.html
);
}
} else {
console.log(`[Monitor] Small change (${changePercentage}%), using simple summary`);
summary = generateSimpleSummary(
diffResult.diff,
previousSnapshot.htmlContent,
fetchResult.html
);
}
}
// 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,
importanceScore: changed ? await calculateChangeImportance(monitor.id, changePercentage, 0, processedHtml) : 0,
summary,
});
// Update monitor
await db.monitors.updateLastChecked(monitor.id, changed);
// Send alert if changed and not first check
if (changed && previousSnapshot) {
try {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendChangeAlert(monitor, user, snapshot, changePercentage);
}
} catch (alertError) {
console.error(`[Monitor] Failed to send alert for ${monitor.id}:`, alertError);
// Continue execution - do not fail the check
}
}
// Clean up old snapshots based on user plan retention period
try {
const retentionUser = await db.users.findById(monitor.userId);
if (retentionUser) {
const { getRetentionDays } = await import('../config');
const retentionDays = getRetentionDays(retentionUser.plan);
await db.snapshots.deleteOldSnapshotsByAge(monitor.id, retentionDays);
}
} catch (cleanupError) {
console.error(`[Monitor] Failed to cleanup snapshots for ${monitor.id}:`, cleanupError);
}
console.log(`[Monitor] Check completed for ${monitor.name}`);
return { snapshot, alertSent: changed && !!previousSnapshot };
} catch (error) {
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
await db.monitors.incrementErrors(monitorId);
}
}
// Re-export scheduler functions for backward compatibility
export { scheduleMonitor, unscheduleMonitor, rescheduleMonitor } from './scheduler';

View File

@@ -0,0 +1,197 @@
import { Queue, Worker, QueueEvents } from 'bullmq';
import Redis from 'ioredis';
import { checkMonitor } from './monitor';
import { Monitor } from '../db';
// Redis connection
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
maxRetriesPerRequest: null,
});
// Monitor check queue
export const monitorQueue = new Queue('monitor-checks', {
connection: redisConnection,
defaultJobOptions: {
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
attempts: 3, // Retry failed jobs 3 times
backoff: {
type: 'exponential',
delay: 5000, // Start with 5 second delay
},
},
});
// Queue events for monitoring
const queueEvents = new QueueEvents('monitor-checks', { connection: redisConnection });
queueEvents.on('completed', ({ jobId }) => {
console.log(`[Scheduler] Job ${jobId} completed`);
});
queueEvents.on('failed', ({ jobId, failedReason }) => {
console.error(`[Scheduler] Job ${jobId} failed:`, failedReason);
});
/**
* Schedule a monitor for recurring checks
*/
export async function scheduleMonitor(monitor: Monitor): Promise<void> {
if (monitor.status !== 'active') {
console.log(`[Scheduler] Skipping inactive monitor ${monitor.id}`);
return;
}
const jobId = `monitor-${monitor.id}`;
try {
// Remove existing job if it exists
await unscheduleMonitor(monitor.id);
// Add new recurring job
await monitorQueue.add(
'check-monitor',
{
monitorId: monitor.id,
url: monitor.url,
name: monitor.name,
},
{
jobId,
repeat: {
every: monitor.frequency * 60 * 1000, // Convert minutes to milliseconds
},
}
);
console.log(
`[Scheduler] Monitor ${monitor.id} scheduled (frequency: ${monitor.frequency} min)`
);
} catch (error) {
console.error(`[Scheduler] Failed to schedule monitor ${monitor.id}:`, error);
throw error;
}
}
/**
* Remove a monitor from the schedule
*/
export async function unscheduleMonitor(monitorId: string): Promise<void> {
const jobId = `monitor-${monitorId}`;
try {
// Get all repeatable jobs
const repeatableJobs = await monitorQueue.getRepeatableJobs();
// Find and remove the job for this monitor
const job = repeatableJobs.find((j) => j.id === jobId);
if (job && job.key) {
await monitorQueue.removeRepeatableByKey(job.key);
console.log(`[Scheduler] Monitor ${monitorId} unscheduled`);
}
// Also remove any pending jobs with this ID
const jobs = await monitorQueue.getJobs(['waiting', 'delayed']);
for (const j of jobs) {
if (j.id === jobId) {
await j.remove();
}
}
} catch (error) {
console.error(`[Scheduler] Failed to unschedule monitor ${monitorId}:`, error);
// Don't throw - unscheduling is best-effort
}
}
/**
* Reschedule a monitor (useful when frequency changes)
*/
export async function rescheduleMonitor(monitor: Monitor): Promise<void> {
console.log(`[Scheduler] Rescheduling monitor ${monitor.id}`);
await unscheduleMonitor(monitor.id);
await scheduleMonitor(monitor);
}
/**
* Initialize the worker to process monitor checks
*/
export function startWorker(): Worker {
const worker = new Worker(
'monitor-checks',
async (job) => {
const { monitorId } = job.data;
console.log(`[Worker] Processing check for monitor ${monitorId}`);
try {
await checkMonitor(monitorId);
console.log(`[Worker] Successfully checked monitor ${monitorId}`);
} catch (error) {
console.error(`[Worker] Error checking monitor ${monitorId}:`, error);
throw error; // Re-throw to mark job as failed
}
},
{
connection: redisConnection,
concurrency: 5, // Process up to 5 monitors concurrently
}
);
worker.on('completed', (job) => {
console.log(`[Worker] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[Worker] Job ${job?.id} failed:`, err.message);
});
worker.on('error', (err) => {
console.error('[Worker] Worker error:', err);
});
console.log('[Worker] Monitor check worker started');
return worker;
}
/**
* Gracefully shutdown the scheduler
*/
export async function shutdownScheduler(): Promise<void> {
console.log('[Scheduler] Shutting down...');
try {
await monitorQueue.close();
await queueEvents.close();
await redisConnection.quit();
console.log('[Scheduler] Shutdown complete');
} catch (error) {
console.error('[Scheduler] Error during shutdown:', error);
}
}
/**
* Get scheduler stats for monitoring
*/
export async function getSchedulerStats() {
try {
const [waiting, active, completed, failed, delayed, repeatableJobs] = await Promise.all([
monitorQueue.getWaitingCount(),
monitorQueue.getActiveCount(),
monitorQueue.getCompletedCount(),
monitorQueue.getFailedCount(),
monitorQueue.getDelayedCount(),
monitorQueue.getRepeatableJobs(),
]);
return {
waiting,
active,
completed,
failed,
delayed,
scheduled: repeatableJobs.length,
};
} catch (error) {
console.error('[Scheduler] Error getting stats:', error);
return null;
}
}

View File

@@ -0,0 +1,289 @@
import { Change } from 'diff';
import * as cheerio from 'cheerio';
import OpenAI from 'openai';
/**
* Generate a simple human-readable summary of changes
* Uses HTML parsing to count added/removed elements
*
* Example output: "3 text blocks changed, 2 new links added, 1 image removed"
*/
export function generateSimpleSummary(
diff: Change[],
htmlOld: string,
htmlNew: string
): string {
const parts: string[] = [];
// Extract text previews from diff
const textPreviews = extractTextPreviews(diff);
// Count changed text blocks from diff
const changedTextBlocks = countChangedTextNodes(diff);
if (changedTextBlocks > 0) {
parts.push(`${changedTextBlocks} text block${changedTextBlocks > 1 ? 's' : ''} changed`);
}
// Parse HTML to count structural changes
const addedLinks = countAddedElements(htmlOld, htmlNew, 'a');
const removedLinks = countRemovedElements(htmlOld, htmlNew, 'a');
const addedImages = countAddedElements(htmlOld, htmlNew, 'img');
const removedImages = countRemovedElements(htmlOld, htmlNew, 'img');
const addedTables = countAddedElements(htmlOld, htmlNew, 'table');
const removedTables = countRemovedElements(htmlOld, htmlNew, 'table');
const addedLists = countAddedElements(htmlOld, htmlNew, 'ul') + countAddedElements(htmlOld, htmlNew, 'ol');
const removedLists = countRemovedElements(htmlOld, htmlNew, 'ul') + countRemovedElements(htmlOld, htmlNew, 'ol');
// Add links
if (addedLinks > 0) {
parts.push(`${addedLinks} new link${addedLinks > 1 ? 's' : ''} added`);
}
if (removedLinks > 0) {
parts.push(`${removedLinks} link${removedLinks > 1 ? 's' : ''} removed`);
}
// Add images
if (addedImages > 0) {
parts.push(`${addedImages} new image${addedImages > 1 ? 's' : ''}`);
}
if (removedImages > 0) {
parts.push(`${removedImages} image${removedImages > 1 ? 's' : ''} removed`);
}
// Add tables
if (addedTables > 0) {
parts.push(`${addedTables} new table${addedTables > 1 ? 's' : ''}`);
}
if (removedTables > 0) {
parts.push(`${removedTables} table${removedTables > 1 ? 's' : ''} removed`);
}
// Add lists
if (addedLists > 0) {
parts.push(`${addedLists} new list${addedLists > 1 ? 's' : ''}`);
}
if (removedLists > 0) {
parts.push(`${removedLists} list${removedLists > 1 ? 's' : ''} removed`);
}
// If no specific changes found, return generic message
if (parts.length === 0 && textPreviews.length === 0) {
return 'Content updated';
}
// Build summary with text previews
let summary = parts.join(', ');
// Add text preview if available
if (textPreviews.length > 0) {
const previewText = textPreviews.slice(0, 2).join(' → ');
if (summary) {
summary += `. Changed: "${previewText}"`;
} else {
summary = `Text changed: "${previewText}"`;
}
}
return summary.charAt(0).toUpperCase() + summary.slice(1);
}
/**
* Extract short text previews from diff (focus on visible text, ignore code)
*/
function extractTextPreviews(diff: Change[]): string[] {
const previews: string[] = [];
const maxPreviewLength = 50;
for (const part of diff) {
if (part.added || part.removed) {
// Skip if it looks like CSS, JavaScript, or technical code
if (looksLikeCode(part.value)) {
continue;
}
// Extract visible text (strip HTML tags)
const text = extractVisibleText(part.value);
if (text.length > 5) { // Only include meaningful text
const truncated = text.length > maxPreviewLength
? text.substring(0, maxPreviewLength) + '...'
: text;
const prefix = part.added ? '+' : '-';
previews.push(`${prefix}${truncated}`);
}
}
}
return previews.slice(0, 4); // Limit to 4 previews
}
/**
* Check if text looks like code (CSS, JS, etc.)
*/
function looksLikeCode(text: string): boolean {
const codePatterns = [
/^\s*[\.\#@]\w+\s*\{/m, // CSS selectors (.class {, #id {, @media {)
/{\s*[a-z-]+\s*:\s*[^}]+}/i, // CSS properties ({ color: red; })
/function\s*\(/, // JavaScript function
/\bconst\s+\w+\s*=/, // JS const declaration
/\bvar\s+\w+\s*=/, // JS var declaration
/\blet\s+\w+\s*=/, // JS let declaration
/^\s*<script/im, // Script tags
/^\s*<style/im, // Style tags
/[a-z-]+\s*:\s*[#\d]+[a-z]+\s*;/i, // CSS property: value;
];
return codePatterns.some(pattern => pattern.test(text));
}
/**
* Extract visible text from HTML, focusing on meaningful content
*/
function extractVisibleText(html: string): string {
return html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove scripts
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove styles
.replace(/<[^>]*>/g, ' ') // Remove HTML tags
.replace(/&nbsp;/g, ' ') // Replace &nbsp;
.replace(/&[a-z]+;/gi, ' ') // Remove other HTML entities
.replace(/\{[^}]*\}/g, '') // Remove CSS blocks { }
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Count changed text nodes from diff
*/
function countChangedTextNodes(diff: Change[]): number {
let count = 0;
diff.forEach((part) => {
if (part.added || part.removed) {
// Count non-empty lines as text blocks
const lines = part.value.split('\n').filter(line => line.trim().length > 0);
count += lines.length;
}
});
// Divide by 2 because additions and removals are counted separately
// but represent the same change
return Math.ceil(count / 2);
}
/**
* Count elements added between old and new HTML
*/
function countAddedElements(htmlOld: string, htmlNew: string, tag: string): number {
try {
const $old = cheerio.load(htmlOld);
const $new = cheerio.load(htmlNew);
const oldCount = $old(tag).length;
const newCount = $new(tag).length;
return Math.max(0, newCount - oldCount);
} catch (error) {
console.error(`[Summarizer] Error counting added ${tag}:`, error);
return 0;
}
}
/**
* Count elements removed between old and new HTML
*/
function countRemovedElements(htmlOld: string, htmlNew: string, tag: string): number {
try {
const $old = cheerio.load(htmlOld);
const $new = cheerio.load(htmlNew);
const oldCount = $old(tag).length;
const newCount = $new(tag).length;
return Math.max(0, oldCount - newCount);
} catch (error) {
console.error(`[Summarizer] Error counting removed ${tag}:`, error);
return 0;
}
}
/**
* Generate AI-powered summary for large changes (≥10%)
* Uses OpenAI API (GPT-4o-mini for cost-efficiency)
*/
export async function generateAISummary(
diff: Change[],
changePercentage: number
): Promise<string> {
try {
// Check if API key is configured
if (!process.env.OPENAI_API_KEY) {
console.warn('[Summarizer] OPENAI_API_KEY not configured, falling back to simple summary');
throw new Error('OPENAI_API_KEY not configured');
}
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// Format diff for AI (reduce token count)
const formattedDiff = formatDiffForAI(diff);
const prompt = `Analyze this website change and create a concise summary for non-programmers.
Focus on IMPORTANT changes only. Medium detail level.
Change percentage: ${changePercentage.toFixed(2)}%
Diff:
${formattedDiff}
Format: "Section name: What changed. Details if important."
Example: "Pricing section updated: 3 prices increased. 2 new product links in footer."
Keep it under 100 words. Be specific about what changed, not how.`;
const completion = await client.chat.completions.create({
model: 'gpt-4o-mini', // Fastest, cheapest
max_tokens: 200,
messages: [{ role: 'user', content: prompt }],
temperature: 0.3, // Low temperature for consistent, factual summaries
});
// Extract text from response
const summary = completion.choices[0]?.message?.content || 'Content updated';
console.log('[Summarizer] AI summary generated:', summary);
return summary.trim();
} catch (error) {
console.error('[Summarizer] AI summary failed:', error);
throw error; // Re-throw to allow fallback to simple summary
}
}
/**
* Format diff for AI to reduce token count
* Extracts only added/removed content, limits to ~1000 characters
*/
function formatDiffForAI(diff: Change[]): string {
const lines: string[] = [];
let charCount = 0;
const maxChars = 1000;
for (const part of diff) {
if (charCount >= maxChars) break;
if (part.added) {
const prefix = '+ ';
const content = part.value.trim().substring(0, 200); // Limit each chunk
lines.push(prefix + content);
charCount += prefix.length + content.length;
} else if (part.removed) {
const prefix = '- ';
const content = part.value.trim().substring(0, 200);
lines.push(prefix + content);
charCount += prefix.length + content.length;
}
// Skip unchanged parts to save tokens
}
return lines.join('\n');
}

View File

@@ -1,101 +1,110 @@
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[];
}
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;
emailVerified?: boolean;
emailVerifiedAt?: Date;
emailEnabled?: boolean;
webhookUrl?: string;
webhookEnabled?: boolean;
slackWebhookUrl?: string;
slackEnabled?: boolean;
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;
importanceScore?: number;
summary?: string;
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[];
}

View File

@@ -1,64 +1,96 @@
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,
};
}
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 as jwt.SignOptions['expiresIn'] });
}
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,
};
}
export function generatePasswordResetToken(email: string): string {
return jwt.sign({ email, type: 'password-reset' }, JWT_SECRET, { expiresIn: '1h' });
}
export function verifyPasswordResetToken(token: string): { email: string } {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { email: string; type: string };
if (decoded.type !== 'password-reset') {
throw new Error('Invalid token type');
}
return { email: decoded.email };
} catch (error) {
throw new Error('Invalid or expired reset token');
}
}
export function generateEmailVerificationToken(email: string): string {
return jwt.sign({ email, type: 'email-verification' }, JWT_SECRET, { expiresIn: '24h' });
}
export function verifyEmailVerificationToken(token: string): { email: string } {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { email: string; type: string };
if (decoded.type !== 'email-verification') {
throw new Error('Invalid token type');
}
return { email: decoded.email };
} catch (error) {
throw new Error('Invalid or expired verification token');
}
}