import { Router } from 'express'; import { authMiddleware, requireRole } from '../auth/middleware'; import { pool } from '../db/pool'; const router = Router(); router.use(authMiddleware); // Get all campaigns router.get('/', async (req, res) => { try { const result = await pool.query(` SELECT c.*, COUNT(cp.product_id) as product_count FROM campaigns c LEFT JOIN campaign_products cp ON c.id = cp.campaign_id GROUP BY c.id ORDER BY c.created_at DESC `); res.json({ campaigns: result.rows }); } catch (error) { console.error('Error fetching campaigns:', error); res.status(500).json({ error: 'Failed to fetch campaigns' }); } }); // Get single campaign with products router.get('/:id', async (req, res) => { try { const { id } = req.params; const campaignResult = await pool.query(` SELECT * FROM campaigns WHERE id = $1 `, [id]); if (campaignResult.rows.length === 0) { return res.status(404).json({ error: 'Campaign not found' }); } const productsResult = await pool.query(` SELECT p.id, p.dispensary_id, p.name_raw as name, p.brand_name_raw as brand, p.category_raw as category, p.subcategory_raw as subcategory, p.price_rec as price, p.thc_percent, p.cbd_percent, p.strain_type, p.primary_image_url as image_url, p.stock_status, p.is_in_stock as in_stock, cp.display_order FROM store_products p JOIN campaign_products cp ON p.id = cp.product_id WHERE cp.campaign_id = $1 ORDER BY cp.display_order `, [id]); res.json({ campaign: campaignResult.rows[0], products: productsResult.rows }); } catch (error) { console.error('Error fetching campaign:', error); res.status(500).json({ error: 'Failed to fetch campaign' }); } }); // Create campaign router.post('/', requireRole('superadmin', 'admin'), async (req, res) => { try { const { name, slug, description, display_style, active, start_date, end_date } = req.body; if (!name || !slug) { return res.status(400).json({ error: 'Name and slug required' }); } const result = await pool.query(` INSERT INTO campaigns (name, slug, description, display_style, active, start_date, end_date) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `, [name, slug, description, display_style || 'grid', active !== false, start_date, end_date]); res.status(201).json({ campaign: result.rows[0] }); } catch (error: any) { console.error('Error creating campaign:', error); if (error.code === '23505') { return res.status(409).json({ error: 'Campaign slug already exists' }); } res.status(500).json({ error: 'Failed to create campaign' }); } }); // Update campaign router.put('/:id', requireRole('superadmin', 'admin'), async (req, res) => { try { const { id } = req.params; const { name, slug, description, display_style, active, start_date, end_date } = req.body; const result = await pool.query(` UPDATE campaigns SET name = COALESCE($1, name), slug = COALESCE($2, slug), description = COALESCE($3, description), display_style = COALESCE($4, display_style), active = COALESCE($5, active), start_date = COALESCE($6, start_date), end_date = COALESCE($7, end_date), updated_at = CURRENT_TIMESTAMP WHERE id = $8 RETURNING * `, [name, slug, description, display_style, active, start_date, end_date, id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Campaign not found' }); } res.json({ campaign: result.rows[0] }); } catch (error: any) { console.error('Error updating campaign:', error); if (error.code === '23505') { return res.status(409).json({ error: 'Campaign slug already exists' }); } res.status(500).json({ error: 'Failed to update campaign' }); } }); // Delete campaign router.delete('/:id', requireRole('superadmin'), async (req, res) => { try { const { id } = req.params; const result = await pool.query(` DELETE FROM campaigns WHERE id = $1 RETURNING id `, [id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Campaign not found' }); } res.json({ message: 'Campaign deleted successfully' }); } catch (error) { console.error('Error deleting campaign:', error); res.status(500).json({ error: 'Failed to delete campaign' }); } }); // Add product to campaign router.post('/:id/products', requireRole('superadmin', 'admin'), async (req, res) => { try { const { id } = req.params; const { product_id, display_order } = req.body; if (!product_id) { return res.status(400).json({ error: 'Product ID required' }); } const result = await pool.query(` INSERT INTO campaign_products (campaign_id, product_id, display_order) VALUES ($1, $2, $3) ON CONFLICT (campaign_id, product_id) DO UPDATE SET display_order = $3 RETURNING * `, [id, product_id, display_order || 0]); res.status(201).json({ campaign_product: result.rows[0] }); } catch (error) { console.error('Error adding product to campaign:', error); res.status(500).json({ error: 'Failed to add product to campaign' }); } }); // Remove product from campaign router.delete('/:id/products/:product_id', requireRole('superadmin', 'admin'), async (req, res) => { try { const { id, product_id } = req.params; const result = await pool.query(` DELETE FROM campaign_products WHERE campaign_id = $1 AND product_id = $2 RETURNING * `, [id, product_id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Product not in campaign' }); } res.json({ message: 'Product removed from campaign' }); } catch (error) { console.error('Error removing product from campaign:', error); res.status(500).json({ error: 'Failed to remove product from campaign' }); } }); /** * GET /api/campaigns/:id/click-summary * Get product click event summary for a campaign * * Query params: * - from: Start date (ISO) * - to: End date (ISO) */ router.get('/:id/click-summary', async (req, res) => { try { const { id } = req.params; const { from, to } = req.query; // Check campaign exists const campaignResult = await pool.query( 'SELECT id, name FROM campaigns WHERE id = $1', [id] ); if (campaignResult.rows.length === 0) { return res.status(404).json({ error: 'Campaign not found' }); } // Build date filter conditions const conditions: string[] = ['campaign_id = $1']; const params: any[] = [id]; let paramIndex = 2; if (from) { conditions.push(`occurred_at >= $${paramIndex++}`); params.push(new Date(from as string)); } if (to) { conditions.push(`occurred_at <= $${paramIndex++}`); params.push(new Date(to as string)); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Get overall stats const statsResult = await pool.query(` SELECT COUNT(*) as total_clicks, COUNT(DISTINCT product_id) as unique_products, COUNT(DISTINCT store_id) as unique_stores, COUNT(DISTINCT brand_id) as unique_brands, COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) as unique_users FROM product_click_events ${whereClause} `, params); // Get clicks by action type const byActionResult = await pool.query(` SELECT action, COUNT(*) as count FROM product_click_events ${whereClause} GROUP BY action ORDER BY count DESC `, params); // Get clicks by source const bySourceResult = await pool.query(` SELECT source, COUNT(*) as count FROM product_click_events ${whereClause} GROUP BY source ORDER BY count DESC `, params); // Get top products (by click count) const topProductsResult = await pool.query(` SELECT product_id, COUNT(*) as click_count FROM product_click_events ${whereClause} GROUP BY product_id ORDER BY click_count DESC LIMIT 10 `, params); // Get daily click counts (last 30 days by default) const dailyParams = [...params]; let dailyWhereClause = whereClause; if (!from) { // Default to last 30 days conditions.push(`occurred_at >= NOW() - INTERVAL '30 days'`); dailyWhereClause = `WHERE ${conditions.join(' AND ')}`; } const dailyResult = await pool.query(` SELECT DATE(occurred_at) as date, COUNT(*) as click_count FROM product_click_events ${dailyWhereClause} GROUP BY DATE(occurred_at) ORDER BY date ASC `, dailyParams); res.json({ campaign: campaignResult.rows[0], summary: { totalClicks: parseInt(statsResult.rows[0].total_clicks, 10), uniqueProducts: parseInt(statsResult.rows[0].unique_products, 10), uniqueStores: parseInt(statsResult.rows[0].unique_stores, 10), uniqueBrands: parseInt(statsResult.rows[0].unique_brands, 10), uniqueUsers: parseInt(statsResult.rows[0].unique_users, 10) }, byAction: byActionResult.rows.map(row => ({ action: row.action, count: parseInt(row.count, 10) })), bySource: bySourceResult.rows.map(row => ({ source: row.source, count: parseInt(row.count, 10) })), topProducts: topProductsResult.rows.map(row => ({ productId: row.product_id, clickCount: parseInt(row.click_count, 10) })), daily: dailyResult.rows.map(row => ({ date: row.date, clickCount: parseInt(row.click_count, 10) })) }); } catch (error: any) { console.error('[Campaigns] Error fetching click summary:', error.message); res.status(500).json({ error: 'Failed to fetch campaign click summary' }); } }); export default router;