diff --git a/backend/package.json b/backend/package.json
index e5b4175..b40c407 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -26,7 +26,8 @@
"pg-hstore": "^2.3.4",
"slugify": "^1.6.6",
"stripe": "^12.0.0",
- "uuid": "^9.0.0"
+ "uuid": "^9.0.0",
+ "xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^2.0.22"
diff --git a/backend/src/routes/mailingListAdmin.js b/backend/src/routes/mailingListAdmin.js
index 3e7a267..b4d52aa 100644
--- a/backend/src/routes/mailingListAdmin.js
+++ b/backend/src/routes/mailingListAdmin.js
@@ -470,13 +470,12 @@ module.exports = (pool, query, authMiddleware) => {
});
/**
- * Import subscribers to a mailing list from CSV
+ * Import subscribers to a mailing list from JSON data
* POST /api/admin/mailing-lists/import
- */
- router.post('/import', upload.single('file'), async (req, res, next) => {
+ */
+ router.post('/import', async (req, res, next) => {
try {
- const { listId } = req.body;
- const file = req.file;
+ const { listId, subscribers } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
@@ -492,10 +491,10 @@ module.exports = (pool, query, authMiddleware) => {
});
}
- if (!file) {
+ if (!subscribers || !Array.isArray(subscribers) || subscribers.length === 0) {
return res.status(400).json({
error: true,
- message: 'CSV file is required'
+ message: 'Subscribers array is required and cannot be empty'
});
}
@@ -512,128 +511,95 @@ module.exports = (pool, query, authMiddleware) => {
});
}
- // 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 processFile = () => {
- return new Promise((resolve, reject) => {
- // For S3 uploads, file.path won't exist, but file.location will
- const filePath = file.path || file.location;
-
- // If S3 storage, we need to download the file first
- if (!file.path && file.location) {
- // Implementation for S3 file would go here
- // For now, we'll reject as this would need a different approach
- reject(new Error('S3 file processing not implemented'));
- return;
- }
-
- // Local file processing
- fs.createReadStream(file.path)
- .pipe(csv())
- .on('data', (data) => results.push(data))
- .on('end', () => {
- // Clean up temp file - only for local storage
- if (file.path) {
- 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;
+ const errors = [];
+ const imported = [];
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]
+ // Process subscribers in batches to avoid overwhelming the database
+ const batchSize = 50;
+
+ for (let i = 0; i < subscribers.length; i += batchSize) {
+ const batch = subscribers.slice(i, i + batchSize);
+
+ // Process each subscriber in the batch
+ for (const data of batch) {
+ try {
+ // Validate email
+ const email = data.email;
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ errorCount++;
+ errors.push({
+ email: email || 'Invalid email',
+ reason: 'Invalid email format'
+ });
+ continue;
+ }
+
+ // Check if subscriber already exists
+ let subscriberId;
+ const subscriberCheck = await client.query(
+ 'SELECT id FROM subscribers WHERE email = $1',
+ [email]
);
- } 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)`,
+
+ 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++;
+ imported.push({
+ email,
+ first_name: data.first_name || null,
+ last_name: data.last_name || null,
+ status: data.status || 'active'
+ });
+ } catch (err) {
+ console.error('Error importing subscriber:', err);
+ errorCount++;
+ errors.push({
+ email: data.email || 'Unknown',
+ reason: err.message || 'Database error'
+ });
}
-
- importedCount++;
- } catch (err) {
- console.error('Error importing subscriber:', err);
- errorCount++;
}
}
@@ -644,6 +610,8 @@ module.exports = (pool, query, authMiddleware) => {
message: `Import completed with ${importedCount} subscribers added and ${errorCount} errors`,
importedCount,
errorCount,
+ imported,
+ errors,
listId
});
} catch (error) {
@@ -653,20 +621,14 @@ module.exports = (pool, query, authMiddleware) => {
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
+ * Export subscribers from a mailing list as Excel
* GET /api/admin/mailing-lists/:id/export
- */
+ */
router.get('/:id/export', async (req, res, next) => {
try {
const { id } = req.params;
@@ -711,55 +673,48 @@ module.exports = (pool, query, authMiddleware) => {
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 tempDir = path.join(__dirname, '../uploads/temp');
- if (!fs.existsSync(tempDir)) {
- fs.mkdirSync(tempDir, { recursive: true });
- }
- const filePath = path.join(__dirname, '../uploads/temp', fileName);
+ // Create Excel workbook and worksheet
+ const XLSX = require('xlsx');
+ const workbook = XLSX.utils.book_new();
- // 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
+ // Format subscribers data for Excel
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() : ''
+ Email: sub.email,
+ 'First Name': sub.first_name || '',
+ 'Last Name': sub.last_name || '',
+ Status: sub.status || 'active',
+ 'Subscribed At': sub.subscribed_at ? new Date(sub.subscribed_at).toISOString() : '',
+ 'Last Activity': sub.last_activity_at ? new Date(sub.last_activity_at).toISOString() : ''
}));
- // Write to CSV
- await csvWriter.writeRecords(formattedSubscribers);
+ // Create worksheet
+ const worksheet = XLSX.utils.json_to_sheet(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'
- });
- }
- }
- });
+ // Set column widths for better readability
+ const colWidths = [
+ { wch: 30 }, // Email
+ { wch: 15 }, // First Name
+ { wch: 15 }, // Last Name
+ { wch: 10 }, // Status
+ { wch: 25 }, // Subscribed At
+ { wch: 25 } // Last Activity
+ ];
+ worksheet['!cols'] = colWidths;
+
+ // Add worksheet to workbook
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'Subscribers');
+
+ // Generate timestamp for filename
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.xlsx`;
+
+ // Set headers for Excel download
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
+
+ // Generate and send Excel file
+ const excelBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
+ res.send(excelBuffer);
} catch (error) {
next(error);
}
diff --git a/frontend/src/components/CookieConsentPopup.jsx b/frontend/src/components/CookieConsentPopup.jsx
index 49b4c6f..da53e01 100644
--- a/frontend/src/components/CookieConsentPopup.jsx
+++ b/frontend/src/components/CookieConsentPopup.jsx
@@ -68,7 +68,7 @@ const CookieConsentPopup = () => {
};
// Reopen the consent popup (for testing or if user wants to change settings)
- // This function can be exposed via a preferences button somewhere in your app
+ // This function can be exposed via a preferences button somewhere
const reopenConsentPopup = () => {
setOpen(true);
};
diff --git a/frontend/src/hooks/emailCampaignHooks.js b/frontend/src/hooks/emailCampaignHooks.js
index 14a250b..19dd9c2 100644
--- a/frontend/src/hooks/emailCampaignHooks.js
+++ b/frontend/src/hooks/emailCampaignHooks.js
@@ -424,11 +424,7 @@ export const useImportSubscribers = () => {
return useMutation({
mutationFn: async (formData) => {
- const response = await apiClient.post('/admin/mailing-lists/import', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data'
- }
- });
+ const response = await apiClient.post('/admin/mailing-lists/import', formData);
return response.data;
},
onSuccess: (data) => {
@@ -460,12 +456,24 @@ export const useExportSubscribers = () => {
responseType: 'blob'
});
+ // Get filename from Content-Disposition header if available
+ let filename = `subscribers-${listId}.xlsx`;
+ const contentDisposition = response.headers['content-disposition'];
+ if (contentDisposition) {
+ const filenameMatch = contentDisposition.match(/filename="(.+)"/);
+ if (filenameMatch) {
+ filename = filenameMatch[1];
+ }
+ }
+
// Create a download link and trigger the download
- const blob = new Blob([response.data], { type: 'text/csv' });
+ const blob = new Blob([response.data], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
- a.download = `subscribers-${listId}.csv`;
+ a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
diff --git a/frontend/src/hooks/reduxHooks.js b/frontend/src/hooks/reduxHooks.js
index dbc397a..dcbdb19 100644
--- a/frontend/src/hooks/reduxHooks.js
+++ b/frontend/src/hooks/reduxHooks.js
@@ -1,12 +1,10 @@
import { useDispatch, useSelector } from 'react-redux';
import { useMemo } from 'react';
-// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch();
export const useAppSelector = useSelector;
-// Create a custom hook for notifications
export const useNotification = () => {
const dispatch = useAppDispatch();
diff --git a/frontend/src/pages/Admin/MailingListsPage.jsx b/frontend/src/pages/Admin/MailingListsPage.jsx
index 0d2b800..dcce812 100644
--- a/frontend/src/pages/Admin/MailingListsPage.jsx
+++ b/frontend/src/pages/Admin/MailingListsPage.jsx
@@ -46,20 +46,16 @@ import {
useMailingLists,
useCreateMailingList,
useUpdateMailingList,
- useDeleteMailingList,
- useImportSubscribers,
- useExportSubscribers
-} from '../../hooks/emailCampaignHooks';
+ useDeleteMailingList
+} 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
@@ -69,8 +65,6 @@ const MailingListsPage = () => {
const createList = useCreateMailingList();
const updateList = useUpdateMailingList();
const deleteList = useDeleteMailingList();
- const importSubscribers = useImportSubscribers();
- const exportSubscribers = useExportSubscribers();
// New list form state
const [newListForm, setNewListForm] = useState({
@@ -182,42 +176,8 @@ const MailingListsPage = () => {
}
};
- // 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) {
@@ -300,18 +260,6 @@ const MailingListsPage = () => {
Delete
-
-