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

View File

@@ -1,284 +1,438 @@
import { Pool, QueryResult } from 'pg';
import { User, Monitor, Snapshot, Alert } from '../types';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('Unexpected database error:', err);
process.exit(-1);
});
export const query = async <T = any>(
text: string,
params?: any[]
): Promise<QueryResult<T>> => {
const start = Date.now();
const result = await pool.query<T>(text, params);
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`Slow query (${duration}ms):`, text);
}
return result;
};
export const getClient = () => pool.connect();
// User queries
export const db = {
users: {
async create(email: string, passwordHash: string): Promise<User> {
const result = await query<User>(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
[email, passwordHash]
);
return result.rows[0];
},
async findById(id: string): Promise<User | null> {
const result = await query<User>(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0] || null;
},
async findByEmail(email: string): Promise<User | null> {
const result = await query<User>(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] || null;
},
async update(id: string, updates: Partial<User>): Promise<User | null> {
const fields = Object.keys(updates);
const values = Object.values(updates);
const setClause = fields.map((field, i) => `${field} = $${i + 2}`).join(', ');
const result = await query<User>(
`UPDATE users SET ${setClause} WHERE id = $1 RETURNING *`,
[id, ...values]
);
return result.rows[0] || null;
},
async updateLastLogin(id: string): Promise<void> {
await query('UPDATE users SET last_login_at = NOW() WHERE id = $1', [id]);
},
},
monitors: {
async create(data: Omit<Monitor, 'id' | 'createdAt' | 'updatedAt' | 'consecutiveErrors'>): Promise<Monitor> {
const result = await query<Monitor>(
`INSERT INTO monitors (
user_id, url, name, frequency, status, element_selector,
ignore_rules, keyword_rules
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[
data.userId,
data.url,
data.name,
data.frequency,
data.status,
data.elementSelector || null,
data.ignoreRules ? JSON.stringify(data.ignoreRules) : null,
data.keywordRules ? JSON.stringify(data.keywordRules) : null,
]
);
return result.rows[0];
},
async findById(id: string): Promise<Monitor | null> {
const result = await query<Monitor>(
'SELECT * FROM monitors WHERE id = $1',
[id]
);
return result.rows[0] || null;
},
async findByUserId(userId: string): Promise<Monitor[]> {
const result = await query<Monitor>(
'SELECT * FROM monitors WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return result.rows;
},
async countByUserId(userId: string): Promise<number> {
const result = await query<{ count: string }>(
'SELECT COUNT(*) as count FROM monitors WHERE user_id = $1',
[userId]
);
return parseInt(result.rows[0].count);
},
async findActiveMonitors(): Promise<Monitor[]> {
const result = await query<Monitor>(
'SELECT * FROM monitors WHERE status = $1',
['active']
);
return result.rows;
},
async update(id: string, updates: Partial<Monitor>): Promise<Monitor | null> {
const fields: string[] = [];
const values: any[] = [];
let paramCount = 2;
Object.entries(updates).forEach(([key, value]) => {
if (value !== undefined) {
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
if (key === 'ignoreRules' || key === 'keywordRules') {
fields.push(`${snakeKey} = $${paramCount}`);
values.push(JSON.stringify(value));
} else {
fields.push(`${snakeKey} = $${paramCount}`);
values.push(value);
}
paramCount++;
}
});
if (fields.length === 0) return null;
const result = await query<Monitor>(
`UPDATE monitors SET ${fields.join(', ')} WHERE id = $1 RETURNING *`,
[id, ...values]
);
return result.rows[0] || null;
},
async delete(id: string): Promise<boolean> {
const result = await query('DELETE FROM monitors WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
},
async updateLastChecked(id: string, changed: boolean): Promise<void> {
if (changed) {
await query(
'UPDATE monitors SET last_checked_at = NOW(), last_changed_at = NOW(), consecutive_errors = 0 WHERE id = $1',
[id]
);
} else {
await query(
'UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = 0 WHERE id = $1',
[id]
);
}
},
async incrementErrors(id: string): Promise<void> {
await query(
'UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = consecutive_errors + 1 WHERE id = $1',
[id]
);
},
},
snapshots: {
async create(data: Omit<Snapshot, 'id' | 'createdAt'>): Promise<Snapshot> {
const result = await query<Snapshot>(
`INSERT INTO snapshots (
monitor_id, html_content, text_content, content_hash, screenshot_url,
http_status, response_time, changed, change_percentage, error_message
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[
data.monitorId,
data.htmlContent,
data.textContent,
data.contentHash,
data.screenshotUrl || null,
data.httpStatus,
data.responseTime,
data.changed,
data.changePercentage || null,
data.errorMessage || null,
]
);
return result.rows[0];
},
async findByMonitorId(monitorId: string, limit = 50): Promise<Snapshot[]> {
const result = await query<Snapshot>(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT $2',
[monitorId, limit]
);
return result.rows;
},
async findLatestByMonitorId(monitorId: string): Promise<Snapshot | null> {
const result = await query<Snapshot>(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1',
[monitorId]
);
return result.rows[0] || null;
},
async findById(id: string): Promise<Snapshot | null> {
const result = await query<Snapshot>(
'SELECT * FROM snapshots WHERE id = $1',
[id]
);
return result.rows[0] || null;
},
async deleteOldSnapshots(monitorId: string, keepCount: number): Promise<void> {
await query(
`DELETE FROM snapshots
WHERE monitor_id = $1
AND id NOT IN (
SELECT id FROM snapshots
WHERE monitor_id = $1
ORDER BY created_at DESC
LIMIT $2
)`,
[monitorId, keepCount]
);
},
},
alerts: {
async create(data: Omit<Alert, 'id' | 'createdAt' | 'deliveredAt' | 'readAt'>): Promise<Alert> {
const result = await query<Alert>(
`INSERT INTO alerts (
monitor_id, snapshot_id, user_id, type, title, summary, channels
) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[
data.monitorId,
data.snapshotId,
data.userId,
data.type,
data.title,
data.summary || null,
JSON.stringify(data.channels),
]
);
return result.rows[0];
},
async findByUserId(userId: string, limit = 50): Promise<Alert[]> {
const result = await query<Alert>(
'SELECT * FROM alerts WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows;
},
async markAsDelivered(id: string): Promise<void> {
await query('UPDATE alerts SET delivered_at = NOW() WHERE id = $1', [id]);
},
async markAsRead(id: string): Promise<void> {
await query('UPDATE alerts SET read_at = NOW() WHERE id = $1', [id]);
},
},
};
export default db;
import { Pool, QueryResult, QueryResultRow } from 'pg';
import { User, Monitor, Snapshot, Alert } from '../types';
// Convert snake_case database keys to camelCase TypeScript properties
function toCamelCase<T>(obj: any): T {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) return obj.map(item => toCamelCase<any>(item)) as T;
if (typeof obj !== 'object') return obj;
const result: any = {};
for (const key in obj) {
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
let value = obj[key];
// Parse JSON fields that are stored as strings in the database
if ((key === 'ignore_rules' || key === 'keyword_rules') && typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
// Keep as-is if parsing fails
}
}
result[camelKey] = value;
}
return result as T;
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('Unexpected database error:', err);
process.exit(-1);
});
export const query = async <T extends QueryResultRow = any>(
text: string,
params?: any[]
): Promise<QueryResult<T>> => {
const start = Date.now();
const result = await pool.query<T>(text, params);
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`Slow query (${duration}ms):`, text);
}
return result;
};
export const getClient = () => pool.connect();
// User queries
export const db = {
users: {
async create(email: string, passwordHash: string): Promise<User> {
const result = await query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
[email, passwordHash]
);
return toCamelCase<User>(result.rows[0]);
},
async findById(id: string): Promise<User | null> {
const result = await query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0] ? toCamelCase<User>(result.rows[0]) : null;
},
async findByEmail(email: string): Promise<User | null> {
const result = await query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] ? toCamelCase<User>(result.rows[0]) : null;
},
async update(id: string, updates: Partial<User>): Promise<User | null> {
const fields = Object.keys(updates);
const values = Object.values(updates);
const setClause = fields.map((field, i) => `${field} = $${i + 2}`).join(', ');
const result = await query<User>(
`UPDATE users SET ${setClause} WHERE id = $1 RETURNING *`,
[id, ...values]
);
return result.rows[0] || null;
},
async updateLastLogin(id: string): Promise<void> {
await query('UPDATE users SET last_login_at = NOW() WHERE id = $1', [id]);
},
async updatePassword(id: string, passwordHash: string): Promise<void> {
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, id]);
},
async updateNotificationSettings(
id: string,
settings: {
emailEnabled?: boolean;
webhookUrl?: string | null;
webhookEnabled?: boolean;
slackWebhookUrl?: string | null;
slackEnabled?: boolean;
}
): Promise<void> {
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (settings.emailEnabled !== undefined) {
updates.push(`email_enabled = $${paramIndex++}`);
values.push(settings.emailEnabled);
}
if (settings.webhookUrl !== undefined) {
updates.push(`webhook_url = $${paramIndex++}`);
values.push(settings.webhookUrl);
}
if (settings.webhookEnabled !== undefined) {
updates.push(`webhook_enabled = $${paramIndex++}`);
values.push(settings.webhookEnabled);
}
if (settings.slackWebhookUrl !== undefined) {
updates.push(`slack_webhook_url = $${paramIndex++}`);
values.push(settings.slackWebhookUrl);
}
if (settings.slackEnabled !== undefined) {
updates.push(`slack_enabled = $${paramIndex++}`);
values.push(settings.slackEnabled);
}
if (updates.length > 0) {
values.push(id);
await query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
values
);
}
},
async delete(id: string): Promise<boolean> {
const result = await query('DELETE FROM users WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
},
async verifyEmail(email: string): Promise<void> {
await query(
'UPDATE users SET email_verified = true, email_verified_at = NOW() WHERE email = $1',
[email]
);
},
},
monitors: {
async create(data: Omit<Monitor, 'id' | 'createdAt' | 'updatedAt' | 'consecutiveErrors'>): Promise<Monitor> {
const result = await query(
`INSERT INTO monitors (
user_id, url, name, frequency, status, element_selector,
ignore_rules, keyword_rules
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[
data.userId,
data.url,
data.name,
data.frequency,
data.status,
data.elementSelector || null,
data.ignoreRules ? JSON.stringify(data.ignoreRules) : null,
data.keywordRules ? JSON.stringify(data.keywordRules) : null,
]
);
return toCamelCase<Monitor>(result.rows[0]);
},
async findById(id: string): Promise<Monitor | null> {
const result = await query(
'SELECT * FROM monitors WHERE id = $1',
[id]
);
return result.rows[0] ? toCamelCase<Monitor>(result.rows[0]) : null;
},
async findByUserId(userId: string): Promise<Monitor[]> {
const result = await query(
'SELECT * FROM monitors WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return result.rows.map(row => toCamelCase<Monitor>(row));
},
async countByUserId(userId: string): Promise<number> {
const result = await query<{ count: string }>(
'SELECT COUNT(*) as count FROM monitors WHERE user_id = $1',
[userId]
);
return parseInt(result.rows[0].count);
},
async findActiveMonitors(): Promise<Monitor[]> {
const result = await query(
'SELECT * FROM monitors WHERE status = $1',
['active']
);
return result.rows.map(row => toCamelCase<Monitor>(row));
},
async update(id: string, updates: Partial<Monitor>): Promise<Monitor | null> {
const fields: string[] = [];
const values: any[] = [];
let paramCount = 2;
Object.entries(updates).forEach(([key, value]) => {
if (value !== undefined) {
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
if (key === 'ignoreRules' || key === 'keywordRules') {
fields.push(`${snakeKey} = $${paramCount}`);
values.push(JSON.stringify(value));
} else {
fields.push(`${snakeKey} = $${paramCount}`);
values.push(value);
}
paramCount++;
}
});
if (fields.length === 0) return null;
const result = await query(
`UPDATE monitors SET ${fields.join(', ')} WHERE id = $1 RETURNING *`,
[id, ...values]
);
return result.rows[0] ? toCamelCase<Monitor>(result.rows[0]) : null;
},
async delete(id: string): Promise<boolean> {
const result = await query('DELETE FROM monitors WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
},
async updateLastChecked(id: string, changed: boolean): Promise<void> {
if (changed) {
await query(
"UPDATE monitors SET last_checked_at = NOW(), last_changed_at = NOW(), consecutive_errors = 0, status = 'active' WHERE id = $1",
[id]
);
} else {
await query(
"UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = 0, status = 'active' WHERE id = $1",
[id]
);
}
},
async incrementErrors(id: string): Promise<void> {
await query(
"UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = consecutive_errors + 1, status = CASE WHEN consecutive_errors >= 0 THEN 'error' ELSE status END WHERE id = $1",
[id]
);
},
},
snapshots: {
async create(data: Omit<Snapshot, 'id' | 'createdAt'>): Promise<Snapshot> {
const result = await query(
`INSERT INTO snapshots (
monitor_id, html_content, text_content, content_hash, screenshot_url,
http_status, response_time, changed, change_percentage, error_message,
importance_score, summary
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[
data.monitorId,
data.htmlContent,
data.textContent,
data.contentHash,
data.screenshotUrl || null,
data.httpStatus,
data.responseTime,
data.changed,
data.changePercentage || null,
data.errorMessage || null,
data.importanceScore ?? 0,
data.summary || null,
]
);
return toCamelCase<Snapshot>(result.rows[0]);
},
async findByMonitorId(monitorId: string, limit = 50): Promise<Snapshot[]> {
const result = await query(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT $2',
[monitorId, limit]
);
return result.rows.map(row => toCamelCase<Snapshot>(row));
},
async findLatestByMonitorId(monitorId: string): Promise<Snapshot | null> {
const result = await query(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1',
[monitorId]
);
return result.rows[0] ? toCamelCase<Snapshot>(result.rows[0]) : null;
},
async findById(id: string): Promise<Snapshot | null> {
const result = await query(
'SELECT * FROM snapshots WHERE id = $1',
[id]
);
return result.rows[0] ? toCamelCase<Snapshot>(result.rows[0]) : null;
},
async deleteOldSnapshots(monitorId: string, keepCount: number): Promise<void> {
await query(
`DELETE FROM snapshots
WHERE monitor_id = $1
AND id NOT IN (
SELECT id FROM snapshots
WHERE monitor_id = $1
ORDER BY created_at DESC
LIMIT $2
)`,
[monitorId, keepCount]
);
},
async deleteOldSnapshotsByAge(monitorId: string, retentionDays: number): Promise<void> {
await query(
`DELETE FROM snapshots
WHERE monitor_id = $1
AND created_at < NOW() - INTERVAL '1 day' * $2`,
[monitorId, retentionDays]
);
},
},
alerts: {
async create(data: Omit<Alert, 'id' | 'createdAt' | 'deliveredAt' | 'readAt'>): Promise<Alert> {
const result = await query(
`INSERT INTO alerts (
monitor_id, snapshot_id, user_id, type, title, summary, channels
) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[
data.monitorId,
data.snapshotId,
data.userId,
data.type,
data.title,
data.summary || null,
JSON.stringify(data.channels),
]
);
return toCamelCase<Alert>(result.rows[0]);
},
async findByUserId(userId: string, limit = 50): Promise<Alert[]> {
const result = await query(
'SELECT * FROM alerts WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows.map(row => toCamelCase<Alert>(row));
},
async markAsDelivered(id: string): Promise<void> {
await query('UPDATE alerts SET delivered_at = NOW() WHERE id = $1', [id]);
},
async markAsRead(id: string): Promise<void> {
await query('UPDATE alerts SET read_at = NOW() WHERE id = $1', [id]);
},
async updateChannels(id: string, channels: string[]): Promise<void> {
await query('UPDATE alerts SET channels = $1 WHERE id = $2', [JSON.stringify(channels), id]);
},
},
webhookLogs: {
async create(data: {
userId: string;
monitorId?: string;
alertId?: string;
webhookType: 'webhook' | 'slack';
url: string;
payload?: any;
statusCode?: number;
responseBody?: string;
success: boolean;
errorMessage?: string;
attempt?: number;
}): Promise<{ id: string }> {
const result = await query(
`INSERT INTO webhook_logs (
user_id, monitor_id, alert_id, webhook_type, url, payload,
status_code, response_body, success, error_message, attempt
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
[
data.userId,
data.monitorId || null,
data.alertId || null,
data.webhookType,
data.url,
data.payload ? JSON.stringify(data.payload) : null,
data.statusCode || null,
data.responseBody || null,
data.success,
data.errorMessage || null,
data.attempt || 1,
]
);
return { id: result.rows[0].id };
},
async findByUserId(userId: string, limit = 100): Promise<any[]> {
const result = await query(
'SELECT * FROM webhook_logs WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows.map(row => toCamelCase<any>(row));
},
async findFailedByUserId(userId: string, limit = 50): Promise<any[]> {
const result = await query(
'SELECT * FROM webhook_logs WHERE user_id = $1 AND success = false ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows.map(row => toCamelCase<any>(row));
},
},
};
export default db;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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