# Alberta Influence Campaign Tool - Complete Setup Guide ## Project Overview A locally-hosted political influence campaign tool for Alberta constituents to contact their representatives via email. Uses the Represent OpenNorth API for representative data and provides both self-service and campaign modes. ## Directory Structure ``` influence-campaign/ ├── docker-compose.yml ├── .env.example ├── app/ │ ├── Dockerfile │ ├── package.json │ ├── server.js │ ├── controllers/ │ │ ├── representatives.js │ │ └── emails.js │ ├── routes/ │ │ └── api.js │ ├── services/ │ │ ├── nocodb.js │ │ ├── represent-api.js │ │ └── email.js │ ├── utils/ │ │ ├── validators.js │ │ └── rate-limiter.js │ └── public/ │ ├── index.html │ ├── css/ │ │ └── styles.css │ └── js/ │ ├── main.js │ ├── api-client.js │ └── postal-lookup.js ├── scripts/ │ └── build-nocodb.sh └── README.md ``` ## 1. Docker Setup ### docker-compose.yml ```yaml version: '3.8' services: app: build: ./app ports: - "3000:3000" environment: - NODE_ENV=development - PORT=3000 - NOCODB_URL=http://nocodb:8080 env_file: - .env depends_on: - nocodb volumes: - ./app:/usr/src/app - /usr/src/app/node_modules command: npm run dev restart: unless-stopped nocodb: image: nocodb/nocodb:latest ports: - "8080:8080" environment: - NC_DB=/usr/app/data/noco.db - NC_AUTH_JWT_SECRET=${NOCODB_AUTH_SECRET} volumes: - nocodb-data:/usr/app/data restart: unless-stopped volumes: nocodb-data: ``` ### .env.example ```bash # NocoDB Configuration NOCODB_URL=http://nocodb:8080 NOCODB_API_TOKEN=your_nocodb_api_token_here NOCODB_AUTH_SECRET=your_jwt_secret_here # SMTP Configuration (e.g., SendGrid, Gmail, etc.) SMTP_HOST=smtp.sendgrid.net SMTP_PORT=587 SMTP_USER=apikey SMTP_PASS=your_smtp_password_here SMTP_FROM_EMAIL=noreply@yourcampaign.ca SMTP_FROM_NAME=Alberta Influence Campaign # Admin Configuration ADMIN_PASSWORD=secure_admin_password_here # Represent API REPRESENT_API_BASE=https://represent.opennorth.ca REPRESENT_API_RATE_LIMIT=60 # App Configuration APP_URL=http://localhost:3000 SESSION_SECRET=your_session_secret_here ``` ### app/Dockerfile ```dockerfile FROM node:18-alpine WORKDIR /usr/src/app # Copy package files COPY package*.json ./ # Install dependencies RUN npm ci --only=production # Copy app files COPY . . # Expose port EXPOSE 3000 # Start the application CMD ["node", "server.js"] ``` ## 2. Backend Implementation ### app/package.json ```json { "name": "alberta-influence-campaign", "version": "1.0.0", "description": "Political influence campaign tool for Alberta", "main": "server.js", "scripts": { "start": "node server.js", "dev": "nodemon server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { "express": "^4.18.2", "cors": "^2.8.5", "dotenv": "^16.3.1", "axios": "^1.6.2", "nodemailer": "^6.9.7", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "express-validator": "^7.0.1" }, "devDependencies": { "nodemon": "^3.0.2" } } ``` ### app/server.js ```javascript const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const path = require('path'); require('dotenv').config(); const apiRoutes = require('./routes/api'); const app = express(); const PORT = process.env.PORT || 3000; // Security middleware app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'"], imgSrc: ["'self'", "https:", "data:"], }, }, })); // Middleware app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); // Routes app.use('/api', apiRoutes); // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); res.status(err.status || 500).json({ error: { message: err.message || 'Internal server error', ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) } }); }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV}`); }); ``` ### app/routes/api.js ```javascript const express = require('express'); const router = express.Router(); const { body, param, validationResult } = require('express-validator'); const representativesController = require('../controllers/representatives'); const emailsController = require('../controllers/emails'); const rateLimiter = require('../utils/rate-limiter'); // Validation middleware const handleValidationErrors = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } next(); }; // Test endpoints router.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); router.get('/test-represent', representativesController.testConnection); // Representatives endpoints router.get( '/representatives/by-postal/:postalCode', param('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/), handleValidationErrors, rateLimiter.general, representativesController.getByPostalCode ); router.post( '/representatives/refresh-postal/:postalCode', param('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/), handleValidationErrors, representativesController.refreshPostalCode ); // Email endpoints router.post( '/emails/send', [ body('from_email').isEmail(), body('to_email').isEmail(), body('subject').notEmpty().isLength({ max: 200 }), body('message').notEmpty().isLength({ min: 10, max: 5000 }), body('representative_name').notEmpty() ], handleValidationErrors, rateLimiter.email, emailsController.sendEmail ); module.exports = router; ``` ### app/services/represent-api.js ```javascript const axios = require('axios'); class RepresentAPIService { constructor() { this.baseURL = process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca'; this.requestCount = 0; this.resetTime = Date.now() + 60000; // Reset every minute } async checkRateLimit() { if (Date.now() > this.resetTime) { this.requestCount = 0; this.resetTime = Date.now() + 60000; } if (this.requestCount >= 60) { throw new Error('Rate limit exceeded. Please wait before making more requests.'); } this.requestCount++; } formatPostalCode(postalCode) { // Remove spaces and convert to uppercase return postalCode.replace(/\s/g, '').toUpperCase(); } async getByPostalCode(postalCode) { try { await this.checkRateLimit(); const formattedPostal = this.formatPostalCode(postalCode); // Validate Alberta postal code (starts with T) if (!formattedPostal.startsWith('T')) { throw new Error('Only Alberta postal codes (starting with T) are supported'); } const response = await axios.get(`${this.baseURL}/postcodes/${formattedPostal}/`, { timeout: 10000, headers: { 'User-Agent': 'Alberta Influence Campaign Tool' } }); return this.processRepresentatives(response.data); } catch (error) { if (error.response?.status === 404) { throw new Error('Postal code not found'); } throw error; } } processRepresentatives(data) { const representatives = []; // Combine centroid and concordance representatives const allReps = [ ...(data.representatives_centroid || []), ...(data.representatives_concordance || []) ]; // De-duplicate based on name and elected_office const seen = new Set(); const uniqueReps = allReps.filter(rep => { const key = `${rep.name}-${rep.elected_office}`; if (seen.has(key)) return false; seen.add(key); return true; }); // Filter and categorize Alberta representatives uniqueReps.forEach(rep => { if (this.isAlbertaRepresentative(rep)) { representatives.push({ ...rep, level: this.determineLevel(rep), contact_info: this.extractContactInfo(rep) }); } }); return { postal_code: data.code, city: data.city, province: data.province, representatives: this.organizeByLevel(representatives) }; } isAlbertaRepresentative(rep) { // Federal MPs for Alberta ridings if (rep.elected_office === 'MP') { return rep.district_name && ( rep.district_name.includes('Alberta') || rep.district_name.includes('Calgary') || rep.district_name.includes('Edmonton') ); } // Provincial MLAs if (rep.elected_office === 'MLA') { return true; } // Municipal representatives const albertaCities = ['Calgary', 'Edmonton', 'Red Deer', 'Lethbridge', 'Medicine Hat', 'St. Albert', 'Airdrie', 'Spruce Grove']; if (rep.elected_office && ['Mayor', 'Councillor', 'Alderman'].includes(rep.elected_office)) { return albertaCities.some(city => rep.district_name && rep.district_name.includes(city) ); } return false; } determineLevel(rep) { if (rep.elected_office === 'MP') return 'federal'; if (rep.elected_office === 'MLA') return 'provincial'; if (['Mayor', 'Councillor', 'Alderman'].includes(rep.elected_office)) return 'municipal'; return 'other'; } extractContactInfo(rep) { const contact = { email: rep.email || null, offices: [] }; if (rep.offices && Array.isArray(rep.offices)) { rep.offices.forEach(office => { contact.offices.push({ type: office.type || 'constituency', phone: office.tel || null, fax: office.fax || null, address: office.postal || null }); }); } return contact; } organizeByLevel(representatives) { return { federal: representatives.filter(r => r.level === 'federal'), provincial: representatives.filter(r => r.level === 'provincial'), municipal: representatives.filter(r => r.level === 'municipal') }; } } module.exports = new RepresentAPIService(); ``` ### app/services/nocodb.js ```javascript const axios = require('axios'); class NocoDBService { constructor() { this.baseURL = process.env.NOCODB_URL; this.apiToken = process.env.NOCODB_API_TOKEN; this.projectName = 'influence_campaign'; this.client = axios.create({ baseURL: `${this.baseURL}/api/v1/db/data/v1/${this.projectName}`, headers: { 'xc-auth': this.apiToken }, timeout: 10000 }); } // Generic methods async create(table, data) { try { const response = await this.client.post(`/${table}`, data); return response.data; } catch (error) { console.error(`Error creating record in ${table}:`, error.message); throw error; } } async find(table, id) { try { const response = await this.client.get(`/${table}/${id}`); return response.data; } catch (error) { console.error(`Error finding record in ${table}:`, error.message); throw error; } } async list(table, params = {}) { try { const response = await this.client.get(`/${table}`, { params }); return response.data; } catch (error) { console.error(`Error listing records from ${table}:`, error.message); throw error; } } async update(table, id, data) { try { const response = await this.client.patch(`/${table}/${id}`, data); return response.data; } catch (error) { console.error(`Error updating record in ${table}:`, error.message); throw error; } } // Specific methods for postal cache async getCachedPostal(postalCode) { try { const results = await this.list('postal_cache', { where: `(postal_code,eq,${postalCode})`, limit: 1 }); if (results.list && results.list.length > 0) { const cached = results.list[0]; const cacheAge = Date.now() - new Date(cached.cached_at).getTime(); const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days if (cacheAge < maxAge) { return JSON.parse(cached.response_json); } } return null; } catch (error) { console.error('Error getting cached postal:', error.message); return null; } } async cachePostal(postalCode, data) { try { const existing = await this.list('postal_cache', { where: `(postal_code,eq,${postalCode})`, limit: 1 }); const cacheData = { postal_code: postalCode, response_json: JSON.stringify(data), cached_at: new Date().toISOString() }; if (existing.list && existing.list.length > 0) { await this.update('postal_cache', existing.list[0].id, cacheData); } else { await this.create('postal_cache', cacheData); } } catch (error) { console.error('Error caching postal:', error.message); } } // Email logging async logEmail(emailData) { try { return await this.create('sent_emails', { from_email: emailData.from_email, to_email: emailData.to_email, representative_name: emailData.representative_name, subject: emailData.subject, body: emailData.body, sent_at: new Date().toISOString(), status: emailData.status || 'sent' }); } catch (error) { console.error('Error logging email:', error.message); // Don't throw - logging failure shouldn't stop email sending } } } module.exports = new NocoDBService(); ``` ### app/services/email.js ```javascript const nodemailer = require('nodemailer'); class EmailService { constructor() { this.transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: process.env.SMTP_PORT || 587, secure: process.env.SMTP_PORT === '465', auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } }); } async sendEmail({ from_email, to_email, subject, message, representative_name }) { try { const mailOptions = { from: `${process.env.SMTP_FROM_NAME} <${process.env.SMTP_FROM_EMAIL}>`, to: to_email, subject: subject, text: message, html: this.generateHTMLEmail(message, from_email, representative_name), replyTo: from_email, headers: { 'X-Sender-Email': from_email, 'X-Campaign': 'Alberta Influence Campaign' } }; const info = await this.transporter.sendMail(mailOptions); return { success: true, messageId: info.messageId, response: info.response }; } catch (error) { console.error('Email send error:', error); throw new Error(`Failed to send email: ${error.message}`); } } generateHTMLEmail(message, from_email, representative_name) { return `
Message from Constituent
Email: ${from_email}

Dear ${representative_name},

${message.split('\n').map(para => `

${para}

`).join('')}
`; } async verifyConnection() { try { await this.transporter.verify(); return { connected: true }; } catch (error) { return { connected: false, error: error.message }; } } } module.exports = new EmailService(); ``` ### app/controllers/representatives.js ```javascript const representAPI = require('../services/represent-api'); const nocoDB = require('../services/nocodb'); class RepresentativesController { async getByPostalCode(req, res, next) { try { const { postalCode } = req.params; // Check cache first let data = await nocoDB.getCachedPostal(postalCode); if (!data) { // Fetch from API data = await representAPI.getByPostalCode(postalCode); // Cache the result await nocoDB.cachePostal(postalCode, data); } res.json({ success: true, data, cached: !!data.from_cache }); } catch (error) { next(error); } } async refreshPostalCode(req, res, next) { try { const { postalCode } = req.params; // Check admin auth const authHeader = req.headers.authorization; if (!authHeader || authHeader !== `Bearer ${process.env.ADMIN_PASSWORD}`) { return res.status(401).json({ error: 'Unauthorized' }); } // Force fetch from API const data = await representAPI.getByPostalCode(postalCode); // Update cache await nocoDB.cachePostal(postalCode, data); res.json({ success: true, message: 'Cache refreshed', data }); } catch (error) { next(error); } } async testConnection(req, res, next) { try { // Test with Edmonton postal code const testPostal = 'T5J2R7'; const data = await representAPI.getByPostalCode(testPostal); res.json({ success: true, message: 'Represent API connection successful', test_postal: testPostal, representatives_found: { federal: data.representatives.federal.length, provincial: data.representatives.provincial.length, municipal: data.representatives.municipal.length } }); } catch (error) { res.status(500).json({ success: false, message: 'Represent API connection failed', error: error.message }); } } } module.exports = new RepresentativesController(); ``` ### app/controllers/emails.js ```javascript const emailService = require('../services/email'); const nocoDB = require('../services/nocodb'); class EmailsController { async sendEmail(req, res, next) { try { const { from_email, to_email, subject, message, representative_name } = req.body; // Send email const result = await emailService.sendEmail({ from_email, to_email, subject, message, representative_name }); // Log to database await nocoDB.logEmail({ from_email, to_email, representative_name, subject, body: message, status: 'sent' }); res.json({ success: true, message: 'Email sent successfully', messageId: result.messageId }); } catch (error) { // Log failed attempt await nocoDB.logEmail({ ...req.body, status: 'failed', error: error.message }); next(error); } } } module.exports = new EmailsController(); ``` ### app/utils/rate-limiter.js ```javascript const rateLimit = require('express-rate-limit'); // General API rate limiter const general = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: 'Too many requests from this IP, please try again later.', standardHeaders: true, legacyHeaders: false, }); // Email sending rate limiter const email = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 10, // Limit to 10 emails per hour per IP message: 'Email sending limit reached. Please wait before sending more emails.', standardHeaders: true, legacyHeaders: false, skipSuccessfulRequests: false, }); module.exports = { general, email }; ``` ### app/utils/validators.js ```javascript // Validate Canadian postal code format function validatePostalCode(postalCode) { const regex = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/; return regex.test(postalCode); } // Validate Alberta postal code (starts with T) function validateAlbertaPostalCode(postalCode) { const formatted = postalCode.replace(/\s/g, '').toUpperCase(); return formatted.startsWith('T') && validatePostalCode(postalCode); } // Validate email format function validateEmail(email) { const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return regex.test(email); } module.exports = { validatePostalCode, validateAlbertaPostalCode, validateEmail }; ``` ## 3. Frontend Implementation ### app/public/index.html ```html Contact Your Alberta Representatives

Contact Your Alberta Representatives

Enter your postal code to find and email your elected representatives

Alberta postal codes start with 'T'
``` ### app/public/css/styles.css ```css * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5; } .container { max-width: 1200px; margin: 0 auto; padding: 20px; } header { text-align: center; margin-bottom: 40px; padding: 20px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } header h1 { color: #005a9c; margin-bottom: 10px; } /* Forms */ .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; } .form-group input, .form-group textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 16px; } .form-group input:focus, .form-group textarea:focus { outline: none; border-color: #005a9c; box-shadow: 0 0 0 2px rgba(0, 90, 156, 0.1); } #postal-form { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); max-width: 500px; margin: 0 auto; } #postal-form .form-group { display: flex; gap: 10px; } #postal-form input { flex: 1; } /* Buttons */ .btn { padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; transition: all 0.3s ease; } .btn-primary { background-color: #005a9c; color: white; } .btn-primary:hover { background-color: #004a7c; } .btn-secondary { background-color: #6c757d; color: white; } .btn-secondary:hover { background-color: #5a6268; } /* Loading Spinner */ .loading { text-align: center; padding: 40px; } .spinner { border: 4px solid #f3f3f3; border-top: 4px solid #005a9c; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 20px; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Error Messages */ .error-message { background-color: #f8d7da; color: #721c24; padding: 15px; border-radius: 4px; margin: 20px 0; border: 1px solid #f5c6cb; } /* Representatives Section */ #representatives-section { margin-top: 40px; } .rep-category { margin-bottom: 40px; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .rep-category h3 { color: #005a9c; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 2px solid #e9ecef; } .rep-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; } .rep-card { border: 1px solid #ddd; border-radius: 8px; padding: 20px; background: #f8f9fa; transition: transform 0.2s, box-shadow 0.2s; } .rep-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .rep-card h4 { color: #005a9c; margin-bottom: 10px; } .rep-card .rep-info { margin-bottom: 10px; color: #666; } .rep-card .rep-info strong { color: #333; } .rep-card .contact-info { margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd; } .rep-card .no-email { color: #6c757d; font-style: italic; } /* Modal */ .modal { position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; } .modal-content { background-color: white; padding: 30px; border-radius: 8px; max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto; position: relative; } .close { position: absolute; right: 20px; top: 20px; font-size: 28px; font-weight: bold; cursor: pointer; color: #aaa; } .close:hover { color: #000; } .modal h2 { color: #005a9c; margin-bottom: 20px; } .form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; } .char-count { display: block; text-align: right; color: #6c757d; font-size: 14px; margin-top: 5px; } .status-message { padding: 15px; border-radius: 4px; margin-top: 20px; } .status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .status-message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } /* Utility Classes */ .hidden { display: none !important; } .help-text { color: #6c757d; font-size: 14px; margin-top: 5px; } /* Mobile Responsiveness */ @media (max-width: 768px) { .container { padding: 10px; } #postal-form .form-group { flex-direction: column; } .rep-cards { grid-template-columns: 1fr; } .modal-content { padding: 20px; width: 95%; } .form-actions { flex-direction: column; } .form-actions .btn { width: 100%; } }