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 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)));
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
* Send campaign immediately
|
||||
* 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;
|
||||
|
||||
|
|
@ -554,9 +554,9 @@ router.post('/:id/send', async (req, res, next) => {
|
|||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
/**
|
||||
* Helper function to send campaign emails to all subscribers
|
||||
* This runs asynchronously after the HTTP response has been sent
|
||||
*
|
||||
|
|
@ -565,7 +565,7 @@ router.post('/:id/send', async (req, res, next) => {
|
|||
* @param {Array} subscribers - Array of subscriber objects
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sendCampaignEmails(campaignId, campaign, subscribers) {
|
||||
async function sendCampaignEmails(campaignId, campaign, subscribers) {
|
||||
// Use a smaller batch size to avoid overwhelming the email server
|
||||
const batchSize = 20;
|
||||
const totalSubscribers = subscribers.length;
|
||||
|
|
@ -634,7 +634,7 @@ async function sendCampaignEmails(campaignId, campaign, subscribers) {
|
|||
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({
|
||||
|
|
|
|||
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
|
||||
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}`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue