# BNKops 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=BNKops 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': 'BNKops 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': 'BNKops 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 `
Enter your postal code to find and email your elected representatives