444 lines
14 KiB
Plaintext
444 lines
14 KiB
Plaintext
|
|
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}`);
|
||
|
|
});
|