gitea
This commit is contained in:
70
backend/src/config.ts
Normal file
70
backend/src/config.ts
Normal 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',
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
12
backend/src/db/migrations/002_add_notification_settings.sql
Normal file
12
backend/src/db/migrations/002_add_notification_settings.sql
Normal 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;
|
||||
8
backend/src/db/migrations/003_add_email_verification.sql
Normal file
8
backend/src/db/migrations/003_add_email_verification.sql
Normal 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);
|
||||
23
backend/src/db/migrations/004_add_webhook_logs.sql
Normal file
23
backend/src/db/migrations/004_add_webhook_logs.sql
Normal 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;
|
||||
11
backend/src/db/migrations/005_add_snapshot_summary.sql
Normal file
11
backend/src/db/migrations/005_add_snapshot_summary.sql
Normal 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';
|
||||
1
backend/src/db/migrations/tmpclaude-0f94-cwd
Normal file
1
backend/src/db/migrations/tmpclaude-0f94-cwd
Normal file
@@ -0,0 +1 @@
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend/src/db/migrations
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
29
backend/src/middleware/rateLimiter.ts
Normal file
29
backend/src/middleware/rateLimiter.ts
Normal 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,
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
185
backend/src/routes/settings.ts
Normal file
185
backend/src/routes/settings.ts
Normal 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;
|
||||
65
backend/src/routes/test.ts
Normal file
65
backend/src/routes/test.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
282
backend/src/services/digest.ts
Normal file
282
backend/src/services/digest.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
190
backend/src/services/importance.ts
Normal file
190
backend/src/services/importance.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
197
backend/src/services/scheduler.ts
Normal file
197
backend/src/services/scheduler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
289
backend/src/services/summarizer.ts
Normal file
289
backend/src/services/summarizer.ts
Normal 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(/ /g, ' ') // Replace
|
||||
.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');
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user