From 91b4c2de76df333f3330612ceb265a63ff2add95 Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Fri, 2 May 2025 01:00:05 -0500 Subject: [PATCH] campagne tracking --- backend/src/index.js | 3 +- backend/src/routes/emailCampaignsAdmin.js | 372 +++++++++++----------- backend/src/routes/emailTracking.js | 136 ++++++++ backend/src/services/emailService.js | 2 +- 4 files changed, 326 insertions(+), 187 deletions(-) create mode 100644 backend/src/routes/emailTracking.js diff --git a/backend/src/index.js b/backend/src/index.js index 02dda69..949fb04 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -37,7 +37,7 @@ const mailingListRoutes = require('./routes/mailingListAdmin'); const emailCampaignListRoutes = require('./routes/emailCampaignsAdmin'); const subscribersAdminRoutes = require('./routes/subscribersAdmin'); const subscribersRoutes = require('./routes/subscribers'); - +const emailTrackingRoutes = require('./routes/emailTracking'); // Create Express app const app = express(); const port = config.port || 4000; @@ -323,6 +323,7 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/products', productRoutes(pool, query)); app.use('/api/subscribers', subscribersRoutes(pool, query)); +app.use('/api/email', emailTrackingRoutes(pool, query)); app.use('/api/auth', authRoutes(pool, query)); app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query))); diff --git a/backend/src/routes/emailCampaignsAdmin.js b/backend/src/routes/emailCampaignsAdmin.js index c703e05..08b58c8 100644 --- a/backend/src/routes/emailCampaignsAdmin.js +++ b/backend/src/routes/emailCampaignsAdmin.js @@ -425,216 +425,216 @@ module.exports = (pool, query, authMiddleware) => { * Send campaign immediately * POST /api/admin/email-campaigns/:id/send */ -router.post('/:id/send', async (req, res, next) => { - try { - const { id } = req.params; - - if (!req.user.is_admin) { - return res.status(403).json({ - error: true, - message: 'Admin access required' - }); - } - - // Get campaign details - const campaignQuery = ` - SELECT * FROM email_campaigns WHERE id = $1 - `; - - const campaignResult = await query(campaignQuery, [id]); - - if (campaignResult.rows.length === 0) { - return res.status(404).json({ - error: true, - message: 'Campaign not found' - }); - } - - const campaign = campaignResult.rows[0]; - - // Ensure campaign is in a valid state for sending - if (campaign.status !== 'draft' && campaign.status !== 'scheduled') { - return res.status(400).json({ - error: true, - message: `Cannot send campaign with status "${campaign.status}"` - }); - } - - // Begin transaction - const client = await pool.connect(); - + router.post('/:id/send', async (req, res, next) => { try { - await client.query('BEGIN'); + const { id } = req.params; - // Update campaign status - await client.query( - `UPDATE email_campaigns - SET status = 'sending', - sent_at = NOW(), - updated_at = NOW() - WHERE id = $1`, - [id] - ); - - // Get recipients from mailing lists - if (!campaign.list_ids || campaign.list_ids.length === 0) { - await client.query('ROLLBACK'); - return res.status(400).json({ + if (!req.user.is_admin) { + return res.status(403).json({ error: true, - message: 'Campaign has no mailing lists selected' + message: 'Admin access required' }); } - // Prepare the query parameters for the list IDs - const listIdParams = campaign.list_ids; - const placeholders = listIdParams.map((_, i) => `$${i + 1}`).join(','); - - // Get active subscribers from selected mailing lists - const subscribersQuery = ` - SELECT DISTINCT - s.id, - s.email, - s.first_name, - s.last_name - FROM subscribers s - JOIN mailing_list_subscribers ms ON s.id = ms.subscriber_id - WHERE ms.list_id IN (${placeholders}) - AND s.status = 'active' + // Get campaign details + const campaignQuery = ` + SELECT * FROM email_campaigns WHERE id = $1 `; - const subscribersResult = await client.query(subscribersQuery, listIdParams); - const subscribers = subscribersResult.rows; + const campaignResult = await query(campaignQuery, [id]); - if (subscribers.length === 0) { - await client.query('ROLLBACK'); - return res.status(400).json({ + if (campaignResult.rows.length === 0) { + return res.status(404).json({ error: true, - message: 'Selected mailing lists have no active subscribers' + message: 'Campaign not found' }); } - // Add recipients to campaign_recipients table - for (const subscriber of subscribers) { - await client.query( - `INSERT INTO campaign_recipients (campaign_id, subscriber_id) - VALUES ($1, $2) - ON CONFLICT (campaign_id, subscriber_id) DO NOTHING`, - [id, subscriber.id] - ); + const campaign = campaignResult.rows[0]; + + // Ensure campaign is in a valid state for sending + if (campaign.status !== 'draft' && campaign.status !== 'scheduled') { + return res.status(400).json({ + error: true, + message: `Cannot send campaign with status "${campaign.status}"` + }); } - await client.query('COMMIT'); + // Begin transaction + const client = await pool.connect(); - // Now send the actual emails using a background process - // We'll update the campaign status after all emails have been sent - sendCampaignEmails(id, campaign, subscribers).then(async () => { - // Update campaign status to sent after all emails have been processed - await query( + try { + await client.query('BEGIN'); + + // Update campaign status + await client.query( `UPDATE email_campaigns - SET status = 'sent', - updated_at = NOW() - WHERE id = $1`, + SET status = 'sending', + sent_at = NOW(), + updated_at = NOW() + WHERE id = $1`, [id] ); - console.log(`Campaign ${id} completed sending to all recipients`); - }).catch(err => { - console.error(`Error sending campaign ${id}:`, err); - }); - res.json({ - success: true, - message: `Campaign scheduled for sending to ${subscribers.length} recipients`, - id - }); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } catch (error) { - next(error); - } -}); - -/** - * Helper function to send campaign emails to all subscribers - * This runs asynchronously after the HTTP response has been sent - * - * @param {string} campaignId - The campaign ID - * @param {Object} campaign - The campaign object - * @param {Array} subscribers - Array of subscriber objects - * @returns {Promise} - */ -async function sendCampaignEmails(campaignId, campaign, subscribers) { - // Use a smaller batch size to avoid overwhelming the email server - const batchSize = 20; - const totalSubscribers = subscribers.length; - let processed = 0; - - try { - // Process subscribers in batches - for (let i = 0; i < totalSubscribers; i += batchSize) { - const batch = subscribers.slice(i, i + batchSize); - - // Send emails in parallel within each batch - await Promise.all(batch.map(async (subscriber) => { - try { - let personalizedContent = personalizeContent(campaign.content, subscriber); - let personalizedSubject = personalizeContent(campaign.subject, subscriber); - let personalizedPreheader = campaign.preheader ? personalizeContent(campaign.preheader, subscriber) : ''; - - await emailService.sendCampaignEmail({ - to: subscriber.email, - subject: personalizedSubject, - preheader: personalizedPreheader, - from: `${campaign.from_name} <${campaign.from_email}>`, - content: personalizedContent, - campaignId: campaignId, - subscriberId: subscriber.id + + // Get recipients from mailing lists + if (!campaign.list_ids || campaign.list_ids.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: true, + message: 'Campaign has no mailing lists selected' }); - - // Log email sending activity - await query( - `INSERT INTO subscriber_activity (subscriber_id, campaign_id, type) - VALUES ($1, $2, 'sent')`, - [subscriber.id, campaignId] - ); - - processed++; - - // Log progress periodically - if (processed % 50 === 0 || processed === totalSubscribers) { - console.log(`Campaign ${campaignId}: ${processed}/${totalSubscribers} emails sent`); - } - } catch (error) { - console.error(`Error sending email to ${subscriber.email}:`, error); - - // Log error in subscriber activity - try { - await query( - `INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, details) - VALUES ($1, $2, 'error', $3)`, - [subscriber.id, campaignId, error.message.substring(0, 255)] - ); - } catch (logError) { - console.error('Error logging subscriber activity:', logError); - } } - })); - - // Add a small delay between batches to avoid rate limiting - if (i + batchSize < totalSubscribers) { - await new Promise(resolve => setTimeout(resolve, 1000)); + + // Prepare the query parameters for the list IDs + const listIdParams = campaign.list_ids; + const placeholders = listIdParams.map((_, i) => `$${i + 1}`).join(','); + + // Get active subscribers from selected mailing lists + const subscribersQuery = ` + SELECT DISTINCT + s.id, + s.email, + s.first_name, + s.last_name + FROM subscribers s + JOIN mailing_list_subscribers ms ON s.id = ms.subscriber_id + WHERE ms.list_id IN (${placeholders}) + AND s.status = 'active' + `; + + const subscribersResult = await client.query(subscribersQuery, listIdParams); + const subscribers = subscribersResult.rows; + + if (subscribers.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: true, + message: 'Selected mailing lists have no active subscribers' + }); + } + + // Add recipients to campaign_recipients table + for (const subscriber of subscribers) { + await client.query( + `INSERT INTO campaign_recipients (campaign_id, subscriber_id) + VALUES ($1, $2) + ON CONFLICT (campaign_id, subscriber_id) DO NOTHING`, + [id, subscriber.id] + ); + } + + await client.query('COMMIT'); + + // Now send the actual emails using a background process + // We'll update the campaign status after all emails have been sent + sendCampaignEmails(id, campaign, subscribers).then(async () => { + // Update campaign status to sent after all emails have been processed + await query( + `UPDATE email_campaigns + SET status = 'sent', + updated_at = NOW() + WHERE id = $1`, + [id] + ); + console.log(`Campaign ${id} completed sending to all recipients`); + }).catch(err => { + console.error(`Error sending campaign ${id}:`, err); + }); + res.json({ + success: true, + message: `Campaign scheduled for sending to ${subscribers.length} recipients`, + id + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); } + } catch (error) { + next(error); } + }); + + /** + * Helper function to send campaign emails to all subscribers + * This runs asynchronously after the HTTP response has been sent + * + * @param {string} campaignId - The campaign ID + * @param {Object} campaign - The campaign object + * @param {Array} subscribers - Array of subscriber objects + * @returns {Promise} + */ + async function sendCampaignEmails(campaignId, campaign, subscribers) { + // Use a smaller batch size to avoid overwhelming the email server + const batchSize = 20; + const totalSubscribers = subscribers.length; + let processed = 0; - console.log(`Campaign ${campaignId} completed: ${processed}/${totalSubscribers} emails sent successfully`); - return processed; - } catch (error) { - console.error(`Error sending campaign ${campaignId}:`, error); - throw error; + try { + // Process subscribers in batches + for (let i = 0; i < totalSubscribers; i += batchSize) { + const batch = subscribers.slice(i, i + batchSize); + + // Send emails in parallel within each batch + await Promise.all(batch.map(async (subscriber) => { + try { + let personalizedContent = personalizeContent(campaign.content, subscriber); + let personalizedSubject = personalizeContent(campaign.subject, subscriber); + let personalizedPreheader = campaign.preheader ? personalizeContent(campaign.preheader, subscriber) : ''; + + await emailService.sendCampaignEmail({ + to: subscriber.email, + subject: personalizedSubject, + preheader: personalizedPreheader, + from: `${campaign.from_name} <${campaign.from_email}>`, + content: personalizedContent, + campaignId: campaignId, + subscriberId: subscriber.id + }); + + // Log email sending activity + await query( + `INSERT INTO subscriber_activity (subscriber_id, campaign_id, type) + VALUES ($1, $2, 'sent')`, + [subscriber.id, campaignId] + ); + + processed++; + + // Log progress periodically + if (processed % 50 === 0 || processed === totalSubscribers) { + console.log(`Campaign ${campaignId}: ${processed}/${totalSubscribers} emails sent`); + } + } catch (error) { + console.error(`Error sending email to ${subscriber.email}:`, error); + + // Log error in subscriber activity + try { + await query( + `INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, details) + VALUES ($1, $2, 'error', $3)`, + [subscriber.id, campaignId, error.message.substring(0, 255)] + ); + } catch (logError) { + console.error('Error logging subscriber activity:', logError); + } + } + })); + + // Add a small delay between batches to avoid rate limiting + if (i + batchSize < totalSubscribers) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + console.log(`Campaign ${campaignId} completed: ${processed}/${totalSubscribers} emails sent successfully`); + return processed; + } catch (error) { + console.error(`Error sending campaign ${campaignId}:`, error); + throw error; + } } -} /** * Schedule campaign for later @@ -917,6 +917,8 @@ async function sendCampaignEmails(campaignId, campaign, subscribers) { `; queryParams.push(parseInt(pageSize, 10), offset); + console.log("queryParams", queryParams) + console.log("activityQuery", activityQuery) const activityResult = await query(activityQuery, queryParams); res.json({ diff --git a/backend/src/routes/emailTracking.js b/backend/src/routes/emailTracking.js new file mode 100644 index 0000000..c3150e8 --- /dev/null +++ b/backend/src/routes/emailTracking.js @@ -0,0 +1,136 @@ +const express = require('express'); +const router = express.Router(); + +module.exports = (pool, query) => { + /** + * Handle email tracking for opens and clicks + * GET /api/email/track + * + * Query parameters: + * - c: Campaign ID + * - s: Subscriber ID + * - t: Type of tracking (open, click) + * - l: Link ID (only for click tracking) + * - u: Original URL (only for click tracking, encoded) + */ + router.get('/track', async (req, res, next) => { + try { + const { c: campaignId, s: subscriberId, t: type, l: linkId, u: encodedUrl } = req.query; + + // Validate required parameters + if (!campaignId || !subscriberId || !type) { + // For tracking pixels, return a 1x1 transparent GIF to avoid breaking email rendering + if (type === 'open') { + return sendTrackingPixel(res); + } + + // For click tracking, redirect to homepage if parameters are invalid + if (type === 'click' && encodedUrl) { + return res.redirect(decodeURIComponent(encodedUrl)); + } + + return res.redirect('/'); + } + + // Process tracking event asynchronously (don't wait for DB operations) + processTrackingEvent(campaignId, subscriberId, type, linkId, encodedUrl) + .catch(err => console.error('Error processing tracking event:', err)); + + // Respond based on tracking type + if (type === 'open') { + // For opens, return a 1x1 transparent GIF + return sendTrackingPixel(res); + } else if (type === 'click' && encodedUrl) { + // For clicks, redirect to the original URL + return res.redirect(decodeURIComponent(encodedUrl)); + } else { + // Fallback to homepage + return res.redirect('/'); + } + } catch (error) { + console.error('Tracking error:', error); + + // Always provide a response, even on error + if (req.query.t === 'open') { + return sendTrackingPixel(res); + } else { + return res.redirect('/'); + } + } + }); + + /** + * Process a tracking event and update subscriber activity + * @param {string} campaignId - Campaign ID + * @param {string} subscriberId - Subscriber ID + * @param {string} type - Event type (open, click) + * @param {string} linkId - Link ID for click events + * @param {string} encodedUrl - Original URL for click events + * @returns {Promise} + */ + async function processTrackingEvent(campaignId, subscriberId, type, linkId, encodedUrl) { + try { + // Get client IP and user agent + const details = {}; + + // Record the tracking event in subscriber_activity + if (type === 'open') { + // Check if an open has already been recorded for this subscriber/campaign + const existingOpen = await query( + `SELECT id FROM subscriber_activity + WHERE subscriber_id = $1 AND campaign_id = $2 AND type = 'open' + LIMIT 1`, + [subscriberId, campaignId] + ); + + // Only record first open to avoid duplicate counting + if (existingOpen.rows.length === 0) { + await query( + `INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, details) + VALUES ($1, $2, 'open', $3)`, + [subscriberId, campaignId, JSON.stringify(details)] + ); + + // Update subscriber's last_activity_at + await query( + `UPDATE subscribers SET last_activity_at = NOW() WHERE id = $1`, + [subscriberId] + ); + } + } else if (type === 'click' && linkId) { + // Record the click with the link ID + await query( + `INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, link_id, url, details) + VALUES ($1, $2, 'click', $3, $4, $5)`, + [subscriberId, campaignId, linkId, decodeURIComponent(encodedUrl), JSON.stringify(details)] + ); + + // Update subscriber's last_activity_at + await query( + `UPDATE subscribers SET last_activity_at = NOW() WHERE id = $1`, + [subscriberId] + ); + } + } catch (error) { + console.error('Error processing tracking event:', error); + // Don't throw - we want to fail silently for tracking + } + } + + /** + * Send a 1x1 transparent GIF for tracking pixels + * @param {Object} res - Express response object + */ + function sendTrackingPixel(res) { + // 1x1 transparent GIF in base64 + const transparentGif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); + + res.set('Content-Type', 'image/gif'); + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.set('Pragma', 'no-cache'); + res.set('Expires', '0'); + res.send(transparentGif); + } + + return router; +}; \ No newline at end of file diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js index c5d972a..c07381a 100644 --- a/backend/src/services/emailService.js +++ b/backend/src/services/emailService.js @@ -445,7 +445,7 @@ const emailService = { // Generate an unsubscribe token const token = uuidv4(); - // Store the token in the database (this would be done asynchronously) + this.storeUnsubscribeToken(subscriberId, token, campaignId); return `${this.siteUrl}/api/subscribers/unsubscribe?token=${token}`;