Complete implementation of newsletter sending system with SendGrid integration: Backend Implementation: - EmailService class with template rendering (Handlebars) - sendNewsletter() method with subscriber iteration - Preview and send controller methods - Admin routes with CSRF protection and authentication - findByInterest() method in NewsletterSubscription model Frontend Implementation: - Newsletter send form with validation - Preview functionality (opens in new window) - Test send to single email - Production send to all tier subscribers - Real-time status updates Dependencies: - handlebars (template engine) - @sendgrid/mail (email delivery) - html-to-text (plain text generation) Security: - Admin-only routes with authentication - CSRF protection on all POST endpoints - Input validation and sanitization - Confirmation dialogs for production sends Next steps: Configure SendGrid API key in environment variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
511 lines
14 KiB
JavaScript
511 lines
14 KiB
JavaScript
/**
|
|
* Newsletter Management - Admin Interface
|
|
*/
|
|
|
|
let currentPage = 1;
|
|
const perPage = 50;
|
|
let currentFilters = {
|
|
status: 'active',
|
|
verified: 'all'
|
|
};
|
|
|
|
/**
|
|
* Initialize page
|
|
*/
|
|
async function init() {
|
|
// Event listeners (navbar handles admin name and logout now)
|
|
document.getElementById('refresh-btn').addEventListener('click', () => loadAll());
|
|
document.getElementById('export-btn').addEventListener('click', exportSubscribers);
|
|
document.getElementById('filter-status').addEventListener('change', handleFilterChange);
|
|
document.getElementById('filter-verified').addEventListener('change', handleFilterChange);
|
|
document.getElementById('prev-page').addEventListener('click', () => changePage(-1));
|
|
document.getElementById('next-page').addEventListener('click', () => changePage(1));
|
|
|
|
// Newsletter sending form listeners
|
|
document.getElementById('send-newsletter-form').addEventListener('submit', handleSendNewsletter);
|
|
document.getElementById('preview-newsletter-btn').addEventListener('click', handlePreviewNewsletter);
|
|
document.getElementById('test-newsletter-btn').addEventListener('click', handleTestNewsletter);
|
|
|
|
// Load data
|
|
await loadAll();
|
|
}
|
|
|
|
/**
|
|
* Load all data
|
|
*/
|
|
async function loadAll() {
|
|
await Promise.all([
|
|
loadStats(),
|
|
loadSubscribers()
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Load statistics
|
|
*/
|
|
async function loadStats() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch('/api/newsletter/admin/stats', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load stats');
|
|
|
|
const data = await response.json();
|
|
const stats = data.stats;
|
|
|
|
document.getElementById('stat-total').textContent = stats.total || 0;
|
|
document.getElementById('stat-active').textContent = stats.active || 0;
|
|
document.getElementById('stat-verified').textContent = stats.verified || 0;
|
|
document.getElementById('stat-recent').textContent = stats.recent_30_days || 0;
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load subscribers list
|
|
*/
|
|
async function loadSubscribers() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const skip = (currentPage - 1) * perPage;
|
|
|
|
const params = new URLSearchParams({
|
|
limit: perPage,
|
|
skip,
|
|
active: currentFilters.status === 'all' ? null : currentFilters.status === 'active',
|
|
verified: currentFilters.verified === 'all' ? null : currentFilters.verified === 'verified'
|
|
});
|
|
|
|
// Remove null values
|
|
for (const [key, value] of [...params.entries()]) {
|
|
if (value === 'null' || value === null) {
|
|
params.delete(key);
|
|
}
|
|
}
|
|
|
|
const response = await fetch(`/api/newsletter/admin/subscriptions?${params}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to load subscribers');
|
|
|
|
const data = await response.json();
|
|
renderSubscribers(data.subscriptions);
|
|
updatePagination(data.pagination);
|
|
} catch (error) {
|
|
console.error('Error loading subscribers:', error);
|
|
document.getElementById('subscribers-table').innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="px-6 py-8 text-center text-red-600">
|
|
Error loading subscribers. Please refresh the page.
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render subscribers table
|
|
*/
|
|
function renderSubscribers(subscriptions) {
|
|
const tbody = document.getElementById('subscribers-table');
|
|
|
|
if (!subscriptions || subscriptions.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
|
|
No subscribers found
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = subscriptions.map(sub => `
|
|
<tr>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
${escapeHtml(sub.email)}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
${escapeHtml(sub.name) || '-'}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
${escapeHtml(sub.source) || 'unknown'}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
|
${sub.active
|
|
? `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
|
${sub.verified ? '<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>' : ''}
|
|
${sub.verified ? 'Active ✓' : 'Active'}
|
|
</span>`
|
|
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">Inactive</span>'
|
|
}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
${formatDate(sub.subscribed_at)}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<button class="view-details-btn text-blue-600 hover:text-blue-900 mr-3" data-id="${String(sub._id)}">View</button>
|
|
<button class="delete-subscriber-btn text-red-600 hover:text-red-900" data-id="${String(sub._id)}" data-email="${escapeHtml(sub.email)}">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
// Add event listeners to buttons
|
|
tbody.querySelectorAll('.view-details-btn').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
const id = this.getAttribute('data-id');
|
|
viewDetails(id);
|
|
});
|
|
});
|
|
|
|
tbody.querySelectorAll('.delete-subscriber-btn').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
const id = this.getAttribute('data-id');
|
|
const email = this.getAttribute('data-email');
|
|
deleteSubscriber(id, email);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update pagination UI
|
|
*/
|
|
function updatePagination(pagination) {
|
|
document.getElementById('showing-from').textContent = pagination.skip + 1;
|
|
document.getElementById('showing-to').textContent = Math.min(pagination.skip + pagination.limit, pagination.total);
|
|
document.getElementById('total-count').textContent = pagination.total;
|
|
|
|
document.getElementById('prev-page').disabled = currentPage === 1;
|
|
document.getElementById('next-page').disabled = !pagination.has_more;
|
|
}
|
|
|
|
/**
|
|
* Handle filter change
|
|
*/
|
|
function handleFilterChange() {
|
|
currentFilters.status = document.getElementById('filter-status').value;
|
|
currentFilters.verified = document.getElementById('filter-verified').value;
|
|
currentPage = 1;
|
|
loadSubscribers();
|
|
}
|
|
|
|
/**
|
|
* Change page
|
|
*/
|
|
function changePage(direction) {
|
|
currentPage += direction;
|
|
loadSubscribers();
|
|
}
|
|
|
|
/**
|
|
* View subscriber details
|
|
*/
|
|
async function viewDetails(id) {
|
|
alert(`Subscriber details for ID: ${id}\n(Full implementation would show a modal with complete subscriber information)`);
|
|
}
|
|
|
|
/**
|
|
* Delete subscriber
|
|
*/
|
|
async function deleteSubscriber(id, email) {
|
|
if (!confirm(`Are you sure you want to delete subscription for ${email}?\n\nThis action cannot be undone.`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(`/api/newsletter/admin/subscriptions/${id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to delete subscriber');
|
|
|
|
alert('Subscriber deleted successfully');
|
|
await loadAll();
|
|
} catch (error) {
|
|
console.error('Error deleting subscriber:', error);
|
|
alert('Failed to delete subscriber. Please try again.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export subscribers as CSV
|
|
*/
|
|
async function exportSubscribers() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const active = currentFilters.status === 'all' ? 'all' : 'true';
|
|
|
|
const response = await fetch(`/api/newsletter/admin/export?active=${active}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to export subscribers');
|
|
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `newsletter-subscribers-${Date.now()}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
} catch (error) {
|
|
console.error('Error exporting subscribers:', error);
|
|
alert('Failed to export subscribers. Please try again.');
|
|
}
|
|
}
|
|
|
|
// Logout handled by navbar component
|
|
|
|
/**
|
|
* Format date
|
|
*/
|
|
function formatDate(dateString) {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Escape HTML
|
|
*/
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Get CSRF token from cookie
|
|
*/
|
|
function getCsrfToken() {
|
|
const cookies = document.cookie.split('; ');
|
|
const csrfCookie = cookies.find(row => row.startsWith('csrf-token='));
|
|
return csrfCookie ? csrfCookie.split('=')[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Validate newsletter form
|
|
*/
|
|
function validateNewsletterForm() {
|
|
const tier = document.getElementById('newsletter-tier').value;
|
|
const subject = document.getElementById('newsletter-subject').value;
|
|
const contentRaw = document.getElementById('newsletter-content').value;
|
|
|
|
if (!tier) {
|
|
showStatus('error', 'Please select a newsletter tier');
|
|
return null;
|
|
}
|
|
|
|
if (!subject) {
|
|
showStatus('error', 'Please enter a subject line');
|
|
return null;
|
|
}
|
|
|
|
if (!contentRaw) {
|
|
showStatus('error', 'Please enter content variables (JSON)');
|
|
return null;
|
|
}
|
|
|
|
let variables;
|
|
try {
|
|
variables = JSON.parse(contentRaw);
|
|
} catch (error) {
|
|
showStatus('error', 'Invalid JSON in content field');
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
tier,
|
|
subject,
|
|
previewText: document.getElementById('newsletter-preview').value,
|
|
variables
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show status message
|
|
*/
|
|
function showStatus(type, message) {
|
|
const statusDiv = document.getElementById('send-status');
|
|
statusDiv.className = `px-4 py-3 rounded ${
|
|
type === 'success' ? 'bg-green-50 border border-green-200 text-green-800' :
|
|
type === 'error' ? 'bg-red-50 border border-red-200 text-red-800' :
|
|
'bg-blue-50 border border-blue-200 text-blue-800'
|
|
}`;
|
|
statusDiv.textContent = message;
|
|
statusDiv.classList.remove('hidden');
|
|
}
|
|
|
|
/**
|
|
* Handle newsletter preview
|
|
*/
|
|
async function handlePreviewNewsletter(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = validateNewsletterForm();
|
|
if (!formData) return;
|
|
|
|
try {
|
|
showStatus('info', 'Generating preview...');
|
|
|
|
const token = localStorage.getItem('admin_token');
|
|
const csrfToken = getCsrfToken();
|
|
|
|
const response = await fetch('/api/newsletter/admin/preview', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
'X-CSRF-Token': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
tier: formData.tier,
|
|
variables: formData.variables
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Preview failed');
|
|
}
|
|
|
|
const html = await response.text();
|
|
|
|
// Open preview in new window
|
|
const previewWindow = window.open('', '_blank', 'width=800,height=600');
|
|
previewWindow.document.write(html);
|
|
previewWindow.document.close();
|
|
|
|
showStatus('success', 'Preview opened in new window');
|
|
} catch (error) {
|
|
console.error('Preview error:', error);
|
|
showStatus('error', `Preview failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle test newsletter send
|
|
*/
|
|
async function handleTestNewsletter(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = validateNewsletterForm();
|
|
if (!formData) return;
|
|
|
|
const testEmail = prompt('Enter your email address for test send:');
|
|
if (!testEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(testEmail)) {
|
|
showStatus('error', 'Valid email address is required for test send');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showStatus('info', 'Sending test email...');
|
|
|
|
const token = localStorage.getItem('admin_token');
|
|
const csrfToken = getCsrfToken();
|
|
|
|
const response = await fetch('/api/newsletter/admin/send', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
'X-CSRF-Token': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
...formData,
|
|
testMode: true,
|
|
testEmail
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showStatus('success', `Test email sent to ${testEmail}`);
|
|
} else {
|
|
throw new Error(result.error || 'Test send failed');
|
|
}
|
|
} catch (error) {
|
|
console.error('Test send error:', error);
|
|
showStatus('error', `Test send failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle newsletter send to all subscribers
|
|
*/
|
|
async function handleSendNewsletter(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = validateNewsletterForm();
|
|
if (!formData) return;
|
|
|
|
const confirmation = confirm(
|
|
`Are you sure you want to send this newsletter to all subscribers of the "${formData.tier}" tier?\n\n` +
|
|
`Subject: ${formData.subject}\n\n` +
|
|
`This action cannot be undone.`
|
|
);
|
|
|
|
if (!confirmation) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showStatus('info', 'Sending newsletter to subscribers...');
|
|
|
|
const token = localStorage.getItem('admin_token');
|
|
const csrfToken = getCsrfToken();
|
|
|
|
const response = await fetch('/api/newsletter/admin/send', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
'X-CSRF-Token': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
...formData,
|
|
testMode: false
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showStatus('success', `${result.message}\n\nSent: ${result.sent}, Failed: ${result.failed}`);
|
|
|
|
// Clear form on success
|
|
if (result.failed === 0) {
|
|
document.getElementById('send-newsletter-form').reset();
|
|
}
|
|
} else {
|
|
throw new Error(result.error || 'Send failed');
|
|
}
|
|
} catch (error) {
|
|
console.error('Send error:', error);
|
|
showStatus('error', `Send failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', init);
|