1144 lines
42 KiB
JavaScript

const nocoDB = require('../services/nocodb');
const emailService = require('../services/email');
const representAPI = require('../services/represent-api');
const qrcodeService = require('../services/qrcode');
const { generateSlug, validateSlug } = require('../utils/validators');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadDir = path.join(__dirname, '../public/uploads');
// Ensure the upload directory exists
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
// Generate unique filename with timestamp
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, 'cover-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit
},
fileFilter: function (req, file, cb) {
// Accept only image files
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files are allowed (jpeg, jpg, png, gif, webp)'));
}
}
});
const VALID_CAMPAIGN_STATUSES = ['draft', 'active', 'paused', 'archived'];
const normalizeTargetLevels = (rawLevels) => {
if (Array.isArray(rawLevels)) {
return rawLevels;
}
if (typeof rawLevels === 'string' && rawLevels.length > 0) {
return rawLevels.split(',').map(level => level.trim()).filter(Boolean);
}
return [];
};
const normalizeStatus = (rawStatus, fallback = 'draft') => {
if (!rawStatus) {
return fallback;
}
const status = String(rawStatus).toLowerCase();
return VALID_CAMPAIGN_STATUSES.includes(status) ? status : fallback;
};
// Helper function to cache representatives
async function cacheRepresentatives(postalCode, representatives, representData) {
try {
// Cache the postal code info
await nocoDB.storePostalCodeInfo({
postal_code: postalCode,
city: representData.city,
province: representData.province
});
// Cache representatives using the existing method
const result = await nocoDB.storeRepresentatives(postalCode, representatives);
if (result.success) {
console.log(`Successfully cached ${result.count} representatives for ${postalCode}`);
} else {
console.log(`Partial success caching representatives for ${postalCode}: ${result.error || 'unknown error'}`);
}
} catch (error) {
console.log(`Failed to cache representatives for ${postalCode}:`, error.message);
// Don't throw - caching is optional and should never break the main flow
}
}
class CampaignsController {
// Get public campaigns (no authentication required)
async getPublicCampaigns(req, res, next) {
try {
const campaigns = await nocoDB.getAllCampaigns();
// Filter to only active campaigns and normalize data structure
const activeCampaigns = await Promise.all(
campaigns
.filter(campaign => {
const status = normalizeStatus(campaign['Status'] || campaign.status);
return status === 'active';
})
.map(async (campaign) => {
const id = campaign.ID || campaign.Id || campaign.id;
// Debug: Log specific fields we're looking for
console.log(`Campaign ${id}:`, {
'Show Call Count': campaign['Show Call Count'],
'show_call_count': campaign.show_call_count,
'Show Email Count': campaign['Show Email Count'],
'show_email_count': campaign.show_email_count
});
// Get email count if show_email_count is enabled
let emailCount = null;
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
console.log(`Getting email count for campaign ID: ${id}, showEmailCount: ${showEmailCount}`);
if (showEmailCount && id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id);
console.log(`Email count result: ${emailCount}`);
}
// Get call count if show_call_count is enabled
let callCount = null;
const showCallCount = campaign['Show Call Count'] || campaign.show_call_count;
console.log(`Getting call count for campaign ID: ${id}, showCallCount: ${showCallCount}`);
if (showCallCount && id != null) {
callCount = await nocoDB.getCampaignCallCount(id);
console.log(`Call count result: ${callCount}`);
}
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
// Return only public-facing information
return {
id,
slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
cover_photo: campaign['Cover Photo'] || campaign.cover_photo,
show_email_count: showEmailCount,
show_call_count: showCallCount,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
emailCount,
callCount
};
})
);
res.json({
success: true,
campaigns: activeCampaigns
});
} catch (error) {
console.error('Get public campaigns error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve campaigns',
message: error.message
});
}
}
// Get all campaigns (for admin panel)
async getAllCampaigns(req, res, next) {
try {
const campaigns = await nocoDB.getAllCampaigns();
// Get email counts for each campaign and normalize data structure
const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => {
const id = campaign.ID || campaign.Id || campaign.id;
let emailCount = 0;
if (id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id);
}
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
// Normalize campaign data structure for frontend
return {
id,
slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
email_subject: campaign['Email Subject'] || campaign.email_subject,
email_body: campaign['Email Body'] || campaign.email_body,
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
cover_photo: campaign['Cover Photo'] || campaign.cover_photo,
status: normalizeStatus(campaign['Status'] || campaign.status),
allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
created_by_user_id: campaign['Created By User ID'] || campaign.created_by_user_id || null,
created_by_user_email: campaign['Created By User Email'] || campaign.created_by_user_email || null,
created_by_user_name: campaign['Created By User Name'] || campaign.created_by_user_name || null,
emailCount
};
}));
let filteredCampaigns = campaignsWithCounts;
if (req.user && !req.user.isAdmin) {
const sessionUserId = req.user.id != null ? String(req.user.id) : (req.session?.userId != null ? String(req.session.userId) : null);
const sessionUserEmail = req.user.email ? String(req.user.email).toLowerCase() : (req.session?.userEmail ? String(req.session.userEmail).toLowerCase() : null);
filteredCampaigns = campaignsWithCounts.filter((campaign) => {
const createdById = campaign.created_by_user_id != null ? String(campaign.created_by_user_id) : null;
const createdByEmail = campaign.created_by_user_email ? String(campaign.created_by_user_email).toLowerCase() : null;
return (
(sessionUserId && createdById && createdById === sessionUserId) ||
(sessionUserEmail && createdByEmail && createdByEmail === sessionUserEmail)
);
});
}
res.json({
success: true,
campaigns: filteredCampaigns
});
} catch (error) {
console.error('Get campaigns error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve campaigns',
message: error.message,
details: error.response?.data || null
});
}
}
// Get single campaign by ID (for admin)
async getCampaignById(req, res, next) {
try {
const { id } = req.params;
const campaign = await nocoDB.getCampaignById(id);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
// Debug logging
console.log('Campaign object keys:', Object.keys(campaign));
console.log('Campaign ID field:', campaign.ID, campaign.Id, campaign.id);
const normalizedId = campaign.ID || campaign.Id || campaign.id;
console.log('Using normalized ID:', normalizedId);
const emailCount = await nocoDB.getCampaignEmailCount(normalizedId);
// Normalize campaign data structure for frontend
res.json({
success: true,
campaign: {
id: normalizedId,
slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
email_subject: campaign['Email Subject'] || campaign.email_subject,
email_body: campaign['Email Body'] || campaign.email_body,
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
cover_photo: campaign['Cover Photo'] || campaign.cover_photo,
status: normalizeStatus(campaign['Status'] || campaign.status),
allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
created_by_user_id: campaign['Created By User ID'] || campaign.created_by_user_id || null,
created_by_user_email: campaign['Created By User Email'] || campaign.created_by_user_email || null,
created_by_user_name: campaign['Created By User Name'] || campaign.created_by_user_name || null,
emailCount
}
});
} catch (error) {
console.error('Get campaign error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve campaign',
message: error.message,
details: error.response?.data || null
});
}
}
// Get campaign by slug (for public access)
async getCampaignBySlug(req, res, next) {
try {
const { slug } = req.params;
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') {
return res.status(403).json({
success: false,
error: 'Campaign is not currently active'
});
}
// Get email count if enabled
let emailCount = null;
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
if (showEmailCount) {
const id = campaign.ID || campaign.Id || campaign.id;
console.log('Getting email count for campaign ID:', id);
if (id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id);
console.log('Email count result:', emailCount);
}
}
// Get call count if enabled
let callCount = null;
const showCallCount = campaign['Show Call Count'] || campaign.show_call_count;
if (showCallCount) {
const id = campaign.ID || campaign.Id || campaign.id;
console.log('Getting call count for campaign ID:', id);
if (id != null) {
callCount = await nocoDB.getCampaignCallCount(id);
console.log('Call count result:', callCount);
}
}
// Debug cover photo value
const coverPhoto = campaign['Cover Photo'] || campaign.cover_photo;
console.log('Raw cover_photo from NocoDB:', coverPhoto, 'Type:', typeof coverPhoto);
res.json({
success: true,
campaign: {
id: campaign.ID || campaign.Id || campaign.id,
slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
cover_photo: coverPhoto || null,
email_subject: campaign['Email Subject'] || campaign.email_subject,
email_body: campaign['Email Body'] || campaign.email_body,
allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
show_call_count: campaign['Show Call Count'] || campaign.show_call_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
emailCount,
callCount
}
});
} catch (error) {
console.error('Get campaign by slug error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve campaign',
message: error.message,
details: error.response?.data || null
});
}
}
// Create new campaign
async createCampaign(req, res, next) {
try {
const {
title,
description,
email_subject,
email_body,
call_to_action,
status = 'draft',
allow_smtp_email = true,
allow_mailto_link = true,
collect_user_info = true,
show_email_count = true,
allow_email_editing = false,
target_government_levels = ['Federal', 'Provincial', 'Municipal']
} = req.body;
const ownerUserId = req.user?.id ?? req.session?.userId ?? null;
const ownerEmail = req.user?.email ?? req.session?.userEmail ?? null;
const ownerName = req.user?.name ?? req.session?.userName ?? null;
const normalizedStatus = normalizeStatus(status, 'draft');
let slug = generateSlug(title);
// Ensure slug is unique
let counter = 1;
let originalSlug = slug;
while (await nocoDB.getCampaignBySlug(slug)) {
slug = `${originalSlug}-${counter}`;
counter++;
}
const campaignData = {
slug,
title,
description,
email_subject,
email_body,
call_to_action,
status: normalizedStatus,
allow_smtp_email,
allow_mailto_link,
collect_user_info,
show_email_count,
allow_email_editing,
// NocoDB MultiSelect expects an array of values
target_government_levels: normalizeTargetLevels(target_government_levels),
// Add user ownership data
created_by_user_id: ownerUserId,
created_by_user_email: ownerEmail,
created_by_user_name: ownerName,
// Add cover photo if uploaded
cover_photo: req.file ? req.file.filename : null
};
const campaign = await nocoDB.createCampaign(campaignData);
// Normalize the created campaign data
res.status(201).json({
success: true,
campaign: {
id: campaign.ID || campaign.Id || campaign.id,
slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
email_subject: campaign['Email Subject'] || campaign.email_subject,
email_body: campaign['Email Body'] || campaign.email_body,
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
cover_photo: campaign['Cover Photo'] || campaign.cover_photo,
status: normalizeStatus(campaign['Status'] || campaign.status),
allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
created_by_user_id: campaign['Created By User ID'] || campaign.created_by_user_id || ownerUserId,
created_by_user_email: campaign['Created By User Email'] || campaign.created_by_user_email || ownerEmail,
created_by_user_name: campaign['Created By User Name'] || campaign.created_by_user_name || ownerName
}
});
} catch (error) {
console.error('Create campaign error:', error);
res.status(500).json({
success: false,
error: 'Failed to create campaign',
message: error.message,
details: error.response?.data || null
});
}
}
// Update campaign
async updateCampaign(req, res, next) {
try {
const { id } = req.params;
const updates = { ...req.body };
const existingCampaign = await nocoDB.getCampaignById(id);
if (!existingCampaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const isAdmin = (req.user && req.user.isAdmin) || req.session?.isAdmin || false;
const sessionUserId = req.user?.id ?? req.session?.userId ?? null;
const sessionUserEmail = req.user?.email ?? req.session?.userEmail ?? null;
if (!isAdmin) {
const createdById = existingCampaign['Created By User ID'] ?? existingCampaign.created_by_user_id ?? null;
const createdByEmail = existingCampaign['Created By User Email'] ?? existingCampaign.created_by_user_email ?? null;
const ownsCampaign = (
(createdById != null && sessionUserId != null && String(createdById) === String(sessionUserId)) ||
(createdByEmail && sessionUserEmail && String(createdByEmail).toLowerCase() === String(sessionUserEmail).toLowerCase())
);
if (!ownsCampaign) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
}
// Track old slug for cascade updates
const oldSlug = existingCampaign['Campaign Slug'] || existingCampaign.slug;
let newSlug = oldSlug;
if (updates.title) {
let slug = generateSlug(updates.title);
const campaignWithSlug = await nocoDB.getCampaignBySlug(slug);
const existingId = campaignWithSlug ? (campaignWithSlug.ID || campaignWithSlug.Id || campaignWithSlug.id) : null;
if (campaignWithSlug && String(existingId) !== String(id)) {
let counter = 1;
const originalSlug = slug;
while (await nocoDB.getCampaignBySlug(slug)) {
slug = `${originalSlug}-${counter}`;
counter++;
}
}
updates.slug = slug;
newSlug = slug;
}
if (updates.target_government_levels !== undefined) {
updates.target_government_levels = normalizeTargetLevels(updates.target_government_levels);
}
// Handle cover photo upload
if (req.file) {
console.log('Cover photo file received:', req.file.filename);
updates.cover_photo = req.file.filename;
} else {
console.log('No cover photo file in request');
}
console.log('Updates object before saving:', updates);
if (updates.status !== undefined) {
const sanitizedStatus = normalizeStatus(updates.status, null);
if (!sanitizedStatus) {
return res.status(400).json({
success: false,
error: 'Invalid campaign status'
});
}
updates.status = sanitizedStatus;
}
// Remove user ownership fields from updates to prevent unauthorized changes
delete updates.created_by_user_id;
delete updates.created_by_user_email;
delete updates.created_by_user_name;
// Remove auto-generated fields that NocoDB handles
delete updates.updated_at;
delete updates.UpdatedAt;
delete updates.created_at;
delete updates.CreatedAt;
const campaign = await nocoDB.updateCampaign(id, updates);
// If slug changed, update references in related tables
if (oldSlug && newSlug && oldSlug !== newSlug) {
console.log(`Campaign slug changed from '${oldSlug}' to '${newSlug}', updating references...`);
const cascadeResult = await nocoDB.updateCampaignSlugReferences(id, oldSlug, newSlug);
if (cascadeResult.success) {
console.log(`Successfully updated slug references: ${cascadeResult.updatedCampaignEmails} campaign emails, ${cascadeResult.updatedCallLogs} call logs`);
} else {
console.warn(`Failed to update some slug references:`, cascadeResult.error);
// Don't fail the main update - cascade is a best-effort operation
}
}
res.json({
success: true,
campaign: {
id: campaign.ID || campaign.Id || campaign.id,
slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
email_subject: campaign['Email Subject'] || campaign.email_subject,
email_body: campaign['Email Body'] || campaign.email_body,
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
cover_photo: campaign['Cover Photo'] || campaign.cover_photo,
status: normalizeStatus(campaign['Status'] || campaign.status),
allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
created_by_user_id: campaign['Created By User ID'] || campaign.created_by_user_id || null,
created_by_user_email: campaign['Created By User Email'] || campaign.created_by_user_email || null,
created_by_user_name: campaign['Created By User Name'] || campaign.created_by_user_name || null
}
});
} catch (error) {
console.error('Update campaign error:', error);
res.status(500).json({
success: false,
error: 'Failed to update campaign',
message: error.message,
details: error.response?.data || null
});
}
}
// Delete campaign
async deleteCampaign(req, res, next) {
try {
const { id } = req.params;
await nocoDB.deleteCampaign(id);
res.json({
success: true,
message: 'Campaign deleted successfully'
});
} catch (error) {
console.error('Delete campaign error:', error);
res.status(500).json({
success: false,
error: 'Failed to delete campaign',
message: error.message,
details: error.response?.data || null
});
}
}
// Send campaign email
async sendCampaignEmail(req, res, next) {
try {
const { slug } = req.params;
const {
userEmail,
userName,
postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod = 'smtp',
customEmailSubject,
customEmailBody
} = req.body;
// Get campaign
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') {
return res.status(403).json({
success: false,
error: 'Campaign is not currently active'
});
}
// Check if the requested email method is allowed
if (emailMethod === 'smtp' && !(campaign['Allow SMTP Email'] || campaign.allow_smtp_email)) {
return res.status(403).json({
success: false,
error: 'SMTP email sending is not enabled for this campaign'
});
}
if (emailMethod === 'mailto' && !(campaign['Allow Mailto Link'] || campaign.allow_mailto_link)) {
return res.status(403).json({
success: false,
error: 'Mailto links are not enabled for this campaign'
});
}
// Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults
const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing;
const subject = (allowEmailEditing && customEmailSubject)
? customEmailSubject
: (campaign['Email Subject'] || campaign.email_subject);
const message = (allowEmailEditing && customEmailBody)
? customEmailBody
: (campaign['Email Body'] || campaign.email_body);
let emailResult = { success: true };
// Send email if SMTP method
if (emailMethod === 'smtp') {
console.log('DEBUG: About to send campaign email...');
emailResult = await emailService.sendCampaignEmail(
recipientEmail,
userEmail,
userName || 'A constituent',
postalCode,
subject,
message,
campaign['Campaign Title'] || campaign.title,
recipientName,
recipientLevel
);
console.log('DEBUG: Campaign email service returned:', emailResult);
}
// Log the campaign email
console.log('DEBUG: About to log campaign email with emailResult:', emailResult);
await nocoDB.logCampaignEmail({
campaign_id: campaign.ID || campaign.Id || campaign.id,
campaign_slug: slug,
user_email: userEmail,
user_name: userName,
user_postal_code: postalCode,
recipient_email: recipientEmail,
recipient_name: recipientName,
recipient_title: recipientTitle,
recipient_level: recipientLevel,
email_method: emailMethod,
subject: subject,
message: message,
status: emailMethod === 'mailto' ? 'clicked' : (emailResult.success ? 'sent' : 'failed')
});
console.log('DEBUG: Campaign email logged with status:', emailMethod === 'mailto' ? 'clicked' : (emailResult.success ? 'sent' : 'failed'));
if (emailMethod === 'smtp') {
if (emailResult.success) {
res.json({
success: true,
message: 'Email sent successfully'
});
} else {
res.status(500).json({
success: false,
error: 'Failed to send email',
message: emailResult.error
});
}
} else {
// For mailto, just return success since we're tracking the click
res.json({
success: true,
message: 'Email action tracked'
});
}
} catch (error) {
console.error('Send campaign email error:', error);
res.status(500).json({
success: false,
error: 'Failed to process campaign email',
message: error.message,
details: error.response?.data || null
});
}
}
// Track user info when they find representatives
async trackUserInfo(req, res, next) {
try {
const { slug } = req.params;
const { userEmail, userName, postalCode } = req.body;
// Get campaign
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') {
return res.status(403).json({
success: false,
error: 'Campaign is not currently active'
});
}
// Log user interaction - finding representatives
await nocoDB.logCampaignEmail({
campaign_id: campaign.ID || campaign.Id || campaign.id,
campaign_slug: slug,
user_email: userEmail || '',
user_name: userName || '',
user_postal_code: postalCode,
recipient_email: '',
recipient_name: '',
recipient_title: '',
recipient_level: 'Other',
email_method: 'smtp', // Use valid option but distinguish by status
subject: 'User Info Capture',
message: 'User searched for representatives',
status: 'user_info_captured'
});
res.json({
success: true,
message: 'User info tracked successfully'
});
} catch (error) {
console.error('Track user info error:', error);
res.status(500).json({
success: false,
error: 'Failed to track user info',
message: error.message,
details: error.response?.data || null
});
}
}
// Get representatives for postal code (for campaign use)
async getRepresentativesForCampaign(req, res, next) {
try {
const { slug, postalCode } = req.params;
// Get campaign to check target levels
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') {
return res.status(403).json({
success: false,
error: 'Campaign is not currently active'
});
}
// First check cache for representatives
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
let representatives = [];
let result = null;
// Try to check cached data first, but don't fail if NocoDB is down
let cachedData = [];
try {
cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode);
console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`);
if (cachedData && cachedData.length > 0) {
representatives = cachedData;
console.log(`Using cached representatives for ${formattedPostalCode}`);
}
} catch (cacheError) {
console.log(`Cache unavailable for ${formattedPostalCode}, proceeding with API call:`, cacheError.message);
}
// If not in cache, fetch from Represent API
if (representatives.length === 0) {
console.log(`Fetching representatives from Represent API for ${formattedPostalCode}`);
result = await representAPI.getRepresentativesByPostalCode(postalCode);
// Process representatives from both concordance and centroid
// Add concordance representatives (if any)
if (result.representatives_concordance && result.representatives_concordance.length > 0) {
representatives = representatives.concat(result.representatives_concordance);
}
// Add centroid representatives (if any) - these are the actual elected officials
if (result.representatives_centroid && result.representatives_centroid.length > 0) {
representatives = representatives.concat(result.representatives_centroid);
}
// Cache the results if we got them from the API
if (representatives.length > 0 && result) {
console.log(`Attempting to cache ${representatives.length} representatives for ${formattedPostalCode}`);
await cacheRepresentatives(formattedPostalCode, representatives, result);
}
}
if (representatives.length === 0) {
return res.json({
success: false,
message: 'No representatives found for this postal code',
representatives: [],
location: {
city: result?.city || 'Alberta',
province: result?.province || 'AB'
}
});
}
// Filter representatives by target government levels
const targetGovernmentLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const targetLevels = Array.isArray(targetGovernmentLevels)
? targetGovernmentLevels
: (typeof targetGovernmentLevels === 'string' && targetGovernmentLevels.length > 0
? targetGovernmentLevels.split(',').map(level => level.trim())
: ['Federal', 'Provincial', 'Municipal']);
const filteredRepresentatives = representatives.filter(rep => {
const repLevel = rep.elected_office ? rep.elected_office.toLowerCase() : 'other';
return targetLevels.some(targetLevel => {
const target = targetLevel.toLowerCase();
if (target === 'federal' && (repLevel.includes('mp') || repLevel.includes('member of parliament'))) {
return true;
}
if (target === 'provincial' && (repLevel.includes('mla') || repLevel.includes('legislative assembly'))) {
return true;
}
if (target === 'municipal' && (repLevel.includes('mayor') || repLevel.includes('councillor') || repLevel.includes('council'))) {
return true;
}
if (target === 'school board' && repLevel.includes('school')) {
return true;
}
return false;
});
});
res.json({
success: true,
representatives: filteredRepresentatives,
location: {
city: result?.city || cachedData[0]?.city || 'Alberta',
province: result?.province || cachedData[0]?.province || 'AB'
}
});
} catch (error) {
console.error('Get representatives for campaign error:', error);
res.status(500).json({
success: false,
error: 'Failed to get representatives',
message: error.message,
details: error.response?.data || null
});
}
}
// Get campaign analytics
async getCampaignAnalytics(req, res, next) {
try {
const { id } = req.params;
const campaign = await nocoDB.getCampaignById(id);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const sessionUserId = req.user?.id ?? req.session?.userId ?? null;
const sessionUserEmail = req.user?.email ?? req.session?.userEmail ?? null;
const isAdmin = (req.user && req.user.isAdmin) || req.session?.isAdmin || false;
if (!isAdmin) {
const createdById = campaign['Created By User ID'] ?? campaign.created_by_user_id ?? null;
const createdByEmail = campaign['Created By User Email'] ?? campaign.created_by_user_email ?? null;
const ownsCampaign = (
(createdById != null && sessionUserId != null && String(createdById) === String(sessionUserId)) ||
(createdByEmail && sessionUserEmail && String(createdByEmail).toLowerCase() === String(sessionUserEmail).toLowerCase())
);
if (!ownsCampaign) {
return res.status(403).json({
success: false,
error: 'Access denied'
});
}
}
const analytics = await nocoDB.getCampaignAnalytics(id);
res.json({
success: true,
analytics
});
} catch (error) {
console.error('Get campaign analytics error:', error);
res.status(500).json({
success: false,
error: 'Failed to get campaign analytics',
message: error.message,
details: error.response?.data || null
});
}
}
// Track campaign phone call
async trackCampaignCall(req, res, next) {
try {
const { slug } = req.params;
const {
representativeName,
representativeTitle,
phoneNumber,
officeType,
userEmail,
userName,
postalCode
} = req.body;
// Validate required fields
if (!representativeName || !phoneNumber) {
return res.status(400).json({
success: false,
error: 'Representative name and phone number are required'
});
}
// Get campaign
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') {
return res.status(403).json({
success: false,
error: 'Campaign is not currently active'
});
}
// Log the call
await nocoDB.logCall({
representativeName,
representativeTitle: representativeTitle || null,
phoneNumber,
officeType: officeType || null,
callerName: userName || null,
callerEmail: userEmail || null,
postalCode: postalCode || null,
campaignId: campaign.ID || campaign.Id || campaign.id,
campaignSlug: slug,
callerIP: req.ip || req.connection?.remoteAddress || null,
timestamp: new Date().toISOString()
});
res.json({
success: true,
message: 'Call tracked successfully'
});
} catch (error) {
console.error('Track campaign call error:', error);
res.status(500).json({
success: false,
error: 'Failed to track call',
message: error.message
});
}
}
// Generate QR code for campaign or response wall
async generateQRCode(req, res, next) {
try {
const { slug } = req.params;
const { type } = req.query; // 'campaign' or 'response-wall'
// Validate type parameter
if (type && !['campaign', 'response-wall'].includes(type)) {
return res.status(400).json({
success: false,
error: 'Invalid type parameter. Must be "campaign" or "response-wall"'
});
}
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
// Build URL based on type
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
let targetUrl;
if (type === 'response-wall') {
targetUrl = `${appUrl}/response-wall.html?campaign=${slug}`;
} else {
// Default to campaign page
targetUrl = `${appUrl}/campaign/${slug}`;
}
// Generate QR code
const qrCodeBuffer = await qrcodeService.generateQRCode(targetUrl, {
width: 400,
margin: 2,
errorCorrectionLevel: 'H' // High error correction for better scanning
});
// Set response headers
res.set({
'Content-Type': 'image/png',
'Content-Length': qrCodeBuffer.length,
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
});
// Send the buffer
res.send(qrCodeBuffer);
} catch (error) {
console.error('Generate QR code error:', error);
res.status(500).json({
success: false,
error: 'Failed to generate QR code',
message: error.message
});
}
}
}
// Export controller instance and upload middleware
const controller = new CampaignsController();
controller.upload = upload;
module.exports = controller;