diff --git a/backend/package-lock.json b/backend/package-lock.json index e2fe3bd..07807e8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -70,6 +70,16 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "csv-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz", + "integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==" + }, + "csv-writer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz", + "integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==" + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index d98cba9..2c09bc7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,8 @@ "dependencies": { "axios": "^1.9.0", "cors": "^2.8.5", + "csv-parser": "^3.2.0", + "csv-writer": "^1.6.0", "dotenv": "^16.0.3", "express": "^4.18.2", "morgan": "^1.10.0", diff --git a/backend/src/index.js b/backend/src/index.js index 8bde6be..f292666 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -33,6 +33,9 @@ const productReviewsRoutes = require('./routes/productReviews'); const productReviewsAdminRoutes = require('./routes/productReviewsAdmin'); const emailTemplatesAdminRoutes = require('./routes/emailTemplatesAdmin'); const publicSettingsRoutes = require('./routes/publicSettings'); +const mailingListRoutes = require('./routes/mailingListAdmin'); +const emailCampaignListRoutes = require('./routes/emailCampaignsAdmin'); +const subscribersAdminRoutes = require('./routes/subscribersAdmin'); // Create Express app const app = express(); @@ -220,11 +223,20 @@ app.use('/api/admin/product-reviews', productReviewsAdminRoutes(pool, query, adm app.use('/api/admin/users', usersAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/admin/coupons', couponsAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); +app.use('/api/admin/subscribers', subscribersAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); + app.use('/api/admin/orders', ordersAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/admin/blog', blogAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/admin/blog-comments', blogCommentsAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/admin/email-templates', emailTemplatesAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); + +app.use('/api/admin/mailing-lists', mailingListRoutes(pool, query, adminAuthMiddleware(pool, query))); +app.use('/api/admin/email-campaigns', emailCampaignListRoutes(pool, query, adminAuthMiddleware(pool, query))); + + + + // Admin-only product image upload app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => { console.log('/api/image/product', req.file); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index a27cdac..ea03a3f 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -8,7 +8,7 @@ const router = express.Router(); module.exports = (pool, query) => { // Register new user router.post('/register', async (req, res, next) => { - const { email, firstName, lastName } = req.body; + const { email, firstName, lastName, isSubscribed = false } = req.body; try { // Check if user already exists @@ -29,7 +29,19 @@ module.exports = (pool, query) => { 'INSERT INTO users (email, first_name, last_name) VALUES ($1, $2, $3) RETURNING id, email', [email, firstName, lastName] ); - + console.log("REGISTERED NEW USER ", email) + if(isSubscribed){ + let subResult = await query( + `INSERT INTO subscribers (id, email, first_name, last_name, status) + VALUES ($1, $2, $3, $4, $5) RETURNING id, email`, + [result.rows[0].id, email, firstName, lastName, 'active'] + ); + let mailResult = await query( + `INSERT INTO mailing_list_subscribers (list_id, subscriber_id) + VALUES ($1, $2)`, + ['1db91b9b-b1f9-4892-80b5-51437d8b6045', result.rows[0].id] + ); + } res.status(201).json({ message: 'User registered successfully', user: result.rows[0] diff --git a/backend/src/routes/emailCampaignsAdmin.js b/backend/src/routes/emailCampaignsAdmin.js new file mode 100644 index 0000000..9d6152f --- /dev/null +++ b/backend/src/routes/emailCampaignsAdmin.js @@ -0,0 +1,1030 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const router = express.Router(); +const { createObjectCsvWriter } = require('csv-writer'); +const fs = require('fs'); +const path = require('path'); +const emailService = require('../services/emailService'); + +module.exports = (pool, query, authMiddleware) => { + // Apply authentication middleware to all routes + router.use(authMiddleware); + + /** + * Get all email campaigns + * GET /api/admin/email-campaigns + */ + router.get('/', async (req, res, next) => { + try { + const { status } = req.query; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Prepare query conditions + let condition = ''; + const params = []; + + if (status) { + condition = 'WHERE status = $1'; + params.push(status); + } + + // Get all campaigns with basic stats + const campaignsQuery = ` + SELECT + ec.*, + COALESCE(stats.recipient_count, 0) as recipient_count + FROM email_campaigns ec + LEFT JOIN ( + SELECT + campaign_id, + COUNT(DISTINCT subscriber_id) as recipient_count + FROM campaign_recipients + GROUP BY campaign_id + ) stats ON ec.id = stats.campaign_id + ${condition} + ORDER BY + CASE + WHEN ec.status = 'draft' THEN 1 + WHEN ec.status = 'scheduled' THEN 2 + WHEN ec.status = 'sending' THEN 3 + WHEN ec.status = 'sent' THEN 4 + ELSE 5 + END, + ec.created_at DESC + `; + + const result = await query(campaignsQuery, params); + + res.json(result.rows); + } catch (error) { + next(error); + } + }); + + /** + * Get a single email campaign + * GET /api/admin/email-campaigns/:id + */ + router.get('/:id', 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 with list info + const campaignQuery = ` + SELECT + ec.*, + ml.name as list_name, + COUNT(cr.subscriber_id) as recipient_count + FROM email_campaigns ec + LEFT JOIN campaign_recipients cr ON ec.id = cr.campaign_id + LEFT JOIN mailing_lists ml ON ec.list_ids[1] = ml.id + WHERE ec.id = $1 + GROUP BY ec.id, ml.name + `; + + const campaignResult = await query(campaignQuery, [id]); + + if (campaignResult.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Campaign not found' + }); + } + + res.json(campaignResult.rows[0]); + } catch (error) { + next(error); + } + }); + + /** + * Create a new email campaign + * POST /api/admin/email-campaigns + */ + router.post('/', async (req, res, next) => { + try { + const { + name, subject, preheader, fromName, fromEmail, + content, design, listIds, status = 'draft' + } = req.body; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Validate required fields + if (!name || !subject || !fromName || !fromEmail) { + return res.status(400).json({ + error: true, + message: 'Name, subject, from name, and from email are required' + }); + } + + // Create new campaign + const campaignId = uuidv4(); + const result = await query( + `INSERT INTO email_campaigns ( + id, name, subject, preheader, from_name, from_email, + content, design, list_ids, status, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + campaignId, name, subject, preheader, fromName, fromEmail, + content, design, listIds, status, req.user.id + ] + ); + + res.status(201).json(result.rows[0]); + } catch (error) { + next(error); + } + }); + + /** + * Update an email campaign + * PUT /api/admin/email-campaigns/:id + */ + router.put('/:id', async (req, res, next) => { + try { + const { id } = req.params; + const { + name, subject, preheader, fromName, fromEmail, + content, design, listIds, status + } = req.body; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Check if campaign exists + const campaignCheck = await query( + 'SELECT status FROM email_campaigns WHERE id = $1', + [id] + ); + + if (campaignCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Campaign not found' + }); + } + + // Cannot edit campaigns that are already sent + const currentStatus = campaignCheck.rows[0].status; + if (currentStatus === 'sent' || currentStatus === 'sending') { + return res.status(400).json({ + error: true, + message: 'Cannot edit a campaign that has already been sent or is currently sending' + }); + } + + // Update campaign + const result = await query( + `UPDATE email_campaigns + SET name = $1, + subject = $2, + preheader = $3, + from_name = $4, + from_email = $5, + content = $6, + design = $7, + list_ids = $8, + status = $9, + updated_at = NOW() + WHERE id = $10 + RETURNING *`, + [ + name, subject, preheader, fromName, fromEmail, + content, design, listIds, status, id + ] + ); + + res.json(result.rows[0]); + } catch (error) { + next(error); + } + }); + + /** + * Delete an email campaign + * DELETE /api/admin/email-campaigns/:id + */ + router.delete('/:id', 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' + }); + } + + // Check if campaign exists + const campaignCheck = await query( + 'SELECT status FROM email_campaigns WHERE id = $1', + [id] + ); + + if (campaignCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Campaign not found' + }); + } + + // Cannot delete campaigns that have been sent + const currentStatus = campaignCheck.rows[0].status; + if (currentStatus === 'sent' || currentStatus === 'sending') { + return res.status(400).json({ + error: true, + message: 'Cannot delete a campaign that has already been sent or is currently sending' + }); + } + + // Begin transaction + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Delete campaign recipients + await client.query( + 'DELETE FROM campaign_recipients WHERE campaign_id = $1', + [id] + ); + + // Delete campaign + await client.query( + 'DELETE FROM email_campaigns WHERE id = $1', + [id] + ); + + await client.query('COMMIT'); + + res.json({ + success: true, + message: 'Campaign deleted successfully' + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + next(error); + } + }); + + /** + * Duplicate an email campaign + * POST /api/admin/email-campaigns/:id/duplicate + */ + router.post('/:id/duplicate', 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' + }); + } + + // Check if campaign exists + const campaignCheck = await query( + 'SELECT * FROM email_campaigns WHERE id = $1', + [id] + ); + + if (campaignCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Campaign not found' + }); + } + + const originalCampaign = campaignCheck.rows[0]; + + // Create new campaign ID + const newCampaignId = uuidv4(); + + // Insert duplicated campaign + const result = await query( + `INSERT INTO email_campaigns ( + id, name, subject, preheader, from_name, from_email, + content, design, list_ids, status, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft', $10) + RETURNING *`, + [ + newCampaignId, + `Copy of ${originalCampaign.name}`, + originalCampaign.subject, + originalCampaign.preheader, + originalCampaign.from_name, + originalCampaign.from_email, + originalCampaign.content, + originalCampaign.design, + originalCampaign.list_ids, + req.user.id + ] + ); + + res.status(201).json(result.rows[0]); + } catch (error) { + next(error); + } + }); + + /** + * Preview/send test email + * POST /api/admin/email-campaigns/:id/preview + */ + router.post('/:id/preview', async (req, res, next) => { + try { + const { id } = req.params; + const { email } = req.body; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + if (!email) { + return res.status(400).json({ + error: true, + message: 'Email address is 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]; + // Send test email with emailService + try { + await emailService.sendTestEmail({ + to: email, + subject: `[TEST] ${campaign.subject}`, + preheader: campaign.preheader, + from: `${campaign.from_name} <${campaign.from_email}>`, + content: campaign.content + }); + + res.json({ + success: true, + message: `Test email sent to ${email}` + }); + } catch (error) { + return res.status(500).json({ + error: true, + message: `Failed to send test email: ${error.message}` + }); + } + } catch (error) { + next(error); + } + }); + +/** + * 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(); + + try { + await client.query('BEGIN'); + + // 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({ + error: true, + message: 'Campaign has no mailing lists selected' + }); + } + + // 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); + }); + https://click.pstmrk.it/3/localhost%3A3000%2Fapi%2Fsubscribers%2Funsubscribe%3Ftoken%3De5b3b22c-ba7f-4684-afce-bd998d45f0b1/aODm/yg69AQ/AQ/030c5f0a-b979-4327-a121-9c91eeb9eb40/1/tDsgHryVEs + 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 { + await emailService.sendCampaignEmail({ + to: subscriber.email, + subject: campaign.subject, + preheader: campaign.preheader || '', + from: `${campaign.from_name} <${campaign.from_email}>`, + content: campaign.content, + 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 + * POST /api/admin/email-campaigns/:id/schedule + */ + router.post('/:id/schedule', async (req, res, next) => { + try { + const { id } = req.params; + const { scheduledDate } = req.body; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + if (!scheduledDate) { + return res.status(400).json({ + error: true, + message: 'Scheduled date is required' + }); + } + + // Validate date is in the future + const scheduleTime = new Date(scheduledDate); + const now = new Date(); + + if (scheduleTime <= now) { + return res.status(400).json({ + error: true, + message: 'Scheduled date must be in the future' + }); + } + + // 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 scheduling + if (campaign.status !== 'draft' && campaign.status !== 'scheduled') { + return res.status(400).json({ + error: true, + message: `Cannot schedule campaign with status "${campaign.status}"` + }); + } + + // Update campaign + const result = await query( + `UPDATE email_campaigns + SET status = 'scheduled', + scheduled_for = $1, + updated_at = NOW() + WHERE id = $2 + RETURNING *`, + [scheduleTime, id] + ); + + res.json({ + success: true, + message: `Campaign scheduled for ${scheduleTime.toISOString()}`, + campaign: result.rows[0] + }); + } catch (error) { + next(error); + } + }); + + /** + * Get campaign analytics + * GET /api/admin/email-campaigns/:id/analytics + */ + router.get('/:id/analytics', 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' + }); + } + + // Check if campaign exists + const campaignCheck = await query( + 'SELECT id FROM email_campaigns WHERE id = $1', + [id] + ); + + if (campaignCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Campaign not found' + }); + } + + // Get campaign recipients count + const recipientsQuery = ` + SELECT COUNT(*) as sent + FROM campaign_recipients + WHERE campaign_id = $1 + `; + + const recipientsResult = await query(recipientsQuery, [id]); + const sent = parseInt(recipientsResult.rows[0].sent, 10); + + // Get delivery stats + const deliveryQuery = ` + SELECT + COUNT(*) FILTER (WHERE a.type = 'bounce') as bounced, + COUNT(*) FILTER (WHERE a.type = 'sent') as delivered + FROM campaign_recipients cr + LEFT JOIN subscriber_activity a + ON cr.subscriber_id = a.subscriber_id + AND a.campaign_id = cr.campaign_id + WHERE cr.campaign_id = $1 + `; + + const deliveryResult = await query(deliveryQuery, [id]); + const bounced = parseInt(deliveryResult.rows[0].bounced, 10); + const delivered = sent - bounced; // In reality, delivered would be from webhooks + + // Get engagement stats + const engagementQuery = ` + SELECT + COUNT(DISTINCT subscriber_id) FILTER (WHERE type = 'open') as opened, + COUNT(DISTINCT subscriber_id) FILTER (WHERE type = 'click') as clicked, + COUNT(DISTINCT subscriber_id) FILTER (WHERE type = 'unsubscribe') as unsubscribed + FROM subscriber_activity + WHERE campaign_id = $1 + `; + + const engagementResult = await query(engagementQuery, [id]); + const opened = parseInt(engagementResult.rows[0].opened, 10); + const clicked = parseInt(engagementResult.rows[0].clicked, 10); + const unsubscribed = parseInt(engagementResult.rows[0].unsubscribed, 10); + + // Get link performance + const linksQuery = ` + SELECT + l.id, + l.url, + l.text, + COUNT(a.id) as clicks, + COUNT(DISTINCT a.subscriber_id) as unique_clicks + FROM campaign_links l + LEFT JOIN subscriber_activity a + ON l.id = a.link_id + AND a.type = 'click' + WHERE l.campaign_id = $1 + GROUP BY l.id, l.url, l.text + ORDER BY unique_clicks DESC + `; + + const linksResult = await query(linksQuery, [id]); + + // Get opens by hour (for graphing) + const opensByHourQuery = ` + SELECT + DATE_TRUNC('hour', timestamp) as hour, + COUNT(*) as opens + FROM subscriber_activity + WHERE campaign_id = $1 AND type = 'open' + GROUP BY hour + ORDER BY hour + `; + + const opensByHourResult = await query(opensByHourQuery, [id]); + + res.json({ + sent, + delivered, + bounced, + opened, + clicked, + unsubscribed, + links: linksResult.rows, + opens_by_hour: opensByHourResult.rows + }); + } catch (error) { + next(error); + } + }); + + /** + * Get campaign subscriber activity + * GET /api/admin/email-campaigns/:id/activity + */ + router.get('/:id/activity', async (req, res, next) => { + try { + const { id } = req.params; + const { page = 0, pageSize = 25, search = '', type } = req.query; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Check if campaign exists + const campaignCheck = await query( + 'SELECT id FROM email_campaigns WHERE id = $1', + [id] + ); + + if (campaignCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Campaign not found' + }); + } + + // Prepare query conditions + const conditions = ['a.campaign_id = $1']; + const queryParams = [id]; + let paramIndex = 2; + + if (search) { + conditions.push(`( + s.email ILIKE ${paramIndex} OR + s.first_name ILIKE ${paramIndex} OR + s.last_name ILIKE ${paramIndex} + )`); + queryParams.push(`%${search}%`); + paramIndex++; + } + + if (type && type !== 'all') { + conditions.push(`a.type = ${paramIndex}`); + queryParams.push(type); + paramIndex++; + } + + // Build WHERE clause + const whereClause = conditions.length > 0 + ? `WHERE ${conditions.join(' AND ')}` + : ''; + + // Get total count + const countQuery = ` + SELECT COUNT(*) as total + FROM subscriber_activity a + JOIN subscribers s ON a.subscriber_id = s.id + ${whereClause} + `; + + const countResult = await query(countQuery, queryParams); + const totalCount = parseInt(countResult.rows[0].total, 10); + + // Get activity with pagination + const offset = page * pageSize; + const activityQuery = ` + SELECT + a.id, + a.type, + a.timestamp, + a.details, + a.url, + s.email, + s.first_name, + s.last_name + FROM subscriber_activity a + JOIN subscribers s ON a.subscriber_id = s.id + ${whereClause} + ORDER BY a.timestamp DESC + LIMIT ${paramIndex} OFFSET ${paramIndex + 1} + `; + + queryParams.push(parseInt(pageSize, 10), offset); + const activityResult = await query(activityQuery, queryParams); + + res.json({ + activities: activityResult.rows, + totalCount, + page: parseInt(page, 10), + pageSize: parseInt(pageSize, 10) + }); + } catch (error) { + next(error); + } + }); + + /** + * Export campaign report + * GET /api/admin/email-campaigns/:id/export + */ + router.get('/:id/export', 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' + }); + } + + // Check if campaign exists + const campaignCheck = await query( + 'SELECT name FROM email_campaigns WHERE id = $1', + [id] + ); + + if (campaignCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Campaign not found' + }); + } + + const campaignName = campaignCheck.rows[0].name; + + // Get all subscriber activity for this campaign + const activityQuery = ` + SELECT + s.email, + s.first_name, + s.last_name, + a.type, + a.timestamp, + a.details, + a.url + FROM subscriber_activity a + JOIN subscribers s ON a.subscriber_id = s.id + WHERE a.campaign_id = $1 + ORDER BY s.email, a.timestamp + `; + + const activityResult = await query(activityQuery, [id]); + const activities = activityResult.rows; + + // Create a temp file for CSV + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `campaign-report-${campaignName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.csv`; + const filePath = path.join(__dirname, '../uploads/temp', fileName); + + // Create CSV writer + const csvWriter = createObjectCsvWriter({ + path: filePath, + header: [ + { id: 'email', title: 'Email' }, + { id: 'first_name', title: 'First Name' }, + { id: 'last_name', title: 'Last Name' }, + { id: 'type', title: 'Activity Type' }, + { id: 'timestamp', title: 'Timestamp' }, + { id: 'details', title: 'Details' }, + { id: 'url', title: 'URL' } + ] + }); + + // Format dates in the data + const formattedActivities = activities.map(activity => ({ + ...activity, + timestamp: activity.timestamp ? new Date(activity.timestamp).toISOString() : '' + })); + + // Write to CSV + await csvWriter.writeRecords(formattedActivities); + + // Send file and delete after sending + res.download(filePath, fileName, (err) => { + // Delete the temp file after sending + fs.unlink(filePath, (unlinkErr) => { + if (unlinkErr) console.error('Error deleting temp file:', unlinkErr); + }); + + if (err) { + console.error('Error sending file:', err); + if (!res.headersSent) { + res.status(500).json({ + error: true, + message: 'Error sending file' + }); + } + } + }); + } catch (error) { + next(error); + } + }); + + return router; +}; \ No newline at end of file diff --git a/backend/src/routes/mailingListAdmin.js b/backend/src/routes/mailingListAdmin.js new file mode 100644 index 0000000..ee8fb4f --- /dev/null +++ b/backend/src/routes/mailingListAdmin.js @@ -0,0 +1,734 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const router = express.Router(); +const multer = require('multer'); +const csv = require('csv-parser'); +const fs = require('fs'); +const path = require('path'); +const { createObjectCsvWriter } = require('csv-writer'); + +// Configure multer for file uploads +const upload = multer({ + dest: path.join(__dirname, '../uploads/temp'), + limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit +}); + +module.exports = (pool, query, authMiddleware) => { + // Apply authentication middleware to all routes + router.use(authMiddleware); + + /** + * Get all mailing lists + * GET /api/admin/mailing-lists + */ + router.get('/', async (req, res, next) => { + try { + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Get all mailing lists with subscriber counts + const result = await query(` + SELECT + ml.id, + ml.name, + ml.description, + ml.created_at, + ml.updated_at, + COUNT(ms.subscriber_id) AS subscriber_count + FROM mailing_lists ml + LEFT JOIN mailing_list_subscribers ms ON ml.id = ms.list_id + GROUP BY + ml.id, ml.name, ml.description, ml.created_at, ml.updated_at + ORDER BY ml.name; + `); + + res.json(result.rows); + } catch (error) { + next(error); + } + }); + + /** + * Get a single mailing list by ID + * GET /api/admin/mailing-lists/:id + */ + router.get('/:id', 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 mailing list with subscriber count + const result = await query(` + SELECT + ml.id, + ml.name, + ml.description, + ml.created_at, + ml.updated_at, + COUNT(ms.subscriber_id) AS subscriber_count + FROM mailing_lists ml + LEFT JOIN mailing_list_subscribers ms ON ml.id = ms.list_id + WHERE ml.id = $1 + GROUP BY + ml.id, ml.name, ml.description, ml.created_at, ml.updated_at; + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Mailing list not found' + }); + } + + res.json(result.rows[0]); + } catch (error) { + next(error); + } + }); + + /** + * Create a new mailing list + * POST /api/admin/mailing-lists + */ + router.post('/', async (req, res, next) => { + try { + const { name, description } = req.body; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Validate required fields + if (!name) { + return res.status(400).json({ + error: true, + message: 'List name is required' + }); + } + + // Create new mailing list + const listId = uuidv4(); + const result = await query( + `INSERT INTO mailing_lists (id, name, description) + VALUES ($1, $2, $3) + RETURNING *`, + [listId, name, description] + ); + + res.status(201).json(result.rows[0]); + } catch (error) { + next(error); + } + }); + + /** + * Update a mailing list + * PUT /api/admin/mailing-lists/:id + */ + router.put('/:id', async (req, res, next) => { + try { + const { id } = req.params; + const { name, description } = req.body; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Validate required fields + if (!name) { + return res.status(400).json({ + error: true, + message: 'List name is required' + }); + } + + // Check if list exists + const listCheck = await query( + 'SELECT id FROM mailing_lists WHERE id = $1', + [id] + ); + + if (listCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Mailing list not found' + }); + } + + // Update mailing list + const result = await query( + `UPDATE mailing_lists + SET name = $1, description = $2, updated_at = NOW() + WHERE id = $3 + RETURNING *`, + [name, description, id] + ); + + res.json(result.rows[0]); + } catch (error) { + next(error); + } + }); + + /** + * Delete a mailing list + * DELETE /api/admin/mailing-lists/:id + */ + router.delete('/:id', 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' + }); + } + + // Check if list exists + const listCheck = await query( + 'SELECT id FROM mailing_lists WHERE id = $1', + [id] + ); + + if (listCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Mailing list not found' + }); + } + + // Begin transaction + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Delete all subscribers from this list + await client.query( + 'DELETE FROM mailing_list_subscribers WHERE list_id = $1', + [id] + ); + + // Delete the mailing list + await client.query( + 'DELETE FROM mailing_lists WHERE id = $1', + [id] + ); + + await client.query('COMMIT'); + + res.json({ + success: true, + message: 'Mailing list deleted successfully' + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + next(error); + } + }); + + /** + * Get subscribers for a mailing list + * GET /api/admin/mailing-lists/:id/subscribers + */ + router.get('/:id/subscribers', async (req, res, next) => { + try { + const { id } = req.params; + const { page = 0, pageSize = 25, search = '', status } = req.query; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Check if list exists + const listCheck = await query( + 'SELECT id FROM mailing_lists WHERE id = $1', + [id] + ); + + if (listCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Mailing list not found' + }); + } + + // Prepare query conditions + const conditions = ['ms.list_id = $1']; + const queryParams = [id]; + let paramIndex = 2; + + if (search) { + conditions.push(`( + s.email ILIKE $${paramIndex} OR + s.first_name ILIKE $${paramIndex} OR + s.last_name ILIKE $${paramIndex} + )`); + queryParams.push(`%${search}%`); + paramIndex++; + } + + if (status && status !== 'all') { + conditions.push(`s.status = $${paramIndex}`); + queryParams.push(status); + paramIndex++; + } + + // Build WHERE clause + const whereClause = conditions.length > 0 + ? `WHERE ${conditions.join(' AND ')}` + : ''; + + // Get total count + const countQuery = ` + SELECT COUNT(*) as total + FROM mailing_list_subscribers ms + JOIN subscribers s ON ms.subscriber_id = s.id + ${whereClause} + `; + + const countResult = await query(countQuery, queryParams); + const totalCount = parseInt(countResult.rows[0].total, 10); + + // Get filtered count if status filter is applied + let filteredCount; + if (status && status !== 'all') { + const filteredCountQuery = ` + SELECT COUNT(*) as filtered + FROM mailing_list_subscribers ms + JOIN subscribers s ON ms.subscriber_id = s.id + WHERE ms.list_id = $1 AND s.status = $2 + `; + const filteredCountResult = await query(filteredCountQuery, [id, status]); + filteredCount = parseInt(filteredCountResult.rows[0].filtered, 10); + } + + // Get subscribers with pagination + const offset = page * pageSize; + const subscribersQuery = ` + SELECT + s.id, + s.email, + s.first_name, + s.last_name, + s.status, + ms.subscribed_at, + s.last_activity_at + FROM mailing_list_subscribers ms + JOIN subscribers s ON ms.subscriber_id = s.id + ${whereClause} + ORDER BY s.email + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + queryParams.push(parseInt(pageSize, 10), offset); + const subscribersResult = await query(subscribersQuery, queryParams); + + res.json({ + subscribers: subscribersResult.rows, + totalCount, + filteredCount, + page: parseInt(page, 10), + pageSize: parseInt(pageSize, 10) + }); + } catch (error) { + next(error); + } + }); + + /** + * Add a subscriber to a mailing list + * POST /api/admin/mailing-lists/:id/subscribers + */ + router.post('/:id/subscribers', async (req, res, next) => { + try { + const { id } = req.params; + const { email, firstName, lastName, status = 'active' } = req.body; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Validate required fields + if (!email) { + return res.status(400).json({ + error: true, + message: 'Email is required' + }); + } + + // Check if list exists + const listCheck = await query( + 'SELECT id FROM mailing_lists WHERE id = $1', + [id] + ); + + if (listCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Mailing list not found' + }); + } + + // Begin transaction + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Check if subscriber already exists + let subscriberId; + const subscriberCheck = await client.query( + 'SELECT id FROM subscribers WHERE email = $1', + [email] + ); + + if (subscriberCheck.rows.length > 0) { + // Update existing subscriber + subscriberId = subscriberCheck.rows[0].id; + await client.query( + `UPDATE subscribers + SET first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + status = $3, + updated_at = NOW() + WHERE id = $4`, + [firstName, lastName, status, subscriberId] + ); + } else { + // Create new subscriber + subscriberId = uuidv4(); + await client.query( + `INSERT INTO subscribers (id, email, first_name, last_name, status) + VALUES ($1, $2, $3, $4, $5)`, + [subscriberId, email, firstName, lastName, status] + ); + } + + // Check if subscriber is already on this list + const listSubscriberCheck = await client.query( + 'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2', + [id, subscriberId] + ); + + if (listSubscriberCheck.rows.length === 0) { + // Add subscriber to list + await client.query( + `INSERT INTO mailing_list_subscribers (list_id, subscriber_id) + VALUES ($1, $2)`, + [id, subscriberId] + ); + } + + await client.query('COMMIT'); + + res.status(201).json({ + success: true, + message: 'Subscriber added to list', + subscriberId, + listId: id + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + next(error); + } + }); + + /** + * Import subscribers to a mailing list from CSV + * POST /api/admin/mailing-lists/import + */ + router.post('/import', upload.single('file'), async (req, res, next) => { + try { + const { listId } = req.body; + const file = req.file; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + if (!listId) { + return res.status(400).json({ + error: true, + message: 'List ID is required' + }); + } + + if (!file) { + return res.status(400).json({ + error: true, + message: 'CSV file is required' + }); + } + + // Check if list exists + const listCheck = await query( + 'SELECT id FROM mailing_lists WHERE id = $1', + [listId] + ); + + if (listCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Mailing list not found' + }); + } + + // Process the CSV file + const results = []; + const processFile = () => { + return new Promise((resolve, reject) => { + fs.createReadStream(file.path) + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', () => { + // Clean up temp file + fs.unlink(file.path, (err) => { + if (err) console.error('Error deleting temp file:', err); + }); + resolve(results); + }) + .on('error', reject); + }); + }; + + const subscribers = await processFile(); + + // Validate and import subscribers + if (subscribers.length === 0) { + return res.status(400).json({ + error: true, + message: 'CSV file is empty or has invalid format' + }); + } + + // Begin transaction + const client = await pool.connect(); + let importedCount = 0; + let errorCount = 0; + + try { + await client.query('BEGIN'); + + for (const data of subscribers) { + try { + // Validate email + const email = data.email; + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errorCount++; + continue; + } + + // Check if subscriber already exists + let subscriberId; + const subscriberCheck = await client.query( + 'SELECT id FROM subscribers WHERE email = $1', + [email] + ); + + if (subscriberCheck.rows.length > 0) { + // Update existing subscriber + subscriberId = subscriberCheck.rows[0].id; + await client.query( + `UPDATE subscribers + SET first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + status = COALESCE($3, status), + updated_at = NOW() + WHERE id = $4`, + [data.first_name, data.last_name, data.status || 'active', subscriberId] + ); + } else { + // Create new subscriber + subscriberId = uuidv4(); + await client.query( + `INSERT INTO subscribers (id, email, first_name, last_name, status) + VALUES ($1, $2, $3, $4, $5)`, + [subscriberId, email, data.first_name || null, data.last_name || null, data.status || 'active'] + ); + } + + // Check if subscriber is already on this list + const listSubscriberCheck = await client.query( + 'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2', + [listId, subscriberId] + ); + + if (listSubscriberCheck.rows.length === 0) { + // Add subscriber to list + await client.query( + `INSERT INTO mailing_list_subscribers (list_id, subscriber_id) + VALUES ($1, $2)`, + [listId, subscriberId] + ); + } + + importedCount++; + } catch (err) { + console.error('Error importing subscriber:', err); + errorCount++; + } + } + + await client.query('COMMIT'); + + res.status(200).json({ + success: true, + message: `Import completed with ${importedCount} subscribers added and ${errorCount} errors`, + importedCount, + errorCount, + listId + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + // Clean up temp file on error + if (req.file) { + fs.unlink(req.file.path, (err) => { + if (err) console.error('Error deleting temp file:', err); + }); + } + next(error); + } + }); + + /** + * Export subscribers from a mailing list as CSV + * GET /api/admin/mailing-lists/:id/export + */ + router.get('/:id/export', 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' + }); + } + + // Check if list exists + const listCheck = await query( + 'SELECT name FROM mailing_lists WHERE id = $1', + [id] + ); + + if (listCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Mailing list not found' + }); + } + + const listName = listCheck.rows[0].name; + + // Get all subscribers for this list + const subscribersQuery = ` + SELECT + s.email, + s.first_name, + s.last_name, + s.status, + ms.subscribed_at, + s.last_activity_at + FROM mailing_list_subscribers ms + JOIN subscribers s ON ms.subscriber_id = s.id + WHERE ms.list_id = $1 + ORDER BY s.email + `; + + const subscribersResult = await query(subscribersQuery, [id]); + const subscribers = subscribersResult.rows; + + // Create a temp file for CSV + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.csv`; + const filePath = path.join(__dirname, '../uploads/temp', fileName); + + // Create CSV writer + const csvWriter = createObjectCsvWriter({ + path: filePath, + header: [ + { id: 'email', title: 'Email' }, + { id: 'first_name', title: 'First Name' }, + { id: 'last_name', title: 'Last Name' }, + { id: 'status', title: 'Status' }, + { id: 'subscribed_at', title: 'Subscribed At' }, + { id: 'last_activity_at', title: 'Last Activity' } + ] + }); + + // Format dates in the data + const formattedSubscribers = subscribers.map(sub => ({ + ...sub, + subscribed_at: sub.subscribed_at ? new Date(sub.subscribed_at).toISOString() : '', + last_activity_at: sub.last_activity_at ? new Date(sub.last_activity_at).toISOString() : '' + })); + + // Write to CSV + await csvWriter.writeRecords(formattedSubscribers); + + // Send file and delete after sending + res.download(filePath, fileName, (err) => { + // Delete the temp file after sending + fs.unlink(filePath, (unlinkErr) => { + if (unlinkErr) console.error('Error deleting temp file:', unlinkErr); + }); + + if (err) { + console.error('Error sending file:', err); + if (!res.headersSent) { + res.status(500).json({ + error: true, + message: 'Error sending file' + }); + } + } + }); + } catch (error) { + next(error); + } + }); + + return router; +}; \ No newline at end of file diff --git a/backend/src/routes/subscribersAdmin.js b/backend/src/routes/subscribersAdmin.js new file mode 100644 index 0000000..2c2b110 --- /dev/null +++ b/backend/src/routes/subscribersAdmin.js @@ -0,0 +1,579 @@ +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const router = express.Router(); +const emailService = require('../services/emailService'); + +module.exports = (pool, query, authMiddleware) => { + + /** + * Public route to handle subscription + * POST /api/subscribers/subscribe + */ + router.post('/subscribe', async (req, res, next) => { + try { + const { email, firstName, lastName, listId } = req.body; + + // Validate required fields + if (!email || !listId) { + return res.status(400).json({ + error: true, + message: 'Email and list ID are required' + }); + } + + // Check if list exists + const listCheck = await query( + 'SELECT id FROM mailing_lists WHERE id = $1', + [listId] + ); + + if (listCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Mailing list not found' + }); + } + + // Begin transaction + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Check if subscriber already exists + let subscriberId; + const subscriberCheck = await client.query( + 'SELECT id, status FROM subscribers WHERE email = $1', + [email] + ); + + if (subscriberCheck.rows.length > 0) { + // Update existing subscriber + subscriberId = subscriberCheck.rows[0].id; + const currentStatus = subscriberCheck.rows[0].status; + + // Only update if not already unsubscribed or complained + if (currentStatus !== 'unsubscribed' && currentStatus !== 'complained') { + await client.query( + `UPDATE subscribers + SET first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + status = 'active', + updated_at = NOW() + WHERE id = $3`, + [firstName, lastName, subscriberId] + ); + } else { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: true, + message: 'This email has previously unsubscribed and cannot be resubscribed without consent' + }); + } + } else { + // Create new subscriber + subscriberId = uuidv4(); + await client.query( + `INSERT INTO subscribers (id, email, first_name, last_name) + VALUES ($1, $2, $3, $4)`, + [subscriberId, email, firstName, lastName] + ); + } + + // Check if subscriber is already on this list + const listSubscriberCheck = await client.query( + 'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2', + [listId, subscriberId] + ); + + if (listSubscriberCheck.rows.length === 0) { + // Add subscriber to list + await client.query( + `INSERT INTO mailing_list_subscribers (list_id, subscriber_id) + VALUES ($1, $2)`, + [listId, subscriberId] + ); + } + + await client.query('COMMIT'); + + // Generate a confirmation token for double opt-in + const confirmationToken = uuidv4(); + + // Store the confirmation token + await query( + `INSERT INTO subscription_confirmations (subscriber_id, token, expires_at) + VALUES ($1, $2, NOW() + INTERVAL '7 days')`, + [subscriberId, confirmationToken] + ); + + // Send confirmation email + try { + await emailService.sendSubscriptionConfirmation({ + to: email, + firstName: firstName || '', + confirmationLink: `${process.env.SITE_URL}/confirm-subscription?token=${confirmationToken}` + }); + } catch (emailError) { + console.error('Failed to send confirmation email:', emailError); + } + + res.status(200).json({ + success: true, + message: 'Subscription request received. Please check your email to confirm your subscription.' + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + next(error); + } + }); + + /** + * Confirm subscription (double opt-in) + * GET /api/subscribers/confirm + */ + router.get('/confirm', async (req, res, next) => { + try { + const { token } = req.query; + + if (!token) { + return res.status(400).json({ + error: true, + message: 'Confirmation token is required' + }); + } + + // Check if token exists and is valid + const confirmationQuery = ` + SELECT + sc.subscriber_id, + s.email, + s.first_name + FROM subscription_confirmations sc + JOIN subscribers s ON sc.subscriber_id = s.id + WHERE sc.token = $1 AND sc.expires_at > NOW() AND sc.confirmed_at IS NULL + `; + + const confirmationResult = await query(confirmationQuery, [token]); + + if (confirmationResult.rows.length === 0) { + return res.status(400).json({ + error: true, + message: 'Invalid or expired confirmation token' + }); + } + + const { subscriber_id, email, first_name } = confirmationResult.rows[0]; + + // Mark subscription as confirmed + await query( + `UPDATE subscription_confirmations + SET confirmed_at = NOW() + WHERE token = $1`, + [token] + ); + + // Ensure subscriber is marked as active + await query( + `UPDATE subscribers + SET status = 'active', updated_at = NOW() + WHERE id = $1`, + [subscriber_id] + ); + + // Send welcome email + try { + await emailService.sendWelcomeEmail({ + to: email, + firstName: first_name || '' + }); + } catch (emailError) { + console.error('Failed to send welcome email:', emailError); + } + + res.status(200).json({ + success: true, + message: 'Your subscription has been confirmed. Thank you!' + }); + } catch (error) { + next(error); + } + }); + + /** + * Handle unsubscribe request + * GET /api/subscribers/unsubscribe + */ + router.get('/unsubscribe', async (req, res, next) => { + try { + const { email, token, listId } = req.query; + + if (!email && !token) { + return res.status(400).json({ + error: true, + message: 'Email or token is required' + }); + } + + // Begin transaction + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + let subscriberId; + + if (token) { + // Verify the unsubscribe token + const tokenCheck = await client.query( + 'SELECT subscriber_id FROM unsubscribe_tokens WHERE token = $1 AND expires_at > NOW()', + [token] + ); + + if (tokenCheck.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: true, + message: 'Invalid or expired unsubscribe token' + }); + } + + subscriberId = tokenCheck.rows[0].subscriber_id; + } else { + // Look up subscriber by email + const subscriberCheck = await client.query( + 'SELECT id FROM subscribers WHERE email = $1', + [email] + ); + + if (subscriberCheck.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ + error: true, + message: 'Email not found in our database' + }); + } + + subscriberId = subscriberCheck.rows[0].id; + } + + // Update subscriber status + await client.query( + `UPDATE subscribers + SET status = 'unsubscribed', updated_at = NOW() + WHERE id = $1`, + [subscriberId] + ); + + // If list ID is provided, only remove from that list + if (listId) { + await client.query( + 'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1 AND list_id = $2', + [subscriberId, listId] + ); + } + // Otherwise remove from all lists + else { + await client.query( + 'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1', + [subscriberId] + ); + } + + // Record unsubscribe activity + await client.query( + `INSERT INTO subscriber_activity (subscriber_id, type, details) + VALUES ($1, 'unsubscribe', $2)`, + [subscriberId, listId ? `Unsubscribed from list ${listId}` : 'Unsubscribed from all lists'] + ); + + await client.query('COMMIT'); + + res.status(200).json({ + success: true, + message: 'You have been successfully unsubscribed' + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + next(error); + } + }); + + /** + * Public route to handle bounce and complaint notifications + * POST /api/subscribers/webhook + */ + router.post('/webhook', async (req, res, next) => { + try { + const { type, email, message, campaignId, reason, timestamp } = req.body; + + // Validate required fields + if (!type || !email) { + return res.status(400).json({ + error: true, + message: 'Type and email are required' + }); + } + + // Verify webhook signature + // In production, implement proper verification of webhook authenticity + + // Find subscriber by email + const subscriberCheck = await query( + 'SELECT id FROM subscribers WHERE email = $1', + [email] + ); + + if (subscriberCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Subscriber not found' + }); + } + + const subscriberId = subscriberCheck.rows[0].id; + + // Handle event based on type + switch (type) { + case 'bounce': + // Update subscriber status + await query( + `UPDATE subscribers + SET status = 'bounced', updated_at = NOW() + WHERE id = $1`, + [subscriberId] + ); + + // Record bounce activity + await query( + `INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, timestamp, details) + VALUES ($1, $2, 'bounce', $3, $4)`, + [subscriberId, campaignId, timestamp || new Date(), reason || message || 'Email bounced'] + ); + break; + + case 'complaint': + // Update subscriber status + await query( + `UPDATE subscribers + SET status = 'complained', updated_at = NOW() + WHERE id = $1`, + [subscriberId] + ); + + // Remove from all lists + await query( + 'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1', + [subscriberId] + ); + + // Record complaint activity + await query( + `INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, timestamp, details) + VALUES ($1, $2, 'complaint', $3, $4)`, + [subscriberId, campaignId, timestamp || new Date(), reason || message || 'Spam complaint received'] + ); + break; + + default: + return res.status(400).json({ + error: true, + message: 'Unsupported event type' + }); + } + + res.json({ + success: true, + message: `Processed ${type} for ${email}` + }); + } catch (error) { + next(error); + } + }); + + + router.use(authMiddleware); + + /** + * Update a subscriber (admin) + * PUT /api/subscribers/:id + */ + router.put('/:id', async (req, res, next) => { + try { + const { id } = req.params; + const { email, firstName, lastName, status } = req.body; + + if (!req.user.is_admin) { + return res.status(403).json({ + error: true, + message: 'Admin access required' + }); + } + + // Validate required fields + if (!email) { + return res.status(400).json({ + error: true, + message: 'Email is required' + }); + } + + // Check if subscriber exists + const subscriberCheck = await query( + 'SELECT id FROM subscribers WHERE id = $1', + [id] + ); + + if (subscriberCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Subscriber not found' + }); + } + + // Update subscriber + const result = await query( + `UPDATE subscribers + SET email = $1, + first_name = $2, + last_name = $3, + status = $4, + updated_at = NOW() + WHERE id = $5 + RETURNING *`, + [email, firstName, lastName, status, id] + ); + + res.json(result.rows[0]); + } catch (error) { + next(error); + } + }); + + /** + * Delete a subscriber (admin) + * DELETE /api/subscribers/:id + */ + router.delete('/:id', 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' + }); + } + + // Check if subscriber exists + const subscriberCheck = await query( + 'SELECT id FROM subscribers WHERE id = $1', + [id] + ); + + if (subscriberCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Subscriber not found' + }); + } + + // Begin transaction + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Remove from all mailing lists + await client.query( + 'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1', + [id] + ); + + // Delete subscriber + await client.query( + 'DELETE FROM subscribers WHERE id = $1', + [id] + ); + + await client.query('COMMIT'); + + res.json({ + success: true, + message: 'Subscriber deleted successfully' + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + next(error); + } + }); + + /** + * Get subscriber activity (admin) + * GET /api/subscribers/:id/activity + */ + router.get('/:id/activity', 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' + }); + } + + // Check if subscriber exists + const subscriberCheck = await query( + 'SELECT id FROM subscribers WHERE id = $1', + [id] + ); + + if (subscriberCheck.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Subscriber not found' + }); + } + + // Get subscriber activity + const activityQuery = ` + SELECT + sa.id, + sa.campaign_id, + ec.name as campaign_name, + sa.type, + sa.timestamp, + sa.details + FROM subscriber_activity sa + LEFT JOIN email_campaigns ec ON sa.campaign_id = ec.id + WHERE sa.subscriber_id = $1 + ORDER BY sa.timestamp DESC + `; + + const activityResult = await query(activityQuery, [id]); + + res.json(activityResult.rows); + } catch (error) { + next(error); + } + }); + + + return router; +}; \ No newline at end of file diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js index 559e471..c5d972a 100644 --- a/backend/src/services/emailService.js +++ b/backend/src/services/emailService.js @@ -1,6 +1,7 @@ const nodemailer = require('nodemailer'); const config = require('../config'); const { query, pool } = require('../db'); +const { v4: uuidv4 } = require('uuid'); /** * Service for sending emails with templates @@ -20,6 +21,8 @@ const emailService = { } }); }, + defaultFrom: config.email.reply, + siteUrl: `${config.site.protocol}://${config.site.apiDomain}`, /** * Get a template by type, preferring the default one @@ -138,6 +141,31 @@ const emailService = { throw error; } }, + /** + * Send an email + * @param {Object} options - Email options for nodemailer + * @returns {Promise} - Promise with the send result + */ + async sendEmail(options) { + try { + // Send email using nodemailer + const transporter = this.createTransporter(); + const result = await transporter.sendMail({ + from: options.from || this.defaultFrom, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text, + attachments: options.attachments, + headers: options.headers + }); + + return result; + } catch (error) { + console.error('Error sending email:', error); + throw error; + } + }, /** * Send a login code email @@ -204,6 +232,52 @@ const emailService = { }); }, + /** + * Send a test email for campaign preview + * @param {Object} options - Email options + * @param {string} options.to - Recipient email + * @param {string} options.subject - Email subject + * @param {string} options.preheader - Email preheader + * @param {string} options.from - From address + * @param {string} options.content - HTML content + * @returns {Promise} - Promise with the send result + */ + async sendTestEmail({ to, subject, preheader, from, content }) { + try { + // Add preheader if provided + let htmlContent = content; + if (preheader) { + htmlContent = ` +
+ ${preheader} +
+ ${content} + `; + } + + // Add test label at top + htmlContent = ` +
+ TEST EMAIL - This is a test preview of your campaign. +
+ ${htmlContent} + `; + + // Send the test email + return await this.sendEmail({ + to, + subject, + html: htmlContent, + from: from || this.defaultFrom, + headers: { + 'X-Test-Email': 'true' + } + }); + } catch (error) { + console.error('Error sending test email:', error); + throw error; + } + }, /** * Send a low stock alert email * @param {Object} options - Options @@ -238,6 +312,264 @@ const emailService = { } }); }, + /** + * Send a subscription confirmation email + * @param {Object} options - Email options + * @param {string} options.to - Recipient email + * @param {string} options.firstName - Recipient's first name + * @param {string} options.confirmationLink - Confirmation link + * @returns {Promise} - Promise with the send result + */ + async sendSubscriptionConfirmation({ to, firstName, confirmationLink }) { + try { + // Get the email template + const template = await this.getEmailTemplate('subscription_confirmation'); + + if (!template) { + // Fallback to a basic template if none is found + return await this.sendEmail({ + to, + subject: 'Please confirm your subscription', + html: ` +

Confirm Your Subscription

+

Hello ${firstName || 'there'},

+

Thank you for subscribing to our mailing list. Please click the link below to confirm your subscription:

+

Confirm Subscription

+

If you did not request this subscription, you can ignore this email.

+ `, + from: this.defaultFrom + }); + } + + // Replace placeholders in the template + let content = template.content; + let subject = template.subject; + + content = content + .replace(/{{first_name}}/g, firstName || 'there') + .replace(/{{confirmation_link}}/g, confirmationLink); + + subject = subject + .replace(/{{first_name}}/g, firstName || 'there'); + + // Send email + return await this.sendEmail({ + to, + subject, + html: content, + from: template.from_name ? `${template.from_name} <${this.defaultFrom}>` : this.defaultFrom + }); + } catch (error) { + console.error('Error sending subscription confirmation email:', error); + throw error; + } + }, + /** + * Send an email to a campaign subscriber + * @param {Object} options - Email options + * @param {string} options.to - Recipient email + * @param {string} options.subject - Email subject + * @param {string} options.preheader - Email preheader + * @param {string} options.from - From address + * @param {string} options.content - HTML content + * @param {string} options.campaignId - Campaign ID + * @param {string} options.subscriberId - Subscriber ID + * @returns {Promise} - Promise with the send result + */ + async sendCampaignEmail({ to, subject, preheader, from, content, campaignId, subscriberId }) { + try { + // Generate tracking pixel and add unsubscribe link + const trackingPixel = this.generateTrackingPixel(campaignId, subscriberId); + const unsubscribeLink = this.generateUnsubscribeLink(subscriberId, campaignId); + + // Add preheader if provided + let htmlContent = content; + if (preheader) { + htmlContent = ` +
+ ${preheader} +
+ ${htmlContent} + `; + } + + // Add tracking pixel and unsubscribe footer + htmlContent = ` + ${htmlContent} +
+

If you no longer wish to receive these emails, you can unsubscribe here.

+

Sent by ${config.site.domain}

+
+ ${trackingPixel} + `; + + // Process links to add tracking + htmlContent = await this.processLinks(htmlContent, campaignId, subscriberId); + + // Send the campaign email + return await this.sendEmail({ + to, + subject, + html: htmlContent, + from: from || this.defaultFrom, + headers: { + 'X-Campaign-ID': campaignId, + 'List-Unsubscribe': `<${unsubscribeLink}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' + } + }); + } catch (error) { + console.error('Error sending campaign email:', error); + throw error; + } + }, + + /** + * Generate a tracking pixel for email opens + * @param {string} campaignId - Campaign ID + * @param {string} subscriberId - Subscriber ID + * @returns {string} - HTML for the tracking pixel + */ + generateTrackingPixel(campaignId, subscriberId) { + const trackingUrl = `${this.siteUrl}/api/email/track?c=${campaignId}&s=${subscriberId}&t=open`; + return ``; + }, + + /** + * Generate an unsubscribe link + * @param {string} subscriberId - Subscriber ID + * @param {string} campaignId - Campaign ID + * @returns {string} - Unsubscribe URL + */ + generateUnsubscribeLink(subscriberId, campaignId) { + // 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}`; + }, + + /** + * Store an unsubscribe token in the database + * @param {string} subscriberId - Subscriber ID + * @param {string} token - Unsubscribe token + * @param {string} campaignId - Campaign ID + */ + async storeUnsubscribeToken(subscriberId, token, campaignId) { + try { + // Set token to expire in 60 days + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 60); + + await query( + `INSERT INTO unsubscribe_tokens (subscriber_id, token, campaign_id, expires_at) + VALUES ($1, $2, $3, $4)`, + [subscriberId, token, campaignId, expiresAt] + ); + } catch (error) { + console.error('Error storing unsubscribe token:', error); + } + }, + + /** + * Process HTML content to add tracking to links + * @param {string} content - HTML content + * @param {string} campaignId - Campaign ID + * @param {string} subscriberId - Subscriber ID + * @returns {string} - HTML with tracked links + */ + async processLinks(content, campaignId, subscriberId) { + try { + // Basic regex to find links - in a production environment, use a proper HTML parser + const linkRegex = /]+href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi; + let match; + let processedContent = content; + + // Store links found in this content + const links = []; + + // First pass: collect all links + while ((match = linkRegex.exec(content)) !== null) { + const url = match[1]; + const text = match[2]; + + // Skip tracking for unsubscribe links and mailto links + if (url.includes('/unsubscribe') || url.startsWith('mailto:')) { + continue; + } + + // Store the link info + links.push({ url, text }); + } + + // Store links in the database and get their IDs + const linkIds = await this.storeLinks(links, campaignId); + + // Second pass: replace links with tracked versions + let index = 0; + processedContent = content.replace(linkRegex, (match, url, text) => { + // Skip tracking for unsubscribe links and mailto links + if (url.includes('/unsubscribe') || url.startsWith('mailto:')) { + return match; + } + + const linkId = linkIds[index++]; + if (!linkId) return match; // Skip if no linkId (shouldn't happen) + + // Create tracking URL + const trackingUrl = `${this.siteUrl}/api/email/track?c=${campaignId}&s=${subscriberId}&l=${linkId}&t=click&u=${encodeURIComponent(url)}`; + + // Replace the original URL with the tracking URL + return match.replace(url, trackingUrl); + }); + + return processedContent; + } catch (error) { + console.error('Error processing links:', error); + return content; // Return original content on error + } + }, + + /** + * Store links in the database + * @param {Array} links - Array of link objects { url, text } + * @param {string} campaignId - Campaign ID + * @returns {Array} - Array of link IDs + */ + async storeLinks(links, campaignId) { + try { + const linkIds = []; + + for (const link of links) { + // Check if this link already exists for this campaign + const existingLinkCheck = await query( + 'SELECT id FROM campaign_links WHERE campaign_id = $1 AND url = $2', + [campaignId, link.url] + ); + + if (existingLinkCheck.rows.length > 0) { + // Link already exists, use its ID + linkIds.push(existingLinkCheck.rows[0].id); + } else { + // Link doesn't exist, create a new one + const linkId = uuidv4(); + await query( + `INSERT INTO campaign_links (id, campaign_id, url, text) + VALUES ($1, $2, $3, $4)`, + [linkId, campaignId, link.url, link.text] + ); + linkIds.push(linkId); + } + } + + return linkIds; + } catch (error) { + console.error('Error storing links:', error); + return []; + } + }, /** * Log an email in the database diff --git a/db/init/20-maillinglist.sql b/db/init/20-maillinglist.sql new file mode 100644 index 0000000..7d73259 --- /dev/null +++ b/db/init/20-maillinglist.sql @@ -0,0 +1,133 @@ +-- Database schema for mailing list and email campaign management + +-- Mailing Lists +CREATE TABLE IF NOT EXISTS mailing_lists ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Subscribers +CREATE TABLE IF NOT EXISTS subscribers ( + id UUID PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + first_name VARCHAR(255), + last_name VARCHAR(255), + status VARCHAR(50) DEFAULT 'active', -- active, unsubscribed, bounced, complained + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_activity_at TIMESTAMP +); + +-- Mailing List Subscribers (many-to-many) +CREATE TABLE IF NOT EXISTS mailing_list_subscribers ( + list_id UUID REFERENCES mailing_lists(id) ON DELETE CASCADE, + subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE, + subscribed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (list_id, subscriber_id) +); + +-- Email Campaigns +CREATE TABLE IF NOT EXISTS email_campaigns ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + subject VARCHAR(255) NOT NULL, + preheader VARCHAR(255), + from_name VARCHAR(255), + from_email VARCHAR(255) NOT NULL, + content TEXT, + design TEXT, -- JSON storage for the email editor design + list_ids UUID[] NOT NULL, -- Array of list IDs to send to + status VARCHAR(50) DEFAULT 'draft', -- draft, scheduled, sending, sent, archived + created_by UUID REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + scheduled_for TIMESTAMP, + sent_at TIMESTAMP +); + +-- Campaign Recipients +CREATE TABLE IF NOT EXISTS campaign_recipients ( + campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE, + subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (campaign_id, subscriber_id) +); + +-- Campaign Links (for click tracking) +CREATE TABLE IF NOT EXISTS campaign_links ( + id UUID PRIMARY KEY, + campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE, + url TEXT NOT NULL, + text VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Subscriber Activity +CREATE TABLE IF NOT EXISTS subscriber_activity ( + id SERIAL PRIMARY KEY, + subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE, + campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL, + link_id UUID REFERENCES campaign_links(id) ON DELETE SET NULL, + type VARCHAR(50) NOT NULL, -- open, click, bounce, complaint, unsubscribe + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + url TEXT, -- For click events + details TEXT, -- Additional information + bounce_type VARCHAR(50), -- hard, soft + CONSTRAINT valid_activity_type CHECK ( + type IN ('open', 'click', 'bounce', 'complaint', 'unsubscribe', 'sent', 'error') + ) +); + +-- Subscription Confirmations (double opt-in) +CREATE TABLE IF NOT EXISTS subscription_confirmations ( + id SERIAL PRIMARY KEY, + subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + confirmed_at TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +-- Unsubscribe Tokens +CREATE TABLE IF NOT EXISTS unsubscribe_tokens ( + id SERIAL PRIMARY KEY, + subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE, + campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL, + token VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + used_at TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +-- Email Logs +ALTER TABLE email_logs + ADD COLUMN IF NOT EXISTS message_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS subscriber_id UUID REFERENCES subscribers(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS error_message TEXT; +-- Create indexes for performance +CREATE INDEX idx_subscribers_email ON subscribers(email); +CREATE INDEX idx_subscribers_status ON subscribers(status); +CREATE INDEX idx_mailing_list_subscribers_list_id ON mailing_list_subscribers(list_id); +CREATE INDEX idx_mailing_list_subscribers_subscriber_id ON mailing_list_subscribers(subscriber_id); +CREATE INDEX idx_email_campaigns_status ON email_campaigns(status); +CREATE INDEX idx_email_campaigns_scheduled_for ON email_campaigns(scheduled_for); +CREATE INDEX idx_campaign_recipients_campaign_id ON campaign_recipients(campaign_id); +CREATE INDEX idx_campaign_recipients_subscriber_id ON campaign_recipients(subscriber_id); +CREATE INDEX idx_campaign_links_campaign_id ON campaign_links(campaign_id); +CREATE INDEX idx_subscriber_activity_subscriber_id ON subscriber_activity(subscriber_id); +CREATE INDEX idx_subscriber_activity_campaign_id ON subscriber_activity(campaign_id); +CREATE INDEX idx_subscriber_activity_type ON subscriber_activity(type); +CREATE INDEX idx_subscriber_activity_timestamp ON subscriber_activity(timestamp); +CREATE INDEX idx_subscription_confirmations_token ON subscription_confirmations(token); +CREATE INDEX idx_unsubscribe_tokens_token ON unsubscribe_tokens(token); +CREATE INDEX idx_email_logs_recipient ON email_logs(recipient); +CREATE INDEX idx_email_logs_campaign_id ON email_logs(campaign_id); +CREATE INDEX idx_email_logs_status ON email_logs(status); + + +INSERT INTO mailing_lists (id, name, description) VALUES + ('1db91b9b-b1f9-4892-80b5-51437d8b6045', 'Default Mailing List', 'This is the default mailing list that new users who accept are attached to, do not delete, feel free to rename'); diff --git a/fileStructure.txt b/fileStructure.txt index 64f859a..36f478e 100644 --- a/fileStructure.txt +++ b/fileStructure.txt @@ -1,10 +1,10 @@ -Rock/ -├── git/ +project/ ├── frontend/ │ ├── node_modules/ │ ├── src/ │ │ ├── pages/ │ │ │ ├── Admin/ +│ │ │ │ ├── BrandingPage.jsx │ │ │ │ ├── ReportsPage.jsx │ │ │ │ ├── ProductEditPage.jsx │ │ │ │ ├── OrdersPage.jsx @@ -42,6 +42,7 @@ Rock/ │ │ │ ├── couponService.js │ │ │ ├── productService.js │ │ │ ├── productReviewService.js +│ │ │ ├── consentService.js (NEW) │ │ │ ├── settingsAdminService.js │ │ │ ├── imageService.js │ │ │ ├── cartService.js @@ -53,10 +54,12 @@ Rock/ │ │ │ ├── ProductReviews.jsx │ │ │ ├── OrderStatusDialog.jsx │ │ │ ├── ImageUploader.jsx +│ │ │ ├── CookieConsentPopup.jsx +│ │ │ ├── CookieSettingsButton.jsx +│ │ │ ├── Footer.jsx │ │ │ ├── CouponInput.jsx │ │ │ ├── EmailDialog.jsx │ │ │ ├── StripePaymentForm.jsx -│ │ │ ├── Footer.jsx │ │ │ ├── ProductImage.jsx │ │ │ ├── ProtectedRoute.jsx │ │ │ ├── Notifications.jsx @@ -70,10 +73,11 @@ Rock/ │ │ │ ├── reduxHooks.js │ │ │ ├── couponAdminHooks.js │ │ │ ├── settingsAdminHooks.js -│ │ │ └── categoryAdminHooks.js +│ │ │ ├── categoryAdminHooks.js +│ │ │ └── brandingHooks.js │ │ ├── layouts/ -│ │ │ ├── AdminLayout.jsx │ │ │ ├── MainLayout.jsx +│ │ │ ├── AdminLayout.jsx │ │ │ └── AuthLayout.jsx │ │ ├── features/ │ │ │ ├── ui/ @@ -83,29 +87,27 @@ Rock/ │ │ │ ├── auth/ │ │ │ │ └── authSlice.js │ │ │ └── theme/ -│ │ │ ├── index.js -│ │ │ └── ThemeProvider.jsx +│ │ │ ├── ThemeProvider.jsx +│ │ │ └── index.js │ │ ├── utils/ │ │ │ └── imageUtils.js │ │ ├── store/ │ │ │ └── index.js │ │ ├── context/ │ │ │ └── StripeContext.jsx -│ │ ├── assets/ -│ │ │ ├── App.jsx -│ │ │ ├── main.jsx -│ │ │ └── config.js -│ │ └── public/ -│ │ ├── favicon.svg -│ │ ├── package-lock.json -│ │ ├── vite.config.js -│ │ ├── README.md -│ │ ├── package.json -│ │ ├── Dockerfile -│ │ ├── nginx.conf -│ │ ├── setup-frontend.sh -│ │ ├── index.html -│ │ └── .env +│ │ └── assets/ +│ │ ├── App.jsx +│ │ ├── main.jsx +│ │ └── config.js +│ └── public/ +│ ├── favicon.svg +│ ├── vite.config.js +│ ├── README.md +│ ├── Dockerfile +│ ├── nginx.conf +│ ├── setup-frontend.sh +│ ├── index.html +│ └── .env ├── backend/ │ ├── node_modules/ │ ├── src/ @@ -113,8 +115,8 @@ Rock/ │ │ │ ├── cart.js │ │ │ ├── couponAdmin.js │ │ │ ├── emailTemplatesAdmin.js -│ │ │ ├── blogAdmin.js │ │ │ ├── productAdmin.js +│ │ │ ├── blogAdmin.js │ │ │ ├── orderAdmin.js │ │ │ ├── settingsAdmin.js │ │ │ ├── blog.js @@ -129,6 +131,7 @@ Rock/ │ │ │ ├── shipping.js │ │ │ ├── images.js │ │ │ ├── userOrders.js +│ │ │ ├── publicSettings.js │ │ │ └── productAdminImages.js │ │ ├── middleware/ │ │ │ ├── upload.js @@ -162,6 +165,7 @@ Rock/ │ ├── 16-blog-schema.sql │ ├── 15-coupon.sql │ ├── 17-product-reviews.sql + │ ├── 19-branding-settings.sql (NEW) │ ├── 04-product-images.sql │ ├── 09-system-settings.sql │ ├── 14-product-notifications.sql diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d2ad55f..f1fe796 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -47,6 +47,16 @@ const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPa const EmailTemplatesPage = lazy(() => import('@pages/Admin/EmailTemplatesPage')); const BrandingPage = lazy(() => import('@pages/Admin/BrandingPage')); +const EmailCampaignsPage = lazy(() => import('@pages/Admin/EmailCampaignsPage')); +const EmailCampaignEditor = lazy(() => import('@pages/Admin/EmailCampaignEditorPage')); +const CampaignSendPage = lazy(() => import('@pages/Admin/CampaignSendPage')); +const CampaignAnalyticsPage = lazy(() => import('@pages/Admin/CampaignAnalyticsPage')); +const MailingListsPage = lazy(() => import('@pages/Admin/MailingListsPage')); +const SubscribersPage = lazy(() => import('@pages/Admin/SubscribersPage')); + +const SubscriptionConfirmPage = lazy(() => import('@pages/SubscriptionConfirmPage')); +const UnsubscribePage = lazy(() => import('@pages/UnsubscribePage')); +const SubscriptionPreferencesPage = lazy(() => import('@pages/SubscriptionPreferencesPage')); const projectId = "rcjhrd0t72" @@ -131,6 +141,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> @@ -191,7 +204,15 @@ function App() { } /> } /> } /> - } /> {/* New Branding Route */} + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* Catch-all route for 404s */} diff --git a/frontend/src/components/SubscriptionForm.jsx b/frontend/src/components/SubscriptionForm.jsx new file mode 100644 index 0000000..a0539d7 --- /dev/null +++ b/frontend/src/components/SubscriptionForm.jsx @@ -0,0 +1,301 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + TextField, + Button, + FormControlLabel, + Checkbox, + Alert, + CircularProgress, + Paper, + Divider, + InputAdornment, + IconButton, + Tooltip, + Collapse +} from '@mui/material'; +import { + SendOutlined as SendIcon, + InfoOutlined as InfoIcon, + CheckCircleOutline as CheckCircleIcon, +} from '@mui/icons-material'; +import apiClient from '@services/api'; + +/** + * A reusable subscription form component for mailing lists + * + * @param {Object} props - Component props + * @param {string} props.listId - The ID of the mailing list to subscribe to + * @param {string} props.title - Form title + * @param {string} props.description - Form description + * @param {string} props.buttonText - Submit button text + * @param {string} props.successMessage - Message to show after successful submission + * @param {boolean} props.collectNames - Whether to collect first and last names + * @param {boolean} props.embedded - Whether this form is embedded in another component (styling) + * @param {function} props.onSuccess - Callback function after successful subscription + */ +const SubscriptionForm = ({ + listId, + title = "Subscribe to Our Newsletter", + description = "Stay updated with our latest news and offers.", + buttonText = "Subscribe", + successMessage = "Thank you for subscribing! Please check your email to confirm your subscription.", + collectNames = true, + embedded = false, + onSuccess = null, +}) => { + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + email: '', + }); + const [formErrors, setFormErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + const [showPrivacyInfo, setShowPrivacyInfo] = useState(false); + const [acceptedTerms, setAcceptedTerms] = useState(false); + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + + // Clear error when field is edited + if (formErrors[name]) { + setFormErrors(prev => ({ + ...prev, + [name]: '' + })); + } + }; + + const validate = () => { + const errors = {}; + + // Validate email + if (!formData.email) { + errors.email = 'Email is required'; + } else if (!/\S+@\S+\.\S+/.test(formData.email)) { + errors.email = 'Email is invalid'; + } + + // Validate names if collectNames is true + if (collectNames) { + if (!formData.firstName) { + errors.firstName = 'First name is required'; + } + } + + // Validate terms acceptance + if (!acceptedTerms) { + errors.terms = 'You must accept the terms to subscribe'; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + // Reset previous errors/success + setError(null); + + // Validate form + if (!validate()) return; + + setIsSubmitting(true); + + try { + // Call the API to subscribe + await apiClient.post('/api/subscribers/subscribe', { + email: formData.email, + firstName: formData.firstName, + lastName: formData.lastName, + listId: listId + }); + + // Show success message and reset form + setIsSuccess(true); + setFormData({ + firstName: '', + lastName: '', + email: '', + }); + setAcceptedTerms(false); + + // Call onSuccess callback if provided + if (onSuccess) onSuccess(); + } catch (err) { + setError(err.response?.data?.message || 'An error occurred. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + const containerStyles = embedded + ? {} + : { + maxWidth: 500, + mx: 'auto', + my: 4, + p: 3, + borderRadius: 2, + boxShadow: 3 + }; + + return ( + + {/* Form Header */} + + + {title} + + {description && ( + + {description} + + )} + + + {/* Success Message */} + {isSuccess && ( + } + severity="success" + sx={{ mb: 3 }} + > + {successMessage} + + )} + + {/* Error Message */} + {error && ( + + {error} + + )} + + {/* Form Fields */} + + {collectNames && ( + + + + + )} + + + + + + + + + ), + }} + /> + + {/* Privacy Info & Terms Checkbox */} + + + setAcceptedTerms(e.target.checked)} + size={embedded ? "small" : "medium"} + /> + } + label="I agree to receive emails and accept the terms" + /> + + setShowPrivacyInfo(!showPrivacyInfo)} + > + + + + + + {formErrors.terms && ( + + {formErrors.terms} + + )} + + + + + By subscribing, you agree to receive marketing emails from us. We respect your privacy and will never share your information with third parties. + + + You can unsubscribe at any time by clicking the unsubscribe link in the footer of our emails. For information about our privacy practices, visit our website. + + + + + + {/* Submit Button */} + + + + + + ); +}; + +export default SubscriptionForm; \ No newline at end of file diff --git a/frontend/src/hooks/emailCampaignHooks.js b/frontend/src/hooks/emailCampaignHooks.js new file mode 100644 index 0000000..6022cf3 --- /dev/null +++ b/frontend/src/hooks/emailCampaignHooks.js @@ -0,0 +1,640 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '@services/api'; +import { useNotification } from './reduxHooks'; + +/** + * Hook for fetching all email campaigns + * @param {string} status - Optional filter by status + */ +export const useEmailCampaigns = (status) => { + return useQuery({ + queryKey: ['email-campaigns', status], + queryFn: async () => { + const params = {}; + if (status) params.status = status; + const response = await apiClient.get('/admin/email-campaigns', { params }); + return response.data; + } + }); +}; + +/** + * Hook for fetching a single email campaign + * @param {string} id - Campaign ID + */ +export const useEmailCampaign = (id) => { + return useQuery({ + queryKey: ['email-campaign', id], + queryFn: async () => { + const response = await apiClient.get(`/admin/email-campaigns/${id}`); + return response.data; + }, + enabled: !!id + }); +}; + +/** + * Hook for creating a new email campaign + */ +export const useCreateEmailCampaign = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async (campaignData) => { + const response = await apiClient.post('/admin/email-campaigns', campaignData); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['email-campaigns'] }); + notification.showNotification('Campaign created successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to create campaign', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for updating an existing email campaign + */ +export const useUpdateEmailCampaign = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async ({ id, campaignData }) => { + const response = await apiClient.put(`/admin/email-campaigns/${id}`, campaignData); + return response.data; + }, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: ['email-campaigns'] }); + queryClient.invalidateQueries({ queryKey: ['email-campaign', variables.id] }); + notification.showNotification('Campaign updated successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to update campaign', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for deleting an email campaign + */ +export const useDeleteEmailCampaign = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async (id) => { + const response = await apiClient.delete(`/admin/email-campaigns/${id}`); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['email-campaigns'] }); + notification.showNotification('Campaign deleted successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to delete campaign', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for duplicating an email campaign + */ +export const useDuplicateEmailCampaign = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async (id) => { + const response = await apiClient.post(`/admin/email-campaigns/${id}/duplicate`); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['email-campaigns'] }); + notification.showNotification('Campaign duplicated successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to duplicate campaign', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for sending an email campaign + */ +export const useSendCampaign = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async (id) => { + const response = await apiClient.post(`/admin/email-campaigns/${id}/send`); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['email-campaigns'] }); + queryClient.invalidateQueries({ queryKey: ['email-campaign', data.id] }); + notification.showNotification('Campaign sent successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to send campaign', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for scheduling an email campaign + */ +export const useScheduleCampaign = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async ({ campaignId, scheduledDate }) => { + const response = await apiClient.post(`/admin/email-campaigns/${campaignId}/schedule`, { + scheduledDate + }); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['email-campaigns'] }); + queryClient.invalidateQueries({ queryKey: ['email-campaign', data.id] }); + notification.showNotification('Campaign scheduled successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to schedule campaign', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for sending a preview/test email + */ +export const usePreviewCampaign = () => { + const notification = useNotification(); + + return useMutation({ + mutationFn: async ({ campaignId, email }) => { + const response = await apiClient.post(`/admin/email-campaigns/${campaignId}/preview`, { + email + }); + return response.data; + }, + onSuccess: () => { + notification.showNotification('Test email sent successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to send test email', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for fetching campaign analytics + */ +export const useCampaignAnalytics = (campaignId) => { + return useQuery({ + queryKey: ['campaign-analytics', campaignId], + queryFn: async () => { + const response = await apiClient.get(`/admin/email-campaigns/${campaignId}/analytics`); + return response.data; + }, + enabled: !!campaignId + }); +}; + +/** + * Hook for fetching campaign subscriber activity + */ +export const useCampaignSubscriberActivity = ( + campaignId, + page = 0, + pageSize = 25, + searchTerm = '', + activityType = 'all' +) => { + return useQuery({ + queryKey: ['campaign-activity', campaignId, page, pageSize, searchTerm, activityType], + queryFn: async () => { + const params = { + page, + pageSize, + search: searchTerm, + type: activityType === 'all' ? undefined : activityType + }; + + const response = await apiClient.get( + `/admin/email-campaigns/${campaignId}/activity`, + { params } + ); + return response.data; + }, + enabled: !!campaignId && tabValue === 2 // Only fetch when on the activity tab + }); +}; + +/** + * Hook for exporting campaign report + */ +export const useExportCampaignReport = () => { + const notification = useNotification(); + + return useMutation({ + mutationFn: async (campaignId) => { + const response = await apiClient.get(`/admin/email-campaigns/${campaignId}/export`, { + responseType: 'blob' + }); + + // Create a download link and trigger the download + const blob = new Blob([response.data], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `campaign-report-${campaignId}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + + return true; + }, + onSuccess: () => { + notification.showNotification('Report exported successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to export report', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for fetching all mailing lists + */ +export const useMailingLists = () => { + return useQuery({ + queryKey: ['mailing-lists'], + queryFn: async () => { + const response = await apiClient.get('/admin/mailing-lists'); + return response.data; + } + }); +}; + +/** + * Hook for fetching a single mailing list + */ +export const useMailingList = (listId) => { + return useQuery({ + queryKey: ['mailing-list', listId], + queryFn: async () => { + const response = await apiClient.get(`/admin/mailing-lists/${listId}`); + return response.data; + }, + enabled: !!listId + }); +}; + +/** + * Hook for creating a new mailing list + */ +export const useCreateMailingList = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async (listData) => { + const response = await apiClient.post('/admin/mailing-lists', listData); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['mailing-lists'] }); + notification.showNotification('Mailing list created successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to create mailing list', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for updating a mailing list + */ +export const useUpdateMailingList = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async ({ id, listData }) => { + const response = await apiClient.put(`/admin/mailing-lists/${id}`, listData); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['mailing-lists'] }); + queryClient.invalidateQueries({ queryKey: ['mailing-list', data.id] }); + notification.showNotification('Mailing list updated successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to update mailing list', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for deleting a mailing list + */ +export const useDeleteMailingList = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async (id) => { + const response = await apiClient.delete(`/admin/mailing-lists/${id}`); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mailing-lists'] }); + notification.showNotification('Mailing list deleted successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to delete mailing list', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for importing subscribers to a list + */ +export const useImportSubscribers = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async (formData) => { + const response = await apiClient.post('/admin/mailing-lists/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['mailing-lists'] }); + queryClient.invalidateQueries({ queryKey: ['mailing-list', data.listId] }); + queryClient.invalidateQueries({ queryKey: ['subscribers', data.listId] }); + notification.showNotification(`${data.importedCount} subscribers imported successfully`, 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to import subscribers', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for exporting subscribers from a list + */ +export const useExportSubscribers = () => { + const notification = useNotification(); + + return useMutation({ + mutationFn: async (listId) => { + const response = await apiClient.get(`/admin/mailing-lists/${listId}/export`, { + responseType: 'blob' + }); + + // Create a download link and trigger the download + const blob = new Blob([response.data], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `subscribers-${listId}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + + return true; + }, + onSuccess: () => { + notification.showNotification('Subscribers exported successfully', 'success'); + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to export subscribers', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for fetching list subscribers + */ +export const useSubscribers = ( + listId, + page = 0, + pageSize = 25, + searchTerm = '', + status = 'all' +) => { + return useQuery({ + queryKey: ['subscribers', listId, page, pageSize, searchTerm, status], + queryFn: async () => { + const params = { + page, + pageSize, + search: searchTerm, + status: status === 'all' ? undefined : status + }; + + const response = await apiClient.get( + `/admin/mailing-lists/${listId}/subscribers`, + { params } + ); + return response.data; + }, + enabled: !!listId + }); +}; + +/** + * Hook for adding a subscriber to a list + */ +export const useAddSubscriber = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async ({ listId, subscriberData }) => { + const response = await apiClient.post(`/admin/mailing-lists/${listId}/subscribers`, subscriberData); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['subscribers', data.listId] }); + queryClient.invalidateQueries({ queryKey: ['mailing-list', data.listId] }); + notification.showNotification('Subscriber added successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to add subscriber', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for updating a subscriber + */ +export const useUpdateSubscriber = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async ({ id, subscriberData }) => { + const response = await apiClient.put(`/admin/subscribers/${id}`, subscriberData); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['subscribers'] }); + queryClient.invalidateQueries({ queryKey: ['subscriber', data.id] }); + notification.showNotification('Subscriber updated successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to update subscriber', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for deleting a subscriber + */ +export const useDeleteSubscriber = () => { + const queryClient = useQueryClient(); + const notification = useNotification(); + + return useMutation({ + mutationFn: async (id) => { + const response = await apiClient.delete(`/admin/subscribers/${id}`); + return response.data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['subscribers'] }); + notification.showNotification('Subscriber deleted successfully', 'success'); + return data; + }, + onError: (error) => { + notification.showNotification( + error.message || 'Failed to delete subscriber', + 'error' + ); + throw error; + } + }); +}; + +/** + * Hook for fetching subscriber activity + */ +export const useSubscriberActivity = (subscriberId, enabled = false) => { + return useQuery({ + queryKey: ['subscriber-activity', subscriberId], + queryFn: async () => { + const response = await apiClient.get(`/admin/subscribers/${subscriberId}/activity`); + return response.data; + }, + enabled: !!subscriberId && enabled + }); +}; + +export default { + useEmailCampaigns, + useEmailCampaign, + useCreateEmailCampaign, + useUpdateEmailCampaign, + useDeleteEmailCampaign, + useDuplicateEmailCampaign, + useSendCampaign, + useScheduleCampaign, + usePreviewCampaign, + useCampaignAnalytics, + useCampaignSubscriberActivity, + useExportCampaignReport, + useMailingLists, + useMailingList, + useCreateMailingList, + useUpdateMailingList, + useDeleteMailingList, + useImportSubscribers, + useExportSubscribers, + useSubscribers, + useAddSubscriber, + useUpdateSubscriber, + useDeleteSubscriber, + useSubscriberActivity +}; \ No newline at end of file diff --git a/frontend/src/layouts/AdminLayout.jsx b/frontend/src/layouts/AdminLayout.jsx index bb81786..43fa0fc 100644 --- a/frontend/src/layouts/AdminLayout.jsx +++ b/frontend/src/layouts/AdminLayout.jsx @@ -87,6 +87,8 @@ const AdminLayout = () => { { text: 'Blog', icon: , path: '/admin/blog' }, { text: 'Blog Comments', icon: , path: '/admin/blog-comments' }, { text: 'Email Templates', icon: , path: '/admin/email-templates' }, + { text: 'Email Campaigns', icon: , path: '/admin/email-campaigns' }, + { text: 'Mailing List', icon: , path: '/admin/mailing-lists' }, { text: 'Branding', icon: , path: '/admin/branding' }, { text: 'Settings', icon: , path: '/admin/settings' }, { text: 'Reports', icon: , path: '/admin/reports' }, diff --git a/frontend/src/pages/Admin/CampaignAnalyticsPage.jsx b/frontend/src/pages/Admin/CampaignAnalyticsPage.jsx new file mode 100644 index 0000000..62531e8 --- /dev/null +++ b/frontend/src/pages/Admin/CampaignAnalyticsPage.jsx @@ -0,0 +1,718 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Paper, + Grid, + Card, + CardContent, + Divider, + Tabs, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + CircularProgress, + Alert, + Button, + IconButton, + List, + ListItem, + ListItemText, + ListItemIcon, + Tooltip, + Chip, + TextField, + InputAdornment, + Menu, + MenuItem, + Breadcrumbs, + Link +} from '@mui/material'; +import { + ArrowBack as ArrowBackIcon, + FileDownload as DownloadIcon, + ContentCopy as CopyIcon, + Search as SearchIcon, + Clear as ClearIcon, + FilterList as FilterIcon, + MoreVert as MoreVertIcon +} from '@mui/icons-material'; +import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; +import { format } from 'date-fns'; +import { + useEmailCampaign, + useCampaignAnalytics, + useCampaignSubscriberActivity, + useExportCampaignReport +} from '../../hooks/emailCampaignHooks'; + +// Import chart components +import { + LineChart, + Line, + BarChart, + Bar, + PieChart, + Pie, + ResponsiveContainer, + XAxis, + YAxis, + Tooltip as RechartsTooltip, + Cell, + Legend +} from 'recharts'; + +const CampaignAnalyticsPage = () => { + const navigate = useNavigate(); + const { id } = useParams(); + const [tabValue, setTabValue] = useState(0); + const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const [filterAnchorEl, setFilterAnchorEl] = useState(null); + const [activityTypeFilter, setActivityTypeFilter] = useState('all'); + const [timePeriod, setTimePeriod] = useState('all'); + + // Fetch campaign data + const { data: campaign, isLoading: campaignLoading, error: campaignError } = useEmailCampaign(id); + + // Fetch analytics data + const { data: analytics, isLoading: analyticsLoading, error: analyticsError } = useCampaignAnalytics(id); + + // Fetch subscriber activity data with pagination + const { + data: activityData, + isLoading: activityLoading, + error: activityError + } = useCampaignSubscriberActivity( + id, page, rowsPerPage, searchTerm, activityTypeFilter + ); + + // Export report mutation + const exportReport = useExportCampaignReport(); + + // Handle tab change + const handleTabChange = (event, newValue) => { + setTabValue(newValue); + }; + + // Handle search input change + const handleSearchChange = (e) => { + setSearchTerm(e.target.value); + setPage(0); // Reset page when search term changes + }; + + // Clear search + const handleClearSearch = () => { + setSearchTerm(''); + }; + + // Handle page change + const handleChangePage = (event, newValue) => { + setPage(newValue); + }; + + // Handle rows per page change + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + // Handle filter menu + const handleFilterClick = (event) => { + setFilterAnchorEl(event.currentTarget); + }; + + const handleFilterClose = () => { + setFilterAnchorEl(null); + }; + + const handleFilterSelect = (filterType) => { + setActivityTypeFilter(filterType); + setFilterAnchorEl(null); + setPage(0); // Reset page when filter changes + }; + + // Handle time period change + const handleTimePeriodChange = (period) => { + setTimePeriod(period); + }; + + // Export campaign report + const handleExportReport = async () => { + try { + await exportReport.mutateAsync(id); + } catch (error) { + console.error('Failed to export report:', error); + } + }; + + // Format date + const formatDate = (dateString) => { + if (!dateString) return '-'; + return format(new Date(dateString), 'MMM d, yyyy h:mm a'); + }; + + // Loading state + if (campaignLoading || analyticsLoading) { + return ; + } + + // Error state + if (campaignError || analyticsError) { + return {campaignError?.message || analyticsError?.message}; + } + + // Campaign not found + if (!campaign) { + return ( + + Campaign not found. Please select a valid campaign. + + ); + } + + // Color constants for charts + const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#A4DE6C']; + + // Calculate metrics for display + const openRate = analytics?.delivered > 0 + ? ((analytics.opened / analytics.delivered) * 100).toFixed(2) + : '0'; + + const clickRate = analytics?.opened > 0 + ? ((analytics.clicked / analytics.opened) * 100).toFixed(2) + : '0'; + + const bounceRate = analytics?.sent > 0 + ? ((analytics.bounced / analytics.sent) * 100).toFixed(2) + : '0'; + + const unsubscribeRate = analytics?.delivered > 0 + ? ((analytics.unsubscribed / analytics.delivered) * 100).toFixed(2) + : '0'; + + // Generate data for pie chart + const deliveryPieData = [ + { name: 'Delivered', value: analytics?.delivered || 0 }, + { name: 'Bounced', value: analytics?.bounced || 0 }, + ]; + + // Generate data for engagement pie chart + const engagementPieData = [ + { name: 'Opened', value: analytics?.opened || 0 }, + { name: 'Clicked', value: analytics?.clicked || 0 }, + { name: 'Unopened', value: (analytics?.delivered || 0) - (analytics?.opened || 0) }, + ]; + + const opensByHourData = analytics?.opens_by_hour || []; + + return ( + + {/* Header with breadcrumbs and back button */} + + + + + + Admin + + + Email Campaigns + + Analytics + + + + + Campaign Analytics: {campaign.name} + + + + + + + {/* Campaign info card */} + + + + Subject: + {campaign.subject} + + + + Sent From: + + {campaign.from_name} ({campaign.from_email}) + + + + + Sent Date: + + {formatDate(campaign.sent_at)} + + + + + Status: + + + + + Recipients: + + {analytics?.sent || 0} subscribers + + + + + List: + + {campaign.list_name || 'Multiple Lists'} + + + + + + {/* Key metrics cards */} + + + + + + Open Rate + + + {openRate}% + + + {analytics?.opened || 0} of {analytics?.delivered || 0} delivered + + + + + + + + + + Click Rate + + + {clickRate}% + + + {analytics?.clicked || 0} of {analytics?.opened || 0} opened + + + + + + + + + + Bounce Rate + + 5 ? "error" : "primary"}> + {bounceRate}% + + + {analytics?.bounced || 0} of {analytics?.sent || 0} sent + + + + + + + + + + Unsubscribe Rate + + + {unsubscribeRate}% + + + {analytics?.unsubscribed || 0} of {analytics?.delivered || 0} delivered + + + + + + + {/* Tabs for different data views */} + + + + + + + + + {/* Tab Content */} + {/* Overview Tab */} + {tabValue === 0 && ( + + {/* Time series chart - opens over time */} + + + Opens Over Time + + + + + + + + + + + { + const date = new Date(value); + return format(date, 'h aaa'); + }} + /> + + [value, 'Opens']} + labelFormatter={(label) => format(new Date(label), 'MMM dd, yyyy h:mm a')} + /> + + + + + + + + {/* Pie charts for Delivery and Engagement */} + + + Delivery + + + + `${name} ${(percent * 100).toFixed(0)}%`} + > + {deliveryPieData.map((entry, index) => ( + + ))} + + + + + + + + + Engagement + + + + `${name} ${(percent * 100).toFixed(0)}%`} + > + {engagementPieData.map((entry, index) => ( + + ))} + + + + + + + + + )} + + {/* Link Performance Tab */} + {tabValue === 1 && ( + + Link Performance + + {analytics?.links && analytics.links.length > 0 ? ( + + + + + URL + Link Text + Clicks + Unique Clicks + Click Rate + Actions + + + + {analytics.links.map((link) => ( + + + + {link.url} + + + {link.text || '-'} + {link.clicks} + {link.unique_clicks} + + {analytics.opened > 0 + ? ((link.unique_clicks / analytics.opened) * 100).toFixed(2) + : '0'}% + + + + navigator.clipboard.writeText(link.url)} + > + + + + + + ))} + +
+
+ ) : ( + + No link data available for this campaign. + + )} +
+ )} + + {/* Subscriber Activity Tab */} + {tabValue === 2 && ( + + Subscriber Activity + + + + + + ), + endAdornment: searchTerm && ( + + + + + + ) + }} + /> + + + + + {/* Activity Type Filter Menu */} + + handleFilterSelect('all')}> + All Activities + + handleFilterSelect('open')}> + Opens + + handleFilterSelect('click')}> + Clicks + + handleFilterSelect('bounce')}> + Bounces + + handleFilterSelect('unsubscribe')}> + Unsubscribes + + + + {/* Activity Table */} + {activityLoading ? ( + + + + ) : activityData?.activities && activityData.activities.length > 0 ? ( + <> + + + + + Email + Name + Activity + Timestamp + Details + + + + {activityData.activities.map((activity) => ( + + {activity.email} + + {activity.first_name || activity.last_name + ? `${activity.first_name || ''} ${activity.last_name || ''}`.trim() + : '-'} + + + + + {formatDate(activity.timestamp)} + + + {activity.type === 'click' + ? `Clicked: ${activity.url || 'Unknown URL'}` + : activity.type === 'bounce' + ? `Bounce type: ${activity.bounce_type || 'Unknown'}` + : activity.details || '-'} + + + + ))} + +
+
+ + + + ) : ( + + No activity data available for this campaign{searchTerm ? ' matching your search criteria' : ''}. + + )} +
+ )} +
+ ); +}; + +export default CampaignAnalyticsPage; \ No newline at end of file diff --git a/frontend/src/pages/Admin/CampaignSendPage.jsx b/frontend/src/pages/Admin/CampaignSendPage.jsx new file mode 100644 index 0000000..44298e8 --- /dev/null +++ b/frontend/src/pages/Admin/CampaignSendPage.jsx @@ -0,0 +1,628 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Paper, + Button, + Stepper, + Step, + StepLabel, + Grid, + Card, + CardContent, + CircularProgress, + Alert, + TextField, + FormControlLabel, + Checkbox, + Radio, + RadioGroup, + FormControl, + FormLabel, + Chip, + Divider, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + IconButton, + List, + ListItem, + ListItemText, + Tooltip, + MenuItem, + Select, + InputLabel +} from '@mui/material'; +import { + ArrowBack as ArrowBackIcon, + Send as SendIcon, + Schedule as ScheduleIcon, + Preview as PreviewIcon, + InfoOutlined as InfoIcon, + WarningAmber as WarningIcon, + CheckCircle as CheckCircleIcon +} from '@mui/icons-material'; +import { useNavigate, useParams } from 'react-router-dom'; +import { format, addHours, addDays, setHours, setMinutes } from 'date-fns'; +import { + useEmailCampaign, + useMailingList, + useSendCampaign, + useScheduleCampaign, + usePreviewCampaign +} from '@hooks/emailCampaignHooks'; + +const CampaignSendPage = () => { + const navigate = useNavigate(); + const { id } = useParams(); + + // Stepper state + const [activeStep, setActiveStep] = useState(0); + const steps = ['Review Campaign', 'Select Delivery Options', 'Confirm & Send']; + + // Custom date time picker state + const initialDate = addHours(new Date(), 1); + const [scheduledDate, setScheduledDate] = useState(initialDate); + const [selectedDate, setSelectedDate] = useState(format(initialDate, 'yyyy-MM-dd')); + const [selectedHour, setSelectedHour] = useState(initialDate.getHours()); + const [selectedMinute, setSelectedMinute] = useState(initialDate.getMinutes()); + + // Delivery options + const [deliveryOption, setDeliveryOption] = useState('send_now'); + const [confirmChecked, setConfirmChecked] = useState(false); + + // Preview dialog + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [testEmailAddress, setTestEmailAddress] = useState(''); + + // Fetch campaign and related data + const { data: campaign, isLoading: campaignLoading, error: campaignError } = useEmailCampaign(id); + + // Load lists information for selected lists + const { data: listData, isLoading: listLoading } = useMailingList( + campaign?.list_ids?.length > 0 ? campaign.list_ids[0] : null + ); + + // Mutations + const sendCampaign = useSendCampaign(); + const scheduleCampaign = useScheduleCampaign(); + const previewCampaign = usePreviewCampaign(); + + // Generate hours and minutes for dropdowns + const hours = Array.from({ length: 24 }, (_, i) => i); + const minutes = Array.from({ length: 60 }, (_, i) => i); + + // Update the scheduledDate when date/time components change + useEffect(() => { + try { + const dateObj = new Date(selectedDate); + dateObj.setHours(selectedHour); + dateObj.setMinutes(selectedMinute); + setScheduledDate(dateObj); + } catch (error) { + console.error("Invalid date selection:", error); + } + }, [selectedDate, selectedHour, selectedMinute]); + + // Handle step changes + const handleNext = () => { + setActiveStep((prevStep) => Math.min(prevStep + 1, steps.length - 1)); + }; + + const handleBack = () => { + setActiveStep((prevStep) => Math.max(prevStep - 1, 0)); + }; + + // Handle delivery option change + const handleDeliveryOptionChange = (event) => { + setDeliveryOption(event.target.value); + }; + + // Handle date change + const handleDateChange = (event) => { + setSelectedDate(event.target.value); + }; + + // Handle hour change + const handleHourChange = (event) => { + setSelectedHour(Number(event.target.value)); + }; + + // Handle minute change + const handleMinuteChange = (event) => { + setSelectedMinute(Number(event.target.value)); + }; + + // Handle preview + const handleOpenPreview = () => { + setPreviewDialogOpen(true); + }; + + // Send test email + const handleSendTest = async () => { + if (!testEmailAddress || !campaign) return; + + try { + await previewCampaign.mutateAsync({ + campaignId: campaign.id, + email: testEmailAddress + }); + + // Clear the field after successful send + if (!previewCampaign.error) { + setTestEmailAddress(''); + } + } catch (error) { + console.error('Failed to send test email:', error); + } + }; + + // Handle final campaign send/schedule + const handleSendCampaign = async () => { + if (!campaign) return; + + try { + if (deliveryOption === 'send_now') { + await sendCampaign.mutateAsync(campaign.id); + } else { + await scheduleCampaign.mutateAsync({ + campaignId: campaign.id, + scheduledDate: scheduledDate.toISOString() + }); + } + + // Navigate back to campaigns list on success + if (!sendCampaign.error && !scheduleCampaign.error) { + navigate('/admin/email-campaigns'); + } + } catch (error) { + console.error('Failed to send/schedule campaign:', error); + } + }; + + // Get estimated recipient count + const getRecipientCount = () => { + if (!campaign || !campaign.list_ids || campaign.list_ids.length === 0) { + return 0; + } + return listData?.subscriber_count || 0; + }; + + // Format hour for display (add leading zero if needed) + const formatTimeValue = (value) => { + return value.toString().padStart(2, '0'); + }; + + // Validate that selected date is in the future + const isDateInPast = () => { + const now = new Date(); + return scheduledDate < now; + }; + + // Loading state + if (campaignLoading) { + return ; + } + + // Error state + if (campaignError) { + return {campaignError.message}; + } + + // Campaign not found + if (!campaign) { + return ( + + Campaign not found. Please select a valid campaign. + + ); + } + + return ( + + {/* Header with back button */} + + navigate(`/admin/email-campaigns/${id}`)} sx={{ mr: 1 }}> + + + + Send Campaign: {campaign.name} + + + + {/* Stepper */} + + {steps.map((label) => ( + + {label} + + ))} + + + {/* Step content */} + + {/* Step 1: Review Campaign */} + {activeStep === 0 && ( + + Review Campaign Details + + + + + + + Campaign Details + + + + Name: + {campaign.name} + + Subject: + {campaign.subject} + + From: + + {campaign.from_name} ({campaign.from_email}) + + + {campaign.preheader && ( + <> + Preheader: + {campaign.preheader} + + )} + + + + + + + + + + Audience + + + + + Mailing Lists: + + + {listLoading ? ( + + ) : ( + + + + )} + + + + + Estimated Recipients: {getRecipientCount()} + + + {getRecipientCount() === 0 && ( + + No recipients in selected mailing lists. Your campaign won't be delivered to anyone. + + )} + + + + + + + + + + + + + )} + + {/* Step 2: Delivery Options */} + {activeStep === 1 && ( + + Delivery Options + + + When should this campaign be sent? + + } + label="Send immediately" + /> + } + label="Schedule for later" + /> + + + + {deliveryOption === 'schedule' && ( + + {/* Custom Date Time Picker */} + + + + + + + + + Hour + + + + + + Minute + + + + + + + + {isDateInPast() && ( + + Selected time is in the past. Please choose a future date and time. + + )} + + + Selected Date/Time: {format(scheduledDate, 'PPpp')} + + + + Campaigns are sent in your local timezone: {Intl.DateTimeFormat().resolvedOptions().timeZone} + + + )} + + + + + Send Test Email + + + + Send a test email to verify how your campaign will look before sending to your list. + + + + setTestEmailAddress(e.target.value)} + /> + + + + + {previewCampaign.isSuccess && ( + + Test email sent successfully! + + )} + + {previewCampaign.error && ( + + {previewCampaign.error.message || 'Failed to send test email'} + + )} + + + + + + + )} + + {/* Step 3: Confirm & Send */} + {activeStep === 2 && ( + + Confirm & Send + + + Please review your campaign before sending + + Once a campaign is sent, it cannot be recalled or edited. + + + + + Sending Summary + + + + + + + + + + + + + + + + + + + + + + setConfirmChecked(e.target.checked)} + /> + } + label="I confirm that this campaign is ready to send" + /> + + + + + + + + + {(sendCampaign.error || scheduleCampaign.error) && ( + + {sendCampaign.error?.message || scheduleCampaign.error?.message || 'An error occurred'} + + )} + + )} + + + {/* Preview Dialog */} + setPreviewDialogOpen(false)} + maxWidth="md" + fullWidth + > + Campaign Preview + + + Subject: + {campaign.subject} + + {campaign.preheader && ( + <> + Preheader: + {campaign.preheader} + + )} + + + + Email Content: + + + + + + + + + + + ); +}; + +export default CampaignSendPage; \ No newline at end of file diff --git a/frontend/src/pages/Admin/EmailCampaignEditorPage.jsx b/frontend/src/pages/Admin/EmailCampaignEditorPage.jsx new file mode 100644 index 0000000..e4f109e --- /dev/null +++ b/frontend/src/pages/Admin/EmailCampaignEditorPage.jsx @@ -0,0 +1,469 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Box, + Typography, + Paper, + TextField, + Button, + Grid, + Divider, + CircularProgress, + Alert, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + FormControl, + InputLabel, + Select, + MenuItem, + FormHelperText, + Chip, + Tooltip +} from '@mui/material'; +import { + ArrowBack as ArrowBackIcon, + Send as SendIcon, + Save as SaveIcon, + Visibility as PreviewIcon, + PersonAdd as PersonAddIcon, + People as PeopleIcon, + Tune as TuneIcon +} from '@mui/icons-material'; +import EmailEditor from 'react-email-editor'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useCreateEmailCampaign, useUpdateEmailCampaign, useEmailCampaign, useMailingLists } from '@hooks/emailCampaignHooks'; + +const EmailCampaignEditor = () => { + const { id } = useParams(); + const isNewCampaign = !id; + const navigate = useNavigate(); + const emailEditorRef = useRef(null); + + const [formData, setFormData] = useState({ + name: '', + subject: '', + preheader: '', + fromName: '', + fromEmail: '', + content: '', + design: null, + listIds: [], + status: 'draft', + }); + + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [previewContent, setPreviewContent] = useState(''); + const [segmentDialogOpen, setSegmentDialogOpen] = useState(false); + + // Fetch email campaign data if editing existing campaign + const { data: campaign, isLoading: campaignLoading, error: campaignError } = useEmailCampaign( + isNewCampaign ? null : id + ); + + // Fetch available mailing lists + const { data: mailingLists, isLoading: listsLoading } = useMailingLists(); + + // Mutation hooks for creating and updating campaigns + const createCampaign = useCreateEmailCampaign(); + const updateCampaign = useUpdateEmailCampaign(); + + // Load campaign data into form when available + useEffect(() => { + if (campaign && !isNewCampaign) { + setFormData({ + name: campaign.name || '', + subject: campaign.subject || '', + preheader: campaign.preheader || '', + fromName: campaign.from_name || '', + fromEmail: campaign.from_email || '', + content: campaign.content || '', + design: campaign.design || null, + listIds: campaign.list_ids || [], + status: campaign.status || 'draft', + }); + + // Load the design into the editor if it exists + if (campaign.design && emailEditorRef.current) { + setTimeout(() => { + emailEditorRef.current.editor.loadDesign(JSON.parse(campaign.design)); + }, 500); + } + } + }, [campaign, isNewCampaign]); + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleListChange = (event) => { + setFormData(prev => ({ ...prev, listIds: event.target.value })); + }; + + const onEditorReady = () => { + // If there's a design being edited, load it + if (formData.design && emailEditorRef.current) { + try { + emailEditorRef.current.editor.loadDesign( + typeof formData.design === 'string' ? JSON.parse(formData.design) : formData.design + ); + } catch (error) { + console.error("Error loading design:", error); + } + } + // Otherwise load a default template + else if (emailEditorRef.current) { + emailEditorRef.current.editor.loadDesign({ + body: { + rows: [ + { + cells: [1], + columns: [ + { + contents: [ + { + type: "text", + values: { + containerPadding: "20px", + textAlign: "center", + text: `

Your Email Campaign

+

Start editing this template to create your campaign.

` + } + } + ] + } + ] + } + ] + } + }); + } + }; + + const handlePreview = () => { + if (emailEditorRef.current) { + emailEditorRef.current.editor.exportHtml((data) => { + const { html } = data; + setPreviewContent(` +
+
Subject: ${formData.subject}
+ ${html} +
+ `); + setPreviewDialogOpen(true); + }); + } + }; + + const handleSave = async (saveAndExit = false) => { + if (!emailEditorRef.current) return; + + try { + // Extract the design and HTML content from the editor + await emailEditorRef.current.editor.exportHtml(async (data) => { + const { design, html } = data; + + // Prepare campaign data + const campaignData = { + ...formData, + content: html, + design: JSON.stringify(design) + }; + if (isNewCampaign) { + // Create new campaign + await createCampaign.mutateAsync(campaignData); + if (saveAndExit) { + navigate('/admin/email-campaigns'); + }else { + navigate(`/admin/email-campaigns/${id || 'new'}/send`); + } + } else { + // Update existing campaign + await updateCampaign.mutateAsync({ id, campaignData }); + if (saveAndExit) { + navigate('/admin/email-campaigns'); + }else { + navigate(`/admin/email-campaigns/${id || 'new'}/send`); + } + } + }); + } catch (error) { + console.error('Failed to save campaign:', error); + } + }; + + const handleSendCampaign = () => { + handleSave(false) + } + + if (campaignLoading && !isNewCampaign) { + return ; + } + + if (campaignError && !isNewCampaign) { + return Error loading campaign: {campaignError.message}; + } + + // Calculate selected lists for display + const selectedLists = mailingLists + ? mailingLists.filter(list => formData.listIds.includes(list.id)) + : []; + + return ( + + {/* Header with back button */} + + navigate('/admin/email-campaigns')} sx={{ mr: 1 }}> + + + + {isNewCampaign ? 'Create New Campaign' : 'Edit Campaign'} + + + + {/* Main campaign editor form */} + + + + + + + + + Status + + + + + + + + + + + + + + + + + + + + + + + Target Audience + + + + + Mailing Lists + + + Select one or more mailing lists to send this campaign to + + + + + + + {/* Email Content Editor */} + + + Email Content + + + + + Tips for creating effective email campaigns: +
    +
  1. Keep your message clear and concise
  2. +
  3. Include a strong call-to-action
  4. +
  5. Test your email on different devices before sending
  6. +
  7. Personalize content where possible using variables like {`{{first_name}}`}
  8. +
  9. Add alt text to images for better accessibility
  10. +
+
+
+ + + + + + + + + + + + + + {!isNewCampaign && } + + +
+ + {/* Preview Dialog */} + setPreviewDialogOpen(false)} + maxWidth="md" + fullWidth + > + + Email Preview + + This is how your email will appear to recipients + + + + + + + + + + + {/* Segment Configuration Dialog */} + setSegmentDialogOpen(false)} + maxWidth="md" + fullWidth + > + Configure Audience Segments + + + Segmentation allows you to target specific groups within your selected mailing lists. + Define conditions below to narrow down your audience. + + + {/* Segmentation logic would go here */} + + Advanced segmentation features will be implemented in a future update. + + + + + + +
+ ); +}; + +export default EmailCampaignEditor; \ No newline at end of file diff --git a/frontend/src/pages/Admin/EmailCampaignsPage.jsx b/frontend/src/pages/Admin/EmailCampaignsPage.jsx new file mode 100644 index 0000000..e7a5283 --- /dev/null +++ b/frontend/src/pages/Admin/EmailCampaignsPage.jsx @@ -0,0 +1,399 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Paper, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + IconButton, + Chip, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + TextField, + InputAdornment, + Tabs, + Tab, + CircularProgress, + Tooltip, + Alert +} from '@mui/material'; +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, + MoreVert as MoreVertIcon, + ContentCopy as DuplicateIcon, + Archive as ArchiveIcon, + Send as SendIcon, + Refresh as RefreshIcon, + Search as SearchIcon, + BarChart as AnalyticsIcon, + Clear as ClearIcon +} from '@mui/icons-material'; +import { Link as RouterLink, useNavigate } from 'react-router-dom'; +import { format } from 'date-fns'; +import { + useEmailCampaigns, + useDeleteEmailCampaign, + useDuplicateEmailCampaign +} from '../../hooks/emailCampaignHooks'; + +const EmailCampaignsPage = () => { + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState(0); + const [searchTerm, setSearchTerm] = useState(''); + const [anchorEl, setAnchorEl] = useState(null); + const [selectedCampaign, setSelectedCampaign] = useState(null); + + // Get all campaigns with optional filtering + const { data: campaigns, isLoading, error, refetch } = useEmailCampaigns( + activeTab === 0 ? undefined : + activeTab === 1 ? 'draft' : + activeTab === 2 ? 'scheduled' : + activeTab === 3 ? 'sent' : + activeTab === 4 ? 'archived' : undefined + ); + + // Delete campaign mutation + const deleteCampaign = useDeleteEmailCampaign(); + + // Duplicate campaign mutation + const duplicateCampaign = useDuplicateEmailCampaign(); + + // Handle tab change + const handleTabChange = (event, newValue) => { + setActiveTab(newValue); + }; + + // Handle search input change + const handleSearchChange = (e) => { + setSearchTerm(e.target.value); + }; + + // Clear search + const handleClearSearch = () => { + setSearchTerm(''); + }; + + // Handle menu open + const handleMenuOpen = (event, campaign) => { + setAnchorEl(event.currentTarget); + setSelectedCampaign(campaign); + }; + + // Handle menu close + const handleMenuClose = () => { + setAnchorEl(null); + setSelectedCampaign(null); + }; + + // Handle campaign deletion + const handleDelete = async () => { + if (selectedCampaign) { + try { + await deleteCampaign.mutateAsync(selectedCampaign.id); + handleMenuClose(); + } catch (error) { + console.error('Failed to delete campaign:', error); + } + } + }; + + // Handle campaign duplication + const handleDuplicate = async () => { + if (selectedCampaign) { + try { + await duplicateCampaign.mutateAsync(selectedCampaign.id); + handleMenuClose(); + } catch (error) { + console.error('Failed to duplicate campaign:', error); + } + } + }; + + // Filter campaigns based on search term + const filteredCampaigns = campaigns + ? campaigns.filter(campaign => + campaign.name.toLowerCase().includes(searchTerm.toLowerCase()) || + campaign.subject.toLowerCase().includes(searchTerm.toLowerCase()) + ) + : []; + + // Format date + const formatDate = (dateString) => { + if (!dateString) return '—'; + return format(new Date(dateString), 'MMM d, yyyy h:mm a'); + }; + + // Get status chip color + const getStatusColor = (status) => { + switch (status) { + case 'draft': + return 'default'; + case 'scheduled': + return 'warning'; + case 'sending': + return 'info'; + case 'sent': + return 'success'; + case 'archived': + return 'error'; + default: + return 'default'; + } + }; + + // Get status display text + const getStatusText = (status) => { + switch (status) { + case 'draft': + return 'Draft'; + case 'scheduled': + return 'Scheduled'; + case 'sending': + return 'Sending'; + case 'sent': + return 'Sent'; + case 'archived': + return 'Archived'; + default: + return status; + } + }; + + // Loading state + if (isLoading) { + return ; + } + + // Error state + if (error) { + return {error.message}; + } + + return ( + + + + Email Campaigns + + + + + + {/* Tabs for filtering */} + + + + + + + + + + + {/* Search and Actions */} + + + + + ), + endAdornment: searchTerm && ( + + + + + + ) + }} + /> + + + + + {/* Campaigns Table */} + + + + + Name + Subject + Status + Recipients + Created + Sent/Scheduled + Actions + + + + {filteredCampaigns.length > 0 ? ( + filteredCampaigns.map((campaign) => ( + + {campaign.name} + {campaign.subject} + + + + + {campaign.recipient_count === undefined ? '—' : campaign.recipient_count} + + {formatDate(campaign.created_at)} + + {campaign.status === 'sent' + ? formatDate(campaign.sent_at) + : campaign.status === 'scheduled' + ? formatDate(campaign.scheduled_for) + : '—'} + + + {['draft', 'scheduled'].includes(campaign.status) && ( + + navigate(`/admin/email-campaigns/${campaign.id}`)} + size="small" + > + + + + )} + + {campaign.status === 'draft' && ( + + navigate(`/admin/email-campaigns/${campaign.id}/send`)} + size="small" + color="primary" + > + + + + )} + + {['sent', 'scheduled'].includes(campaign.status) && ( + + navigate(`/admin/email-campaigns/${campaign.id}/analytics`)} + size="small" + color="info" + > + + + + )} + + handleMenuOpen(e, campaign)} + size="small" + > + + + + + )) + ) : ( + + + + {searchTerm + ? 'No campaigns match your search criteria.' + : activeTab !== 0 + ? `No ${activeTab === 1 ? 'draft' : activeTab === 2 ? 'scheduled' : activeTab === 3 ? 'sent' : 'archived'} campaigns found.` + : 'No campaigns found. Create your first campaign by clicking the "Create Campaign" button.'} + + + + )} + +
+
+ + {/* Campaign Actions Menu */} + + + + + + Duplicate + + + {selectedCampaign && selectedCampaign.status !== 'archived' && ( + { + // Archive logic would go here + handleMenuClose(); + }}> + + + + Archive + + )} + + + + + + Delete + + +
+ ); +}; + +export default EmailCampaignsPage; \ No newline at end of file diff --git a/frontend/src/pages/Admin/EmailTemplatesPage.jsx b/frontend/src/pages/Admin/EmailTemplatesPage.jsx index 69ac312..f4c6844 100644 --- a/frontend/src/pages/Admin/EmailTemplatesPage.jsx +++ b/frontend/src/pages/Admin/EmailTemplatesPage.jsx @@ -184,7 +184,6 @@ const DEFAULT_TEMPLATES = { } }, shipping_notification: { - // Simplified template - the actual structure would be more complex in the real editor body: { rows: [ { diff --git a/frontend/src/pages/Admin/MailingListsPage.jsx b/frontend/src/pages/Admin/MailingListsPage.jsx new file mode 100644 index 0000000..0d2b800 --- /dev/null +++ b/frontend/src/pages/Admin/MailingListsPage.jsx @@ -0,0 +1,412 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Paper, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + IconButton, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + CircularProgress, + Alert, + Tabs, + Tab, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + Tooltip, + Grid, + InputAdornment +} from '@mui/material'; +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, + Download as DownloadIcon, + Upload as UploadIcon, + People as PeopleIcon, + MoreVert as MoreVertIcon, + Search as SearchIcon, + Clear as ClearIcon +} from '@mui/icons-material'; +import { Link as RouterLink, useNavigate } from 'react-router-dom'; +import { format } from 'date-fns'; +import { + useMailingLists, + useCreateMailingList, + useUpdateMailingList, + useDeleteMailingList, + useImportSubscribers, + useExportSubscribers +} from '../../hooks/emailCampaignHooks'; + +const MailingListsPage = () => { + const navigate = useNavigate(); + const [searchTerm, setSearchTerm] = useState(''); + const [newListDialogOpen, setNewListDialogOpen] = useState(false); + const [editListData, setEditListData] = useState(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [selectedListId, setSelectedListId] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [importFile, setImportFile] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); + + // Fetch all mailing lists + const { data: mailingLists, isLoading, error } = useMailingLists(); + + // Mutations + const createList = useCreateMailingList(); + const updateList = useUpdateMailingList(); + const deleteList = useDeleteMailingList(); + const importSubscribers = useImportSubscribers(); + const exportSubscribers = useExportSubscribers(); + + // New list form state + const [newListForm, setNewListForm] = useState({ + name: '', + description: '' + }); + + // Handle form input changes + const handleFormChange = (e) => { + const { name, value } = e.target; + setNewListForm(prev => ({ ...prev, [name]: value })); + }; + + // Handle edit form input changes + const handleEditFormChange = (e) => { + const { name, value } = e.target; + setEditListData(prev => ({ ...prev, [name]: value })); + }; + + // Handle search input change + const handleSearchChange = (e) => { + setSearchTerm(e.target.value); + }; + + // Clear search + const handleClearSearch = () => { + setSearchTerm(''); + }; + + // Filter lists based on search term + const filteredLists = mailingLists + ? mailingLists.filter(list => + list.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (list.description && list.description.toLowerCase().includes(searchTerm.toLowerCase())) + ) + : []; + + // Format date + const formatDate = (dateString) => { + if (!dateString) return '—'; + return format(new Date(dateString), 'MMM d, yyyy'); + }; + + // Open menu for a specific list + const handleMenuOpen = (event, listId) => { + setAnchorEl(event.currentTarget); + setSelectedListId(listId); + }; + + // Close menu + const handleMenuClose = () => { + setAnchorEl(null); + setSelectedListId(null); + }; + + // Open edit dialog for a list + const handleEditList = (list) => { + setEditListData(list); + handleMenuClose(); + }; + + // Open delete confirmation dialog + const handleDeleteClick = () => { + setDeleteDialogOpen(true); + handleMenuClose(); + }; + + // Confirm list deletion + const handleConfirmDelete = async () => { + if (selectedListId) { + try { + await deleteList.mutateAsync(selectedListId); + setDeleteDialogOpen(false); + setSelectedListId(null); + } catch (error) { + console.error('Failed to delete list:', error); + } + } + }; + + // Handle new list submission + const handleCreateList = async () => { + if (!newListForm.name) return; + + try { + await createList.mutateAsync(newListForm); + setNewListForm({ name: '', description: '' }); + setNewListDialogOpen(false); + } catch (error) { + console.error('Failed to create list:', error); + } + }; + + // Handle list update + const handleUpdateList = async () => { + if (!editListData || !editListData.id || !editListData.name) return; + + try { + await updateList.mutateAsync({ + id: editListData.id, + listData: { + name: editListData.name, + description: editListData.description + } + }); + setEditListData(null); + } catch (error) { + console.error('Failed to update list:', error); + } + }; + + // Handle file selection for import + const handleFileChange = (e) => { + if (e.target.files && e.target.files.length > 0) { + setImportFile(e.target.files[0]); + } + }; + + // Handle subscriber import + const handleImport = async () => { + if (!importFile || !selectedListId) return; + + try { + const formData = new FormData(); + formData.append('file', importFile); + formData.append('listId', selectedListId); + + await importSubscribers.mutateAsync(formData); + setImportDialogOpen(false); + setImportFile(null); + handleMenuClose(); + } catch (error) { + console.error('Failed to import subscribers:', error); + } + }; + + // Handle subscriber export + const handleExport = async () => { + if (!selectedListId) return; + + try { + await exportSubscribers.mutateAsync(selectedListId); + handleMenuClose(); + } catch (error) { + console.error('Failed to export subscribers:', error); + } + }; + + // Loading state + if (isLoading) { + return ; + } + + // Error state + if (error) { + return {error.message}; + } + return ( + + + Mailing Lists + + + + + + + + ), + endAdornment: searchTerm && ( + + + + ) + }} + /> + + + + + + + + + + + Name + Description + Created + Actions + + + + {filteredLists.map((list) => ( + + {list.name} + {list.description || '—'} + {formatDate(list.created_at)} + + handleMenuOpen(e, list.id)}> + + + + handleEditList(list)}> + + Edit + + + + Delete + + { + setImportDialogOpen(true); + setSelectedListId(list.id); + handleMenuClose(); + }}> + + Import Subscribers + + + + Export Subscribers + + navigate(`/admin/mailing-lists/${list.id}/subscribers`)}> + + View Subscribers + + + + + ))} + +
+
+ + {/* New List Dialog */} + setNewListDialogOpen(false)}> + Create Mailing List + + + + + + + + + + + {/* Edit List Dialog */} + setEditListData(null)}> + Edit Mailing List + + + + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)}> + Confirm Deletion + + Are you sure you want to delete this mailing list? + + + + + + + + {/* Import Subscribers Dialog */} + setImportDialogOpen(false)}> + Import Subscribers + + + + + + + + +
+ ); +}; + +export default MailingListsPage; diff --git a/frontend/src/pages/Admin/SubscribersPage.jsx b/frontend/src/pages/Admin/SubscribersPage.jsx new file mode 100644 index 0000000..76008bc --- /dev/null +++ b/frontend/src/pages/Admin/SubscribersPage.jsx @@ -0,0 +1,775 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Paper, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + IconButton, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + CircularProgress, + Alert, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + Tooltip, + Grid, + InputAdornment, + FormControl, + InputLabel, + Select, + Breadcrumbs, + Link +} from '@mui/material'; +import { + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + ArrowBack as ArrowBackIcon, + MoreVert as MoreVertIcon, + Mail as MailIcon, + Search as SearchIcon, + Clear as ClearIcon, + Block as BlockIcon, + CheckCircle as CheckCircleIcon +} from '@mui/icons-material'; +import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; +import { format } from 'date-fns'; +import { + useMailingList, + useSubscribers, + useAddSubscriber, + useUpdateSubscriber, + useDeleteSubscriber, + useSubscriberActivity +} from '@hooks/emailCampaignHooks'; + +const SubscribersPage = () => { + const navigate = useNavigate(); + const { listId } = useParams(); + const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const [statusFilter, setStatusFilter] = useState('all'); + const [selectedSubscriber, setSelectedSubscriber] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [activityDialogOpen, setActivityDialogOpen] = useState(false); + // New subscriber form data + const [subscriberForm, setSubscriberForm] = useState({ + email: '', + firstName: '', + lastName: '', + status: 'active' + }); + + // Fetch mailing list details + const { data: mailingList, isLoading: listLoading, error: listError } = useMailingList(listId); + + // Fetch subscribers with pagination and filtering + const { data: subscribersData, isLoading: subscribersLoading, error: subscribersError } = + useSubscribers(listId, page, rowsPerPage, searchTerm, statusFilter); + + // Fetch subscriber activity when needed + const { data: subscriberActivity, isLoading: activityLoading } = + useSubscriberActivity(selectedSubscriber?.id, activityDialogOpen); + + // Mutations + const addSubscriber = useAddSubscriber(); + const updateSubscriber = useUpdateSubscriber(); + const deleteSubscriber = useDeleteSubscriber(); + + // Handle form input changes + const handleFormChange = (e) => { + const { name, value } = e.target; + setSubscriberForm(prev => ({ ...prev, [name]: value })); + }; + + // Handle search input change + const handleSearchChange = (e) => { + setSearchTerm(e.target.value); + setPage(0); // Reset to first page when searching + }; + + // Clear search + const handleClearSearch = () => { + setSearchTerm(''); + }; + + // Handle status filter change + const handleStatusFilterChange = (e) => { + setStatusFilter(e.target.value); + setPage(0); // Reset to first page when changing filter + }; + + // Handle page change + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + // Handle rows per page change + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + // Open menu for a specific subscriber + const handleMenuOpen = (event, subscriber) => { + setAnchorEl(event.currentTarget); + setSelectedSubscriber(subscriber); + }; + + // Close menu + const handleMenuClose = () => { + setAnchorEl(null); + }; + + // Open edit dialog for a subscriber + const handleEditSubscriber = () => { + if (!selectedSubscriber) return; + + setSubscriberForm({ + email: selectedSubscriber.email, + firstName: selectedSubscriber.first_name || '', + lastName: selectedSubscriber.last_name || '', + status: selectedSubscriber.status + }); + + setEditDialogOpen(true); + handleMenuClose(); + }; + + // Open delete confirmation dialog + const handleDeleteClick = () => { + setDeleteDialogOpen(true); + handleMenuClose(); + }; + + // View subscriber activity + const handleViewActivity = () => { + setActivityDialogOpen(true); + handleMenuClose(); + }; + + // Add new subscriber + const handleAddSubscriber = async () => { + if (!subscriberForm.email) return; + + try { + await addSubscriber.mutateAsync({ + listId, + subscriberData: { + email: subscriberForm.email, + firstName: subscriberForm.firstName, + lastName: subscriberForm.lastName, + status: subscriberForm.status + } + }); + + // Reset form and close dialog + setSubscriberForm({ + email: '', + firstName: '', + lastName: '', + status: 'active' + }); + setAddDialogOpen(false); + } catch (error) { + console.error('Failed to add subscriber:', error); + } + }; + + // Update subscriber + const handleUpdateSubscriber = async () => { + if (!selectedSubscriber || !subscriberForm.email) return; + + try { + await updateSubscriber.mutateAsync({ + id: selectedSubscriber.id, + subscriberData: { + email: subscriberForm.email, + firstName: subscriberForm.firstName, + lastName: subscriberForm.lastName, + status: subscriberForm.status + } + }); + + // Reset form and close dialog + setEditDialogOpen(false); + } catch (error) { + console.error('Failed to update subscriber:', error); + } + }; + + // Delete subscriber + const handleConfirmDelete = async () => { + if (!selectedSubscriber) return; + + try { + await deleteSubscriber.mutateAsync(selectedSubscriber.id); + setDeleteDialogOpen(false); + } catch (error) { + console.error('Failed to delete subscriber:', error); + } + }; + + // Format date + const formatDate = (dateString) => { + if (!dateString) return '—'; + return format(new Date(dateString), 'MMM d, yyyy h:mm a'); + }; + + // Get status chip color + const getStatusColor = (status) => { + switch (status) { + case 'active': + return 'success'; + case 'unsubscribed': + return 'error'; + case 'bounced': + return 'warning'; + case 'complained': + return 'error'; + default: + return 'default'; + } + }; + + // Loading state + if (listLoading) { + return ; + } + + // Error state + if (listError) { + return {listError.message}; + } + + return ( + + {/* Header with breadcrumbs */} + + + + + + Admin + + + Mailing Lists + + Subscribers + + + + + {mailingList?.name} Subscribers + + + + {mailingList?.description && ( + + {mailingList.description} + + )} + + + {/* Filters and Search */} + + + + + + + ), + endAdornment: searchTerm && ( + + + + + + ) + }} + /> + + + + Status + + + + + + + {subscribersData?.totalCount || 0} total subscribers + {statusFilter !== 'all' && subscribersData?.filteredCount !== undefined && ( + <>, {subscribersData.filteredCount} ${statusFilter} + )} + + + + + + + {/* Subscribers Table */} + + + + + Email + Name + Status + Subscribed + Last Activity + Actions + + + + {subscribersLoading ? ( + + + + + + ) : subscribersData?.subscribers?.length > 0 ? ( + subscribersData.subscribers.map((subscriber) => ( + + {subscriber.email} + + {subscriber.first_name || subscriber.last_name + ? `${subscriber.first_name || ''} ${subscriber.last_name || ''}`.trim() + : '—'} + + + + + {formatDate(subscriber.subscribed_at)} + + {subscriber.last_activity_at + ? formatDate(subscriber.last_activity_at) + : 'No activity'} + + + + { + setSelectedSubscriber(subscriber); + handleEditSubscriber(); + }} + size="small" + > + + + + + handleMenuOpen(e, subscriber)} + size="small" + > + + + + + )) + ) : ( + + + + {searchTerm || statusFilter !== 'all' + ? 'No subscribers match your filters.' + : 'No subscribers in this list yet. Add your first subscriber to get started.'} + + + + )} + +
+
+ + {/* Pagination */} + {subscribersData && subscribersData.totalCount > 0 && ( + + )} + + {/* Subscriber Actions Menu */} + + + + + + View Activity + + + {selectedSubscriber && selectedSubscriber.status === 'active' && ( + { + setSubscriberForm({ + ...subscriberForm, + email: selectedSubscriber.email, + firstName: selectedSubscriber.first_name || '', + lastName: selectedSubscriber.last_name || '', + status: 'unsubscribed' + }); + setEditDialogOpen(true); + handleMenuClose(); + }}> + + + + Unsubscribe + + )} + + {selectedSubscriber && selectedSubscriber.status !== 'active' && ( + { + setSubscriberForm({ + ...subscriberForm, + email: selectedSubscriber.email, + firstName: selectedSubscriber.first_name || '', + lastName: selectedSubscriber.last_name || '', + status: 'active' + }); + setEditDialogOpen(true); + handleMenuClose(); + }}> + + + + Reactivate + + )} + + + + + + Delete + + + + {/* Add Subscriber Dialog */} + setAddDialogOpen(false)} + maxWidth="sm" + fullWidth + > + Add New Subscriber + + + + + + + + + + + + + + Status + + + + {addSubscriber.error && ( + + {addSubscriber.error.message} + + )} + + + + + + + + {/* Edit Subscriber Dialog */} + setEditDialogOpen(false)} + maxWidth="sm" + fullWidth + > + Edit Subscriber + + + + + + + + + + + + + + Status + + + + {updateSubscriber.error && ( + + {updateSubscriber.error.message} + + )} + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)} + > + Confirm Deletion + + + Are you sure you want to delete the subscriber{' '} + {selectedSubscriber?.email}? + This action cannot be undone. + + + + + + + + + {/* Activity Dialog */} + setActivityDialogOpen(false)} + maxWidth="md" + fullWidth + > + + Subscriber Activity + {selectedSubscriber && ( + + {selectedSubscriber.email} + + )} + + + {activityLoading ? ( + + + + ) : subscriberActivity && subscriberActivity.length > 0 ? ( + + + + + Date + Campaign + Activity Type + Details + + + + {subscriberActivity.map((activity) => ( + + {formatDate(activity.timestamp)} + {activity.campaign_name || '—'} + + + + {activity.details || '—'} + + ))} + +
+
+ ) : ( + + No activity recorded for this subscriber. + + )} +
+ + + +
+
+ ); +}; + +export default SubscribersPage; \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 3b7e9e8..c9f4210 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Box, TextField, Button, Typography, CircularProgress, Alert } from '@mui/material'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom'; import { useRequestLoginCode, useVerifyCode } from '../hooks/apiHooks'; const LoginPage = () => { @@ -165,6 +165,13 @@ const LoginPage = () => { )} + + + Don't have an account?{' '} + + Sign up + + ); }; diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 0e52077..cba6132 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Box, TextField, Button, Typography, CircularProgress, Alert, Grid } from '@mui/material'; +import { Box, TextField, Button, Typography, CircularProgress, Alert, Grid, FormControlLabel, Checkbox, } from '@mui/material'; import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { useRegister } from '../hooks/apiHooks'; @@ -9,6 +9,7 @@ const RegisterPage = () => { lastName: '', email: '', }); + const [mailingListEnabled, setMailingListEnabled] = useState(false); const navigate = useNavigate(); // React Query mutation @@ -21,6 +22,10 @@ const RegisterPage = () => { [name]: value, })); }; + // Handle Mailing List checkbox change + const handleMailingListToggle = (event) => { + setMailingListEnabled(event.target.checked); + }; const handleSubmit = async (e) => { e.preventDefault(); @@ -31,7 +36,7 @@ const RegisterPage = () => { } try { - await register.mutateAsync(formData); + await register.mutateAsync({...formData, isSubscribed: mailingListEnabled}); // Redirect to login page after successful registration setTimeout(() => { @@ -105,6 +110,20 @@ const RegisterPage = () => { disabled={register.isLoading || register.isSuccess} /> + + + } + label="Signup for our mailing list?" + /> + + + Already have an account?{' '} diff --git a/frontend/src/pages/SubscriptionConfirmPage.jsx b/frontend/src/pages/SubscriptionConfirmPage.jsx new file mode 100644 index 0000000..4b1b031 --- /dev/null +++ b/frontend/src/pages/SubscriptionConfirmPage.jsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Paper, + Container, + CircularProgress, + Alert, + Button, + Card, + CardContent, + Divider +} from '@mui/material'; +import { useNavigate, useLocation } from 'react-router-dom'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import apiClient from '@services/api'; + +const SubscriptionConfirmPage = () => { + const [loading, setLoading] = useState(true); + const [confirmed, setConfirmed] = useState(false); + const [error, setError] = useState(null); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + const confirmSubscription = async () => { + try { + // Get token from URL query params + const params = new URLSearchParams(location.search); + const token = params.get('token'); + + if (!token) { + setError('Missing confirmation token. Please check your email and click the link again.'); + setLoading(false); + return; + } + + // Call the API to confirm the subscription + const response = await apiClient.get(`/api/subscribers/confirm?token=${token}`); + + setConfirmed(true); + setLoading(false); + } catch (error) { + setError( + error.response?.data?.message || + 'An error occurred while confirming your subscription. The link may be invalid or expired.' + ); + setLoading(false); + } + }; + + confirmSubscription(); + }, [location]); + + return ( + + + + + {loading ? ( + + + + Confirming your subscription... + + + ) : confirmed ? ( + + + + Subscription Confirmed! + + + Thank you for confirming your subscription. You're now signed up to receive our newsletters and updates. + + + + + ) : ( + + + + Confirmation Failed + + + {error} + + + + )} + + + + + ); +}; + +export default SubscriptionConfirmPage; \ No newline at end of file diff --git a/frontend/src/pages/SubscriptionPreferencesPage.jsx b/frontend/src/pages/SubscriptionPreferencesPage.jsx new file mode 100644 index 0000000..1a80a28 --- /dev/null +++ b/frontend/src/pages/SubscriptionPreferencesPage.jsx @@ -0,0 +1,319 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Container, + CircularProgress, + Alert, + Button, + TextField, + Card, + CardContent, + Divider, + FormControlLabel, + Checkbox, + List, + ListItem, + ListItemText, + Grid, + Paper, + Switch +} from '@mui/material'; +import { useNavigate, useParams } from 'react-router-dom'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import PersonIcon from '@mui/icons-material/Person'; +import apiClient from '@services/api'; + +const SubscriptionPreferencesPage = () => { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const [subscriber, setSubscriber] = useState(null); + const [lists, setLists] = useState([]); + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + email: '', + listPreferences: {} + }); + + const { token } = useParams(); + const navigate = useNavigate(); + + useEffect(() => { + const loadSubscriptionPreferences = async () => { + try { + if (!token) { + setError('Missing preferences token. You need a valid link to access your preferences.'); + setLoading(false); + return; + } + + // Call the API to get subscriber info and preferences + const response = await apiClient.get(`/api/subscribers/preferences?token=${token}`); + + // Set subscriber info + setSubscriber(response.data.subscriber); + setLists(response.data.lists); + + // Initialize form data + const { email, first_name, last_name } = response.data.subscriber; + const listPrefs = {}; + + response.data.lists.forEach(list => { + listPrefs[list.id] = list.is_subscribed; + }); + + setFormData({ + firstName: first_name || '', + lastName: last_name || '', + email: email, + listPreferences: listPrefs + }); + + setLoading(false); + } catch (error) { + setError( + error.response?.data?.message || + 'An error occurred while loading your preferences. The link may be invalid or expired.' + ); + setLoading(false); + } + }; + + loadSubscriptionPreferences(); + }, [token]); + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleListToggle = (listId) => { + setFormData(prev => ({ + ...prev, + listPreferences: { + ...prev.listPreferences, + [listId]: !prev.listPreferences[listId] + } + })); + }; + + const handleUnsubscribeAll = () => { + const updatedPrefs = {}; + Object.keys(formData.listPreferences).forEach(listId => { + updatedPrefs[listId] = false; + }); + + setFormData(prev => ({ + ...prev, + listPreferences: updatedPrefs + })); + }; + + const handleSavePreferences = async (e) => { + e.preventDefault(); + setSaving(true); + + try { + // Call API to update preferences + await apiClient.post(`/api/subscribers/preferences?token=${token}`, { + firstName: formData.firstName, + lastName: formData.lastName, + listPreferences: formData.listPreferences + }); + + setSaved(true); + setSaving(false); + } catch (error) { + setError( + error.response?.data?.message || + 'An error occurred while saving your preferences. Please try again or contact support.' + ); + setSaving(false); + } + }; + + return ( + + + + Email Subscription Preferences + + + {loading ? ( + + + + Loading your preferences... + + + ) : error ? ( + + + + + Unable to Load Preferences + + + {error} + + + + + ) : saved ? ( + + + + + Preferences Updated Successfully + + + Your subscription preferences have been saved. Thank you for keeping your information up to date. + + + + + ) : subscriber ? ( + + + {/* Personal Information */} + + + + Your Information + + + + + + + + + + + + + + Your information is securely stored and we will never share it with third parties. + + + + + + {/* Subscription Preferences */} + + + + + Email Subscriptions + + + + + + {lists.length === 0 ? ( + + You are not currently subscribed to any mailing lists. + + ) : ( + + {lists.map(list => ( + handleListToggle(list.id)} + /> + } + > + + + ))} + + )} + + + + Toggle the switches to manage your subscriptions. You can update these preferences at any time + by requesting a new preferences link to your email. + + + + + + + + + + + + ) : null} + + + ); +}; + +export default SubscriptionPreferencesPage; \ No newline at end of file diff --git a/frontend/src/pages/UnsubscribePage.jsx b/frontend/src/pages/UnsubscribePage.jsx new file mode 100644 index 0000000..6980988 --- /dev/null +++ b/frontend/src/pages/UnsubscribePage.jsx @@ -0,0 +1,274 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Paper, + Container, + CircularProgress, + Alert, + Button, + TextField, + Card, + CardContent, + Divider, + FormControlLabel, + Checkbox, + Radio, + RadioGroup, + FormControl, + FormLabel, + Chip +} from '@mui/material'; +import { useNavigate, useLocation } from 'react-router-dom'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import apiClient from '@services/api'; + +const UnsubscribePage = () => { + const [loading, setLoading] = useState(true); + const [processing, setProcessing] = useState(false); + const [unsubscribed, setUnsubscribed] = useState(false); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [email, setEmail] = useState(''); + const [reason, setReason] = useState(''); + const [customReason, setCustomReason] = useState(''); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + const checkUnsubscribeParams = async () => { + try { + // Get token from URL query params + const params = new URLSearchParams(location.search); + const token = params.get('token'); + const emailParam = params.get('email'); + const listId = params.get('listId'); + + // If we have a token, use it to unsubscribe automatically + if (token) { + await handleUnsubscribe(null, token, listId); + } + // If we just have an email, pre-fill the form + else if (emailParam) { + setEmail(emailParam); + setShowForm(true); + setLoading(false); + } + // No token or email, show the form + else { + setShowForm(true); + setLoading(false); + } + } catch (error) { + setError( + error.response?.data?.message || + 'An error occurred. Please try again or contact support.' + ); + setLoading(false); + } + }; + + checkUnsubscribeParams(); + }, [location]); + + const handleUnsubscribe = async (e, tokenOverride = null, listIdOverride = null) => { + if (e) e.preventDefault(); + + if (!tokenOverride && !email) { + setError('Please enter your email address.'); + return; + } + + setProcessing(true); + + try { + // Construct parameters + const params = new URLSearchParams(); + if (tokenOverride) params.append('token', tokenOverride); + if (!tokenOverride && email) params.append('email', email); + if (listIdOverride) params.append('listId', listIdOverride); + + // Call the API to process the unsubscribe request + await apiClient.get(`/api/subscribers/unsubscribe?${params.toString()}`); + + // Save feedback if provided + if (reason) { + try { + await apiClient.post('/api/subscribers/feedback', { + email, + reason: reason === 'other' ? customReason : reason + }); + } catch (feedbackError) { + console.error('Error saving feedback:', feedbackError); + // We don't want to show an error for feedback submission failure + } + } + + setUnsubscribed(true); + setProcessing(false); + } catch (error) { + setError( + error.response?.data?.message || + 'An error occurred while processing your request. Please try again or contact support.' + ); + setProcessing(false); + } + }; + + return ( + + + + + {loading ? ( + + + + Processing your request... + + + ) : unsubscribed ? ( + + + + Unsubscribed Successfully + + + You have been successfully unsubscribed from our mailing list. + We're sorry to see you go, but we respect your decision. + + + If you change your mind, you can always subscribe again in the future. + + + + + ) : showForm ? ( + + + Unsubscribe + + + We're sorry to see you go. Please enter your email address below to unsubscribe from our mailing list. + + + {error && ( + + {error} + + )} + + + setEmail(e.target.value)} + margin="normal" + required + type="email" + disabled={processing} + /> + + + + Would you mind telling us why you're unsubscribing? (Optional) + setReason(e.target.value)} + > + } + label="I receive too many emails" + /> + } + label="The content is not relevant to me" + /> + } + label="I never subscribed" + /> + } + label="Other reason" + /> + + + + {reason === 'other' && ( + setCustomReason(e.target.value)} + margin="normal" + disabled={processing} + multiline + rows={2} + /> + )} + + + + + + + + + ) : ( + + + + Something Went Wrong + + + {error || 'Unable to process your request. Please try again or contact support.'} + + + + )} + + + + + ); +}; + +export default UnsubscribePage; \ No newline at end of file diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index 999828e..63e8dee 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -7,6 +7,7 @@ export const authService = { * @param {string} userData.email - User's email * @param {string} userData.firstName - User's first name * @param {string} userData.lastName - User's last name + * @param {string} userData.isSubscribed - User's Mailinglist preference * @returns {Promise} Promise with the API response */ register: async (userData) => {