From 8da0206f1040e060cdcb335023dbb80402b0f0d0 Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Thu, 1 May 2025 22:51:25 -0500 Subject: [PATCH] moved maling unsubscribed from behiond auth --- backend/src/index.js | 3 + backend/src/routes/subscribers.js | 407 ++++++++++++++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 backend/src/routes/subscribers.js diff --git a/backend/src/index.js b/backend/src/index.js index f292666..02dda69 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -36,6 +36,7 @@ const publicSettingsRoutes = require('./routes/publicSettings'); const mailingListRoutes = require('./routes/mailingListAdmin'); const emailCampaignListRoutes = require('./routes/emailCampaignsAdmin'); const subscribersAdminRoutes = require('./routes/subscribersAdmin'); +const subscribersRoutes = require('./routes/subscribers'); // Create Express app const app = express(); @@ -321,6 +322,8 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re // Use routes app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/products', productRoutes(pool, query)); +app.use('/api/subscribers', subscribersRoutes(pool, query)); + app.use('/api/auth', authRoutes(pool, query)); app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query))); diff --git a/backend/src/routes/subscribers.js b/backend/src/routes/subscribers.js new file mode 100644 index 0000000..76d6642 --- /dev/null +++ b/backend/src/routes/subscribers.js @@ -0,0 +1,407 @@ +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); + } + }); + + + + return router; +}; \ No newline at end of file