diff --git a/influence/README.MD b/influence/README.MD index 557022f..c1e5a68 100644 --- a/influence/README.MD +++ b/influence/README.MD @@ -1,4 +1,233 @@ -# README +# Alberta Influence Campaign Tool -Welcome to Influence, a tool for creating political change by targeting influential individuals within a community. This application is designed to help campaigns identify and engage with key figures who can sway public opinion and mobilize support. +A comprehensive web application that helps Alberta residents connect with their elected representatives across all levels of government. Users can find their representatives by postal code and send direct emails to advocate for important issues. + +## Features + +- **Representative Lookup**: Find elected officials by Alberta postal code (T prefixed) +- **Multi-Level Government**: Displays federal MPs, provincial MLAs, and municipal representatives +- **Contact Information**: Shows photos, email addresses, phone numbers, and office locations +- **Direct Email**: Built-in email composer to contact representatives +- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API +- **Responsive Design**: Works seamlessly on desktop and mobile devices +- **Real-time Data**: Integrates with Represent OpenNorth API for up-to-date information + +## Technology Stack + +- **Backend**: Node.js with Express.js +- **Database**: NocoDB (REST API) +- **External API**: Represent OpenNorth Canada API +- **Frontend**: Vanilla JavaScript, HTML5, CSS3 +- **Email**: SMTP integration +- **Deployment**: Docker with docker-compose +- **Rate Limiting**: Express rate limiter for API protection + +## Quick Start + +### Prerequisites +- Docker and Docker Compose +- Access to existing NocoDB instance +- SMTP email configuration + +### Installation + +1. **Clone and navigate to the project**: + ```bash + cd /path/to/changemaker.lite/influence + ``` + +2. **Configure environment**: + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + +3. **Set up NocoDB tables**: + ```bash + ./scripts/build-nocodb.sh + ``` + +4. **Start the application**: + ```bash + docker compose up --build + ``` + +5. **Access the application**: + - Open http://localhost:3333 + - Enter an Alberta postal code (e.g., T5N4B8) + - View your representatives and send emails + +## Configuration + +### Environment Variables (.env) + +```bash +# Server Configuration +NODE_ENV=production +PORT=3333 + +# NocoDB Configuration +NOCODB_API_URL=https://db.cmlite.org +NOCODB_API_TOKEN=your_nocodb_token +NOCODB_PROJECT_ID=your_project_id + +# Email Configuration (SMTP) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your_email@gmail.com +SMTP_PASS=your_app_password +SMTP_FROM_NAME=Alberta Influence Campaign +SMTP_FROM_EMAIL=your_email@gmail.com + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 +``` + +## API Endpoints + +### Representatives +- `GET /api/representatives/by-postal/:postalCode` - Get representatives by postal code +- `POST /api/representatives/refresh-postal/:postalCode` - Refresh cached data + +### Email +- `POST /api/emails/send` - Send email to representative +- `GET /api/emails/logs` - Get email sending logs (with filters) + +### Health +- `GET /api/health` - Application health check +- `GET /api/test-represent` - Test Represent API connection + +## Database Schema + +### Representatives Table +- postal_code, name, email, district_name +- elected_office, party_name, representative_set_name +- url, photo_url, cached_at + +### Emails Table +- recipient_email, recipient_name, sender_email +- subject, message, status, sent_at + +### Postal Codes Table +- postal_code, city, province +- centroid_lat, centroid_lng, last_updated + +## Development + +### Project Structure +``` +influence/ +├── app/ +│ ├── controllers/ # Business logic +│ ├── routes/ # API routes +│ ├── services/ # External integrations +│ ├── utils/ # Helper functions +│ ├── middleware/ # Express middleware +│ ├── public/ # Frontend assets +│ └── server.js # Express app entry point +├── scripts/ +│ └── build-nocodb.sh # Database setup +├── docker-compose.yml # Container orchestration +├── Dockerfile # Container definition +└── .env # Environment configuration +``` + +### Key Components + +- **RepresentativesController**: Handles postal code lookups and caching +- **EmailController**: Manages email composition and sending +- **NocoDBService**: Database operations with error handling +- **RepresentAPI**: Integration with OpenNorth Represent API +- **EmailService**: SMTP email functionality + +## Features in Detail + +### Smart Caching System +- First request fetches from Represent API and caches in NocoDB +- Subsequent requests served from cache for fast performance +- Graceful fallback to API when NocoDB is unavailable +- Automatic error recovery and retry logic + +### Representative Display +- Shows photo with fallback to initials +- Contact information including phone and address +- Party affiliation and government level +- Direct links to official profiles + +### Email Integration +- Modal-based email composer +- Pre-filled recipient information +- SMTP sending with delivery confirmation +- Email history and logging + +### Error Handling +- Comprehensive error logging +- User-friendly error messages +- API fallback mechanisms +- Rate limiting protection + +## Production Deployment + +### Docker Production +```bash +# Build and start in production mode +docker compose -f docker-compose.yml up -d --build + +# View logs +docker compose logs -f app + +# Scale if needed +docker compose up --scale app=2 +``` + +### Monitoring +- Health check endpoint: `/api/health` +- Application logs via Docker +- NocoDB integration status monitoring +- Email delivery tracking + +## Troubleshooting + +### Common Issues + +1. **NocoDB Connection Errors**: + - Check API URL and token in .env + - Run `./scripts/build-nocodb.sh` to setup tables + - Application works without NocoDB (API fallback) + +2. **Email Not Sending**: + - Verify SMTP credentials in .env + - Check spam/junk folders + - Review email logs via API endpoint + +3. **No Representatives Found**: + - Ensure postal code starts with 'T' (Alberta) + - Check Represent API status + - Try different postal code format + +### Log Analysis +```bash +# View application logs +docker compose logs app + +# Follow logs in real-time +docker compose logs -f app + +# Check specific errors +docker compose logs app | grep ERROR +``` + +## Contributing + +This is part of the larger changemaker.lite project. Follow the established patterns for: +- Error handling and logging +- API response formats +- Database integration +- Frontend component structure + +## License + +Part of the changemaker.lite project ecosystem. diff --git a/influence/app/Dockerfile b/influence/app/Dockerfile new file mode 100644 index 0000000..c1f84c0 --- /dev/null +++ b/influence/app/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +WORKDIR /usr/src/app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --only=production + +# Copy app files +COPY . . + +# Expose port +EXPOSE 3000 + +# Start the application +CMD ["node", "server.js"] \ No newline at end of file diff --git a/influence/app/controllers/authController.js b/influence/app/controllers/authController.js new file mode 100644 index 0000000..f94fc7e --- /dev/null +++ b/influence/app/controllers/authController.js @@ -0,0 +1,159 @@ +const nocodbService = require('../services/nocodb'); + +class AuthController { + async login(req, res) { + try { + const { email, password } = req.body; + + // Validate input + if (!email || !password) { + return res.status(400).json({ + success: false, + error: 'Email and password are required' + }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + error: 'Invalid email format' + }); + } + + console.log('Login attempt:', { + email, + ip: req.ip, + userAgent: req.headers['user-agent'] + }); + + // Fetch user from NocoDB + const user = await nocodbService.getUserByEmail(email); + + if (!user) { + console.warn(`No user found with email: ${email}`); + return res.status(401).json({ + success: false, + error: 'Invalid email or password' + }); + } + + // Check password + if (user.Password !== password && user.password !== password) { + console.warn(`Invalid password for email: ${email}`); + return res.status(401).json({ + success: false, + error: 'Invalid email or password' + }); + } + + // Update last login time + try { + const userId = user.Id || user.id; + await nocodbService.updateUser(userId, { + 'Last Login': new Date().toISOString() + }); + } catch (updateError) { + console.warn('Failed to update last login time:', updateError.message); + // Don't fail the login + } + + // Set session + req.session.authenticated = true; + req.session.userId = user.Id || user.id; + req.session.userEmail = user.Email || user.email; + req.session.userName = user.Name || user.name; + req.session.isAdmin = user.Admin || user.admin || false; + + console.log('User logged in successfully:', { + email: req.session.userEmail, + isAdmin: req.session.isAdmin + }); + + // Force session save + req.session.save((err) => { + if (err) { + console.error('Session save error:', err); + return res.status(500).json({ + success: false, + error: 'Session error. Please try again.' + }); + } + + res.json({ + success: true, + user: { + id: req.session.userId, + email: req.session.userEmail, + name: req.session.userName, + isAdmin: req.session.isAdmin + } + }); + }); + + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ + success: false, + error: 'Server error. Please try again later.' + }); + } + } + + async logout(req, res) { + try { + const userEmail = req.session?.userEmail; + + req.session.destroy((err) => { + if (err) { + console.error('Session destroy error:', err); + return res.status(500).json({ + success: false, + error: 'Logout failed' + }); + } + + console.log('User logged out:', userEmail); + res.json({ success: true }); + }); + } catch (error) { + console.error('Logout error:', error); + res.status(500).json({ + success: false, + error: 'Server error during logout' + }); + } + } + + async checkSession(req, res) { + try { + const isAuthenticated = (req.session && req.session.authenticated) || + (req.session && req.session.userId && req.session.userEmail); + + if (isAuthenticated) { + res.json({ + authenticated: true, + user: { + id: req.session.userId, + email: req.session.userEmail, + name: req.session.userName, + isAdmin: req.session.isAdmin + } + }); + } else { + res.json({ + authenticated: false + }); + } + } catch (error) { + console.error('Session check error:', error); + res.status(500).json({ + success: false, + error: 'Session check failed' + }); + } + } +} + +module.exports = new AuthController(); \ No newline at end of file diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js new file mode 100644 index 0000000..c88791b --- /dev/null +++ b/influence/app/controllers/campaigns.js @@ -0,0 +1,500 @@ +const nocoDB = require('../services/nocodb'); +const emailService = require('../services/email'); +const representAPI = require('../services/represent-api'); +const { generateSlug, validateSlug } = require('../utils/validators'); + +class CampaignsController { + // Get all campaigns (for admin panel) + async getAllCampaigns(req, res, next) { + try { + const campaigns = await nocoDB.getAllCampaigns(); + + // Get email counts for each campaign + const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => { + const id = campaign.Id ?? campaign.id; + let emailCount = 0; + if (id != null) { + emailCount = await nocoDB.getCampaignEmailCount(id); + } + // Normalize id property for frontend + return { + id, + ...campaign, + emailCount + }; + })); + + res.json({ + success: true, + campaigns: campaignsWithCounts + }); + } 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' + }); + } + + const normalizedId = campaign.Id ?? campaign.id ?? id; + const emailCount = await nocoDB.getCampaignEmailCount(normalizedId); + + res.json({ + success: true, + campaign: { + id: normalizedId, + ...campaign, + 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' + }); + } + + if (campaign.status !== 'active') { + return res.status(403).json({ + success: false, + error: 'Campaign is not currently active' + }); + } + + // Get email count if enabled + let emailCount = null; + if (campaign.show_email_count) { + const id = campaign.Id ?? campaign.id; + if (id != null) { + emailCount = await nocoDB.getCampaignEmailCount(id); + } + } + + res.json({ + success: true, + campaign: { + id: campaign.id, + slug: campaign.slug, + title: campaign.title, + description: campaign.description, + call_to_action: campaign.call_to_action, + email_subject: campaign.email_subject, + email_body: campaign.email_body, + allow_smtp_email: campaign.allow_smtp_email, + allow_mailto_link: campaign.allow_mailto_link, + collect_user_info: campaign.collect_user_info, + show_email_count: campaign.show_email_count, + target_government_levels: Array.isArray(campaign.target_government_levels) + ? campaign.target_government_levels + : (typeof campaign.target_government_levels === 'string' && campaign.target_government_levels.length > 0 + ? campaign.target_government_levels.split(',').map(s => s.trim()) + : []), + emailCount + } + }); + } 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, + allow_smtp_email = true, + allow_mailto_link = true, + collect_user_info = true, + show_email_count = true, + target_government_levels = ['Federal', 'Provincial', 'Municipal'] + } = req.body; + + // Generate slug from title + 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: 'draft', + allow_smtp_email, + allow_mailto_link, + collect_user_info, + show_email_count, + // NocoDB MultiSelect expects an array of values + target_government_levels: Array.isArray(target_government_levels) + ? target_government_levels + : (typeof target_government_levels === 'string' && target_government_levels.length > 0 + ? target_government_levels.split(',').map(s => s.trim()) + : []) + }; + + const campaign = await nocoDB.createCampaign(campaignData); + + res.status(201).json({ + success: true, + campaign: { + id: campaign.Id ?? campaign.id, + ...campaign + } + }); + } 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; + + // If title is being updated, regenerate slug + if (updates.title) { + let slug = generateSlug(updates.title); + + // Ensure slug is unique (but allow current campaign to keep its slug) + const existingCampaign = await nocoDB.getCampaignBySlug(slug); + if (existingCampaign && existingCampaign.id !== parseInt(id)) { + let counter = 1; + let originalSlug = slug; + while (await nocoDB.getCampaignBySlug(slug)) { + slug = `${originalSlug}-${counter}`; + counter++; + } + } + updates.slug = slug; + } + + // Ensure target_government_levels remains an array for MultiSelect + if (updates.target_government_levels) { + updates.target_government_levels = Array.isArray(updates.target_government_levels) + ? updates.target_government_levels + : (typeof updates.target_government_levels === 'string' + ? updates.target_government_levels.split(',').map(s => s.trim()) + : []); + } + + updates.updated_at = new Date().toISOString(); + + const campaign = await nocoDB.updateCampaign(id, updates); + + res.json({ + success: true, + campaign: { + id: campaign.Id ?? campaign.id ?? id, + ...campaign + } + }); + } 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' + } = req.body; + + // Get campaign + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ + success: false, + error: 'Campaign not found' + }); + } + + if (campaign.status !== '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) { + return res.status(403).json({ + success: false, + error: 'SMTP email sending is not enabled for this campaign' + }); + } + + if (emailMethod === 'mailto' && !campaign.allow_mailto_link) { + return res.status(403).json({ + success: false, + error: 'Mailto links are not enabled for this campaign' + }); + } + + const subject = campaign.email_subject; + const message = campaign.email_body; + + let emailResult = { success: true }; + + // Send email if SMTP method + if (emailMethod === 'smtp') { + emailResult = await emailService.sendEmail({ + to: recipientEmail, + from: { + email: process.env.SMTP_FROM_EMAIL, + name: process.env.SMTP_FROM_NAME + }, + replyTo: userEmail, + subject: subject, + text: message, + html: ` +
${message.replace(/\n/g, '
')}
This message was sent via the Alberta Influence Campaign Tool by ${userName || 'A constituent'} (${userEmail}) from postal code ${postalCode} as part of the "${campaign.title}" campaign.
+ ` + }); + } + + // Log the campaign email + await nocoDB.logCampaignEmail({ + 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'), + timestamp: new Date().toISOString() + }); + + 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 + }); + } + } + + // 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' + }); + } + + if (campaign.status !== 'active') { + return res.status(403).json({ + success: false, + error: 'Campaign is not currently active' + }); + } + + // Get representatives + const result = await representAPI.getRepresentativesByPostalCode(postalCode); + + if (!result.success) { + return res.status(result.status || 500).json(result); + } + + // Filter representatives by target government levels + const targetLevels = Array.isArray(campaign.target_government_levels) + ? campaign.target_government_levels + : (typeof campaign.target_government_levels === 'string' && campaign.target_government_levels.length > 0 + ? campaign.target_government_levels.split(',').map(level => level.trim()) + : ['Federal', 'Provincial', 'Municipal']); + + const filteredRepresentatives = result.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: result.location + }); + + } 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 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 + }); + } + } +} + +module.exports = new CampaignsController(); \ No newline at end of file diff --git a/influence/app/controllers/emails.js b/influence/app/controllers/emails.js new file mode 100644 index 0000000..5203241 --- /dev/null +++ b/influence/app/controllers/emails.js @@ -0,0 +1,61 @@ +const emailService = require('../services/email'); +const nocoDB = require('../services/nocodb'); + +class EmailsController { + async sendEmail(req, res, next) { + try { + const { recipientEmail, senderName, senderEmail, subject, message, postalCode } = req.body; + + // Send the email + const emailResult = await emailService.sendEmail({ + to: recipientEmail, + from: { + email: process.env.SMTP_FROM_EMAIL, + name: process.env.SMTP_FROM_NAME + }, + replyTo: senderEmail, + subject: subject, + text: message, + html: ` +${message.replace(/\n/g, '
')}
This message was sent via the Alberta Influence Campaign Tool by ${senderName} (${senderEmail}) from postal code ${postalCode}.
+ ` + }); + + // Log the email send event + await nocoDB.logEmailSend({ + recipientEmail, + senderName, + senderEmail, + subject, + postalCode, + status: emailResult.success ? 'sent' : 'failed', + timestamp: new Date().toISOString() + }); + + if (emailResult.success) { + res.json({ + success: true, + message: 'Email sent successfully', + messageId: emailResult.messageId + }); + } else { + res.status(500).json({ + success: false, + error: 'Failed to send email', + message: emailResult.error + }); + } + } catch (error) { + console.error('Send email error:', error); + res.status(500).json({ + success: false, + error: 'Failed to send email', + message: error.message + }); + } + } +} + +module.exports = new EmailsController(); \ No newline at end of file diff --git a/influence/app/controllers/representatives.js b/influence/app/controllers/representatives.js new file mode 100644 index 0000000..a977ba9 --- /dev/null +++ b/influence/app/controllers/representatives.js @@ -0,0 +1,170 @@ +const representAPI = require('../services/represent-api'); +const nocoDB = require('../services/nocodb'); + +// 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 + await nocoDB.storeRepresentatives(postalCode, representatives); + + console.log(`Successfully cached representatives for ${postalCode}`); + } catch (error) { + console.log(`Failed to cache representatives for ${postalCode}:`, error.message); + // Don't throw - caching is optional + } +} + +class RepresentativesController { + async testConnection(req, res, next) { + try { + const result = await representAPI.testConnection(); + res.json(result); + } catch (error) { + console.error('Test connection error:', error); + res.status(500).json({ + error: 'Failed to test connection', + message: error.message + }); + } + } + + async getByPostalCode(req, res, next) { + try { + const { postalCode } = req.params; + const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase(); + + // 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) { + return res.json({ + source: 'cache', + postalCode: formattedPostalCode, + representatives: cachedData + }); + } + } catch (cacheError) { + console.log(`Cache unavailable for ${formattedPostalCode}, proceeding with API call:`, cacheError.message); + } + + // If not in cache, fetch from Represent API + console.log(`Fetching representatives from Represent API for ${postalCode}`); + const representData = await representAPI.getRepresentativesByPostalCode(postalCode); + + if (!representData) { + return res.json({ + success: false, + message: 'No data found for this postal code', + data: { + postalCode, + location: null, + representatives: [] + } + }); + } + + // Process representatives from both concordance and centroid + let representatives = []; + + // Add concordance representatives (if any) + if (representData.boundaries_concordance && representData.boundaries_concordance.length > 0) { + representatives = representatives.concat(representData.boundaries_concordance); + } + + // Add centroid representatives (if any) - these are the actual elected officials + if (representData.representatives_centroid && representData.representatives_centroid.length > 0) { + representatives = representatives.concat(representData.representatives_centroid); + } + + console.log(`Representatives concordance count: ${representData.boundaries_concordance ? representData.boundaries_concordance.length : 0}`); + console.log(`Representatives centroid count: ${representData.representatives_centroid ? representData.representatives_centroid.length : 0}`); + console.log(`Total representatives found: ${representatives.length}`); + + if (representatives.length === 0) { + return res.json({ + success: false, + message: 'No representatives found for this postal code', + data: { + postalCode, + location: { + city: representData.city, + province: representData.province + }, + representatives: [] + } + }); + } + + // Try to cache the results (will fail gracefully if NocoDB is down) + console.log(`Attempting to cache ${representatives.length} representatives for ${postalCode}`); + await cacheRepresentatives(postalCode, representatives, representData); + + res.json({ + success: true, + data: { + postalCode, + location: { + city: representData.city, + province: representData.province + }, + representatives + } + }); + } catch (error) { + console.error('Get representatives error:', error); + res.status(500).json({ + error: 'Failed to fetch representatives', + message: error.message + }); + } + } + + async refreshPostalCode(req, res, next) { + try { + const { postalCode } = req.params; + const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase(); + + // Clear cached data + await nocoDB.clearRepresentativesByPostalCode(formattedPostalCode); + + // Fetch fresh data from API + const representData = await representAPI.getRepresentativesByPostalCode(formattedPostalCode); + + if (!representData || !representData.representatives_concordance) { + return res.status(404).json({ + error: 'No representatives found for this postal code', + postalCode: formattedPostalCode + }); + } + + // Cache the fresh results + await nocoDB.storeRepresentatives(formattedPostalCode, representData.representatives_concordance); + + res.json({ + source: 'refreshed', + postalCode: formattedPostalCode, + representatives: representData.representatives_concordance, + city: representData.city, + province: representData.province + }); + } catch (error) { + console.error('Refresh representatives error:', error); + res.status(500).json({ + error: 'Failed to refresh representatives', + message: error.message + }); + } + } +} + +module.exports = new RepresentativesController(); \ No newline at end of file diff --git a/influence/app/middleware/auth.js b/influence/app/middleware/auth.js new file mode 100644 index 0000000..8641c54 --- /dev/null +++ b/influence/app/middleware/auth.js @@ -0,0 +1,71 @@ +const nocodbService = require('../services/nocodb'); + +const requireAuth = async (req, res, next) => { + const isAuthenticated = (req.session && req.session.authenticated) || + (req.session && req.session.userId && req.session.userEmail); + + if (isAuthenticated) { + // Set up req.user object for controllers that expect it + req.user = { + id: req.session.userId, + email: req.session.userEmail, + isAdmin: req.session.isAdmin || false + }; + + next(); + } else { + console.warn('Unauthorized access attempt', { + ip: req.ip, + path: req.path, + userAgent: req.get('User-Agent'), + method: req.method, + timestamp: new Date().toISOString() + }); + + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(401).json({ + success: false, + error: 'Authentication required' + }); + } else { + res.redirect('/login.html'); + } + } +}; + +const requireAdmin = async (req, res, next) => { + const isAuthenticated = (req.session && req.session.authenticated) || + (req.session && req.session.userId && req.session.userEmail); + + if (isAuthenticated && req.session.isAdmin) { + // Set up req.user object for controllers that expect it + req.user = { + id: req.session.userId, + email: req.session.userEmail, + isAdmin: req.session.isAdmin || false + }; + + next(); + } else { + console.warn('Unauthorized admin access attempt', { + ip: req.ip, + path: req.path, + user: req.session?.userEmail || 'anonymous', + userAgent: req.get('User-Agent') + }); + + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(403).json({ + success: false, + error: 'Admin access required' + }); + } else { + res.redirect('/login.html'); + } + } +}; + +module.exports = { + requireAuth, + requireAdmin +}; \ No newline at end of file diff --git a/influence/app/package.json b/influence/app/package.json new file mode 100644 index 0000000..f6c2e04 --- /dev/null +++ b/influence/app/package.json @@ -0,0 +1,39 @@ +{ + "name": "alberta-influence-campaign", + "version": "1.0.0", + "description": "A locally-hosted political influence campaign tool for Alberta constituents", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "test": "jest" + }, + "keywords": [ + "politics", + "alberta", + "campaign", + "represent", + "email" + ], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.0.0", + "dotenv": "^16.3.1", + "express-validator": "^7.0.1", + "express-rate-limit": "^6.8.1", + "axios": "^1.5.0", + "nodemailer": "^6.9.4", + "express-session": "^1.17.3", + "bcryptjs": "^2.4.3" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "jest": "^29.6.2" + }, + "engines": { + "node": ">=16.0.0" + } +} \ No newline at end of file diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html new file mode 100644 index 0000000..4c8268d --- /dev/null +++ b/influence/app/public/admin.html @@ -0,0 +1,532 @@ + + + + + +Manage your influence campaigns
+Manage your influence campaigns and track engagement
+No campaigns found. Create your first campaign
'; + return; + } + + list.innerHTML = this.campaigns.map(campaign => ` +${campaign.description || 'No description'}
+
+ Campaign URL: /campaign/${campaign.slug}
+