import express from 'express'; import cors from 'cors'; import rateLimit from 'express-rate-limit'; import pg from 'pg'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import dotenv from 'dotenv'; import { createCheckoutSession, getSubscriptionStatus, createPortalSession, handleWebhookEvent, verifyWebhookSignature } from './stripe-billing.js'; import { attachSubscription, requireActiveSubscription } from './subscription-middleware.js'; dotenv.config(); const app = express(); const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || 'postgresql://tenderpilot:tenderpilot123@localhost:5432/tenderpilot' }); // Middleware app.use(cors()); // Raw body parser for webhooks (must be before express.json()) app.use('/api/billing/webhook', express.raw({ type: 'application/json' })); // JSON parser for all other routes app.use(express.json()); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }); app.use('/api/', limiter); // Auth token verification middleware const verifyToken = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token provided' }); try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch (e) { res.status(401).json({ error: 'Invalid token' }); } }; // Attach subscription info to request (after token verification) app.use('/api/', attachSubscription(pool)); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); // POST /api/auth/register app.post('/api/auth/register', async (req, res) => { try { const { email, password, company_name, tier } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password required' }); } const hashedPassword = await bcrypt.hash(password, 10); const result = await pool.query( 'INSERT INTO users (email, password_hash, company_name, tier) VALUES ($1, $2, $3, $4) RETURNING id, email, company_name, tier', [email, hashedPassword, company_name || '', tier || 'free'] ); const user = result.rows[0]; const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET); res.status(201).json({ user, token }); } catch (error) { console.error(error); if (error.code === '23505') { return res.status(400).json({ error: 'Email already exists' }); } res.status(500).json({ error: 'Registration failed' }); } }); // POST /api/auth/login app.post('/api/auth/login', async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password required' }); } const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]); if (result.rows.length === 0) { return res.status(401).json({ error: 'Invalid credentials' }); } const user = result.rows[0]; const passwordMatch = await bcrypt.compare(password, user.password_hash); if (!passwordMatch) { return res.status(401).json({ error: 'Invalid credentials' }); } const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET); res.json({ user: { id: user.id, email: user.email, company_name: user.company_name, tier: user.tier }, token }); } catch (error) { console.error(error); res.status(500).json({ error: 'Login failed' }); } }); // GET /api/tenders - Enhanced with filters app.get('/api/tenders', verifyToken, async (req, res) => { try { const { search, sort, limit, offset, sources, min_value, max_value, deadline_days, sectors } = req.query; let query = 'SELECT * FROM tenders WHERE status = $1 AND (deadline IS NULL OR deadline > NOW())'; const params = ['open']; let paramIndex = 2; // Search filter if (search) { query += ` AND (title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; params.push(`%${search}%`); paramIndex++; } // Source filter if (sources) { const sourceList = sources.split(',').map(s => s.trim()); const placeholders = sourceList.map(() => `$${paramIndex++}`).join(','); query += ` AND source IN (${placeholders})`; params.push(...sourceList); } // Value range filter if (min_value) { query += ` AND value_high >= $${paramIndex}`; params.push(parseFloat(min_value)); paramIndex++; } if (max_value) { query += ` AND value_high <= $${paramIndex}`; params.push(parseFloat(max_value)); paramIndex++; } // Deadline filter if (deadline_days) { const daysNum = parseInt(deadline_days); query += ` AND deadline <= CURRENT_DATE + INTERVAL '${daysNum} days'`; } // Sector filter // Sector filter disabled until column exists // if (sectors) { // const sectorList = sectors.split(',').map(s => s.trim()); // const placeholders = sectorList.map(() => `$${paramIndex++}`).join(','); // query += ` AND sector IN (${placeholders})`; // params.push(...sectorList); } // Count total before pagination const countQuery = query.replace('SELECT *', 'SELECT COUNT(*) as count'); const countResult = await pool.query(countQuery, params); const totalCount = parseInt(countResult.rows[0].count); // Sorting query += ` ORDER BY ${sort === 'value' ? 'value_high DESC' : 'deadline ASC'}`; // Pagination query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; const pageLimit = Math.min(parseInt(limit) || 20, 100); const pageOffset = parseInt(offset) || 0; params.push(pageLimit, pageOffset); const result = await pool.query(query, params); res.json({ tenders: result.rows, total: totalCount }); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to fetch tenders' }); } }); // GET /api/tenders/stats - Dashboard statistics app.get('/api/tenders/stats', verifyToken, async (req, res) => { try { // Total open tenders const totalResult = await pool.query( 'SELECT COUNT(*) as count FROM tenders WHERE status = $1', ['open'] ); const total = parseInt(totalResult.rows[0].count); // New this week const newResult = await pool.query( 'SELECT COUNT(*) as count FROM tenders WHERE status = $1 AND created_at >= CURRENT_DATE - INTERVAL \'7 days\'', ['open'] ); const newThisWeek = parseInt(newResult.rows[0].count); // Closing soon (next 7 days) const closingResult = await pool.query( 'SELECT COUNT(*) as count FROM tenders WHERE status = $1 AND deadline <= CURRENT_DATE + INTERVAL \'7 days\' AND deadline >= CURRENT_DATE', ['open'] ); const closingSoon = parseInt(closingResult.rows[0].count); // By source const sourceResult = await pool.query( 'SELECT source, COUNT(*) as count FROM tenders WHERE status = $1 GROUP BY source', ['open'] ); const bySource = sourceResult.rows.reduce((acc, row) => { acc[row.source] = parseInt(row.count); return acc; }, {}); res.json({ total, new_this_week: newThisWeek, closing_soon: closingSoon, matched_to_profile: 0, by_source: bySource }); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to fetch statistics' }); } }); // GET /api/tenders/:id app.get('/api/tenders/:id', verifyToken, async (req, res) => { try { const result = await pool.query('SELECT * FROM tenders WHERE id = $1', [req.params.id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Tender not found' }); } res.json(result.rows[0]); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to fetch tender' }); } }); // POST /api/profile app.post('/api/profile', verifyToken, async (req, res) => { try { const { sectors, keywords, min_value, max_value, locations, authority_types } = req.body; const result = await pool.query( `INSERT INTO profiles (user_id, sectors, keywords, min_value, max_value, locations, authority_types) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id) DO UPDATE SET sectors = $2, keywords = $3, min_value = $4, max_value = $5, locations = $6, authority_types = $7, updated_at = CURRENT_TIMESTAMP RETURNING *`, [req.user.id, sectors || [], keywords || [], min_value || null, max_value || null, locations || [], authority_types || []] ); res.json(result.rows[0]); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to save profile' }); } }); // GET /api/matches app.get('/api/matches', verifyToken, async (req, res) => { try { const result = await pool.query( `SELECT t.* FROM tenders t INNER JOIN matches m ON t.id = m.tender_id WHERE m.user_id = $1 ORDER BY t.deadline ASC`, [req.user.id] ); res.json({ matches: result.rows }); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to fetch matches' }); } }); // GET /api/alerts/preferences app.get('/api/alerts/preferences', verifyToken, async (req, res) => { try { const result = await pool.query( 'SELECT id, user_id, keywords, sectors, min_value, max_value, locations, authority_types, created_at, updated_at FROM profiles WHERE user_id = $1', [req.user.id] ); if (result.rows.length === 0) { return res.json({ preferences: null }); } res.json({ preferences: result.rows[0] }); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to fetch alert preferences' }); } }); // POST /api/alerts/preferences app.post('/api/alerts/preferences', verifyToken, async (req, res) => { try { const { keywords, sectors, min_value, max_value, locations, authority_types } = req.body; // Validate value ranges if (min_value && max_value && min_value > max_value) { return res.status(400).json({ error: 'min_value cannot be greater than max_value' }); } const result = await pool.query( `INSERT INTO profiles (user_id, keywords, sectors, min_value, max_value, locations, authority_types) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id) DO UPDATE SET keywords = $2, sectors = $3, min_value = $4, max_value = $5, locations = $6, authority_types = $7, updated_at = CURRENT_TIMESTAMP RETURNING id, user_id, keywords, sectors, min_value, max_value, locations, authority_types, created_at, updated_at`, [req.user.id, keywords || [], sectors || [], min_value || null, max_value || null, locations || [], authority_types || []] ); res.json({ preferences: result.rows[0], message: 'Alert preferences updated successfully' }); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to save alert preferences' }); } }); // ===== BILLING ROUTES ===== // POST /api/billing/checkout - Create a checkout session app.post('/api/billing/checkout', verifyToken, async (req, res) => { try { const { plan, successUrl, cancelUrl } = req.body; if (!plan || !successUrl || !cancelUrl) { return res.status(400).json({ error: 'plan, successUrl, and cancelUrl are required' }); } const user = await pool.query('SELECT email FROM users WHERE id = $1', [req.user.id]); if (user.rows.length === 0) { return res.status(404).json({ error: 'User not found' }); } const session = await createCheckoutSession( pool, req.user.id, user.rows[0].email, plan, successUrl, cancelUrl ); res.json({ sessionId: session.id, url: session.url }); } catch (error) { console.error('Checkout error:', error); res.status(500).json({ error: error.message }); } }); // POST /api/billing/webhook - Stripe webhook handler app.post('/api/billing/webhook', async (req, res) => { const signature = req.headers['stripe-signature']; try { const event = verifyWebhookSignature( req.body, signature, process.env.STRIPE_WEBHOOK_SECRET ); await handleWebhookEvent(pool, event); res.json({ received: true }); } catch (error) { console.error('Webhook error:', error.message); res.status(400).json({ error: 'Webhook signature verification failed' }); } }); // GET /api/billing/subscription - Get current subscription status app.get('/api/billing/subscription', verifyToken, async (req, res) => { try { const subscription = await getSubscriptionStatus(pool, req.user.id); if (!subscription) { return res.json({ subscription: null, message: 'No active subscription. User is on free tier.' }); } res.json({ subscription }); } catch (error) { console.error('Subscription status error:', error); res.status(500).json({ error: error.message }); } }); // POST /api/billing/portal - Create Stripe Customer Portal session app.post('/api/billing/portal', verifyToken, async (req, res) => { try { const { returnUrl } = req.body; if (!returnUrl) { return res.status(400).json({ error: 'returnUrl is required' }); } const session = await createPortalSession(pool, req.user.id, returnUrl); res.json({ url: session.url }); } catch (error) { console.error('Portal session error:', error); res.status(500).json({ error: error.message }); } }); // Error handling app.use((err, req, res, next) => { console.error(err); res.status(500).json({ error: 'Internal server error' }); }); const PORT = process.env.PORT || 3456; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });