PriceGhost/backend/src/index.ts
clucraft 28d6523959 Add Clear button to notification bell dropdown
- Add notifications_cleared_at column to users table
- Clear button marks notifications as seen without deleting history
- Badge and dropdown only show notifications after last clear
- History page still shows all notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 03:20:03 -05:00

249 lines
9.4 KiB
TypeScript

import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth';
import productRoutes from './routes/products';
import priceRoutes from './routes/prices';
import settingsRoutes from './routes/settings';
import profileRoutes from './routes/profile';
import adminRoutes from './routes/admin';
import notificationRoutes from './routes/notifications';
import { startScheduler } from './services/scheduler';
import pool from './config/database';
// Run database migrations
async function runMigrations() {
const client = await pool.connect();
try {
// First, ensure base tables exist (for fresh installs without init.sql)
await client.query(`
-- Users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255),
is_admin BOOLEAN DEFAULT false,
telegram_bot_token VARCHAR(255),
telegram_chat_id VARCHAR(255),
discord_webhook_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- System settings table
CREATE TABLE IF NOT EXISTS system_settings (
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Default system settings
INSERT INTO system_settings (key, value) VALUES ('registration_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
-- Products table
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
name VARCHAR(255),
image_url TEXT,
refresh_interval INTEGER DEFAULT 3600,
last_checked TIMESTAMP,
next_check_at TIMESTAMP,
stock_status VARCHAR(20) DEFAULT 'unknown',
price_drop_threshold DECIMAL(10,2),
target_price DECIMAL(10,2),
notify_back_in_stock BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, url)
);
-- Price history table
CREATE TABLE IF NOT EXISTS price_history (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
price DECIMAL(10,2) NOT NULL,
currency VARCHAR(10) DEFAULT 'USD',
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for faster price history queries
CREATE INDEX IF NOT EXISTS idx_price_history_product_date
ON price_history(product_id, recorded_at);
`);
console.log('Base tables ensured');
// Add AI settings columns to users table if they don't exist
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ai_enabled') THEN
ALTER TABLE users ADD COLUMN ai_enabled BOOLEAN DEFAULT false;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ai_provider') THEN
ALTER TABLE users ADD COLUMN ai_provider VARCHAR(20);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'anthropic_api_key') THEN
ALTER TABLE users ADD COLUMN anthropic_api_key TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'openai_api_key') THEN
ALTER TABLE users ADD COLUMN openai_api_key TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'pushover_user_key') THEN
ALTER TABLE users ADD COLUMN pushover_user_key TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'pushover_app_token') THEN
ALTER TABLE users ADD COLUMN pushover_app_token TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'telegram_enabled') THEN
ALTER TABLE users ADD COLUMN telegram_enabled BOOLEAN DEFAULT true;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'discord_enabled') THEN
ALTER TABLE users ADD COLUMN discord_enabled BOOLEAN DEFAULT true;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'pushover_enabled') THEN
ALTER TABLE users ADD COLUMN pushover_enabled BOOLEAN DEFAULT true;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ollama_base_url') THEN
ALTER TABLE users ADD COLUMN ollama_base_url TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ollama_model') THEN
ALTER TABLE users ADD COLUMN ollama_model TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_topic') THEN
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_enabled') THEN
ALTER TABLE users ADD COLUMN ntfy_enabled BOOLEAN DEFAULT true;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ai_verification_enabled') THEN
ALTER TABLE users ADD COLUMN ai_verification_enabled BOOLEAN DEFAULT false;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'notifications_cleared_at') THEN
ALTER TABLE users ADD COLUMN notifications_cleared_at TIMESTAMP;
END IF;
END $$;
`);
// Create stock_status_history table if it doesn't exist
await client.query(`
CREATE TABLE IF NOT EXISTS stock_status_history (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_stock_history_product_date
ON stock_status_history(product_id, changed_at);
`);
// Add ai_status column to price_history table
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'price_history' AND column_name = 'ai_status') THEN
ALTER TABLE price_history ADD COLUMN ai_status VARCHAR(20);
END IF;
END $$;
`);
// Create notification_history table for tracking all triggered notifications
await client.query(`
CREATE TABLE IF NOT EXISTS notification_history (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
notification_type VARCHAR(50) NOT NULL,
triggered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
old_price DECIMAL(10,2),
new_price DECIMAL(10,2),
currency VARCHAR(10),
price_change_percent DECIMAL(5,2),
target_price DECIMAL(10,2),
old_stock_status VARCHAR(20),
new_stock_status VARCHAR(20),
channels_notified JSONB,
product_name VARCHAR(500),
product_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_notification_history_user_date
ON notification_history(user_id, triggered_at DESC);
CREATE INDEX IF NOT EXISTS idx_notification_history_product
ON notification_history(product_id);
`);
console.log('Database migrations completed');
} catch (error) {
console.error('Migration error:', error);
throw error; // Re-throw to prevent server from starting with broken DB
} finally {
client.release();
}
}
// Load environment variables
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Health check endpoint
app.get('/health', (_, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);
app.use('/api/products', priceRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/notifications', notificationRoutes);
// Error handling middleware
app.use(
(
err: Error,
_req: express.Request,
res: express.Response,
_next: express.NextFunction
) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
}
);
// Start server with proper initialization sequence
async function startServer() {
try {
// Run database migrations BEFORE accepting connections
await runMigrations();
app.listen(PORT, () => {
console.log(`PriceGhost API server running on port ${PORT}`);
// Start the background price checker
if (process.env.NODE_ENV !== 'test') {
startScheduler();
}
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
startServer();
export default app;