campagne tracking
This commit is contained in:
parent
8da0206f10
commit
91b4c2de76
4 changed files with 326 additions and 187 deletions
|
|
@ -37,7 +37,7 @@ const mailingListRoutes = require('./routes/mailingListAdmin');
|
||||||
const emailCampaignListRoutes = require('./routes/emailCampaignsAdmin');
|
const emailCampaignListRoutes = require('./routes/emailCampaignsAdmin');
|
||||||
const subscribersAdminRoutes = require('./routes/subscribersAdmin');
|
const subscribersAdminRoutes = require('./routes/subscribersAdmin');
|
||||||
const subscribersRoutes = require('./routes/subscribers');
|
const subscribersRoutes = require('./routes/subscribers');
|
||||||
|
const emailTrackingRoutes = require('./routes/emailTracking');
|
||||||
// Create Express app
|
// Create Express app
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = config.port || 4000;
|
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/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
app.use('/api/products', productRoutes(pool, query));
|
app.use('/api/products', productRoutes(pool, query));
|
||||||
app.use('/api/subscribers', subscribersRoutes(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/auth', authRoutes(pool, query));
|
||||||
app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query)));
|
app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query)));
|
||||||
|
|
|
||||||
|
|
@ -425,216 +425,216 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
* Send campaign immediately
|
* Send campaign immediately
|
||||||
* POST /api/admin/email-campaigns/:id/send
|
* POST /api/admin/email-campaigns/:id/send
|
||||||
*/
|
*/
|
||||||
router.post('/:id/send', async (req, res, next) => {
|
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();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
const { id } = req.params;
|
||||||
|
|
||||||
// Update campaign status
|
if (!req.user.is_admin) {
|
||||||
await client.query(
|
return res.status(403).json({
|
||||||
`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({
|
|
||||||
error: true,
|
error: true,
|
||||||
message: 'Campaign has no mailing lists selected'
|
message: 'Admin access required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the query parameters for the list IDs
|
// Get campaign details
|
||||||
const listIdParams = campaign.list_ids;
|
const campaignQuery = `
|
||||||
const placeholders = listIdParams.map((_, i) => `$${i + 1}`).join(',');
|
SELECT * FROM email_campaigns WHERE id = $1
|
||||||
|
|
||||||
// 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 campaignResult = await query(campaignQuery, [id]);
|
||||||
const subscribers = subscribersResult.rows;
|
|
||||||
|
|
||||||
if (subscribers.length === 0) {
|
if (campaignResult.rows.length === 0) {
|
||||||
await client.query('ROLLBACK');
|
return res.status(404).json({
|
||||||
return res.status(400).json({
|
|
||||||
error: true,
|
error: true,
|
||||||
message: 'Selected mailing lists have no active subscribers'
|
message: 'Campaign not found'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add recipients to campaign_recipients table
|
const campaign = campaignResult.rows[0];
|
||||||
for (const subscriber of subscribers) {
|
|
||||||
await client.query(
|
// Ensure campaign is in a valid state for sending
|
||||||
`INSERT INTO campaign_recipients (campaign_id, subscriber_id)
|
if (campaign.status !== 'draft' && campaign.status !== 'scheduled') {
|
||||||
VALUES ($1, $2)
|
return res.status(400).json({
|
||||||
ON CONFLICT (campaign_id, subscriber_id) DO NOTHING`,
|
error: true,
|
||||||
[id, subscriber.id]
|
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
|
try {
|
||||||
// We'll update the campaign status after all emails have been sent
|
await client.query('BEGIN');
|
||||||
sendCampaignEmails(id, campaign, subscribers).then(async () => {
|
|
||||||
// Update campaign status to sent after all emails have been processed
|
// Update campaign status
|
||||||
await query(
|
await client.query(
|
||||||
`UPDATE email_campaigns
|
`UPDATE email_campaigns
|
||||||
SET status = 'sent',
|
SET status = 'sending',
|
||||||
updated_at = NOW()
|
sent_at = NOW(),
|
||||||
WHERE id = $1`,
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
console.log(`Campaign ${id} completed sending to all recipients`);
|
|
||||||
}).catch(err => {
|
// Get recipients from mailing lists
|
||||||
console.error(`Error sending campaign ${id}:`, err);
|
if (!campaign.list_ids || campaign.list_ids.length === 0) {
|
||||||
});
|
await client.query('ROLLBACK');
|
||||||
res.json({
|
return res.status(400).json({
|
||||||
success: true,
|
error: true,
|
||||||
message: `Campaign scheduled for sending to ${subscribers.length} recipients`,
|
message: 'Campaign has no mailing lists selected'
|
||||||
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<void>}
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}));
|
|
||||||
|
// Prepare the query parameters for the list IDs
|
||||||
// Add a small delay between batches to avoid rate limiting
|
const listIdParams = campaign.list_ids;
|
||||||
if (i + batchSize < totalSubscribers) {
|
const placeholders = listIdParams.map((_, i) => `$${i + 1}`).join(',');
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
// 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<void>}
|
||||||
|
*/
|
||||||
|
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`);
|
try {
|
||||||
return processed;
|
// Process subscribers in batches
|
||||||
} catch (error) {
|
for (let i = 0; i < totalSubscribers; i += batchSize) {
|
||||||
console.error(`Error sending campaign ${campaignId}:`, error);
|
const batch = subscribers.slice(i, i + batchSize);
|
||||||
throw error;
|
|
||||||
|
// 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
|
* Schedule campaign for later
|
||||||
|
|
@ -917,6 +917,8 @@ async function sendCampaignEmails(campaignId, campaign, subscribers) {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
queryParams.push(parseInt(pageSize, 10), offset);
|
queryParams.push(parseInt(pageSize, 10), offset);
|
||||||
|
console.log("queryParams", queryParams)
|
||||||
|
console.log("activityQuery", activityQuery)
|
||||||
const activityResult = await query(activityQuery, queryParams);
|
const activityResult = await query(activityQuery, queryParams);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
|
||||||
136
backend/src/routes/emailTracking.js
Normal file
136
backend/src/routes/emailTracking.js
Normal file
|
|
@ -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<void>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -445,7 +445,7 @@ const emailService = {
|
||||||
// Generate an unsubscribe token
|
// Generate an unsubscribe token
|
||||||
const token = uuidv4();
|
const token = uuidv4();
|
||||||
|
|
||||||
// Store the token in the database (this would be done asynchronously)
|
|
||||||
this.storeUnsubscribeToken(subscriberId, token, campaignId);
|
this.storeUnsubscribeToken(subscriberId, token, campaignId);
|
||||||
|
|
||||||
return `${this.siteUrl}/api/subscribers/unsubscribe?token=${token}`;
|
return `${this.siteUrl}/api/subscribers/unsubscribe?token=${token}`;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue