Pushing the new influence app in its current state

This commit is contained in:
admin 2025-09-20 11:19:26 -06:00
parent 83f5055471
commit e037017817
37 changed files with 9751 additions and 2 deletions

View File

@ -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.

18
influence/app/Dockerfile Normal file
View File

@ -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"]

View File

@ -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();

View File

@ -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: `
<p>${message.replace(/\n/g, '<br>')}</p>
<hr>
<p><small>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.</small></p>
`
});
}
// 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();

View File

@ -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: `
<p>${message.replace(/\n/g, '<br>')}</p>
<hr>
<p><small>This message was sent via the Alberta Influence Campaign Tool by ${senderName} (${senderEmail}) from postal code ${postalCode}.</small></p>
`
});
// 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();

View File

@ -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();

View File

@ -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
};

View File

@ -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"
}
}

View File

@ -0,0 +1,532 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - Alberta Influence Campaign Tool</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css">
<style>
.admin-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.admin-header {
background: #2c3e50;
color: white;
padding: 2rem 0;
margin-bottom: 2rem;
text-align: center;
}
.admin-nav {
display: flex;
background: #34495e;
border-radius: 8px;
overflow: hidden;
margin-bottom: 2rem;
}
.nav-btn {
flex: 1;
padding: 1rem;
background: none;
border: none;
color: white;
cursor: pointer;
transition: background-color 0.3s;
}
.nav-btn:hover {
background: #3498db;
}
.nav-btn.active {
background: #3498db;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-row {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.checkbox-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.campaign-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.campaign-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.campaign-header h3 {
margin: 0;
color: #2c3e50;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
}
.status-draft {
background: #f39c12;
color: white;
}
.status-active {
background: #27ae60;
color: white;
}
.status-paused {
background: #e74c3c;
color: white;
}
.status-archived {
background: #95a5a6;
color: white;
}
.campaign-meta {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.campaign-meta p {
margin: 0.25rem 0;
}
.campaign-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
font-size: 0.9rem;
transition: background-color 0.3s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn:hover {
opacity: 0.9;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.loading {
text-align: center;
padding: 2rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-radius: 50%;
border-top: 4px solid #3498db;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.hidden {
display: none !important;
}
.message-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.message-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.analytics-stat {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.analytics-stat h3 {
font-size: 2rem;
margin: 0 0 0.5rem 0;
color: #3498db;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
@media (max-width: 768px) {
.admin-nav {
flex-direction: column;
}
.form-grid {
grid-template-columns: 1fr;
}
.campaign-actions {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="admin-header">
<div class="admin-container">
<h1>Campaign Admin Panel</h1>
<p>Manage your influence campaigns</p>
</div>
</div>
<div class="admin-container">
<!-- Navigation -->
<nav class="admin-nav">
<button class="nav-btn active" data-tab="campaigns">Campaigns</button>
<button class="nav-btn" data-tab="create">Create Campaign</button>
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
</nav>
<!-- Success/Error Messages -->
<div id="message-container" class="hidden"></div>
<!-- Campaigns Tab -->
<div id="campaigns-tab" class="tab-content active">
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
<h2 style="margin: 0;">Active Campaigns</h2>
<button class="btn btn-primary" data-action="create-campaign">Create New Campaign</button>
</div>
<div id="campaigns-loading" class="loading hidden">
<div class="spinner"></div>
<p>Loading campaigns...</p>
</div>
<div id="campaigns-list" class="campaign-list">
<!-- Campaigns will be loaded here -->
</div>
</div>
<!-- Create Campaign Tab -->
<div id="create-tab" class="tab-content">
<h2>Create New Campaign</h2>
<form id="create-campaign-form">
<div class="form-grid">
<div class="form-group">
<label for="create-title">Campaign Title *</label>
<input type="text" id="create-title" name="title" required
placeholder="Save Alberta Parks">
</div>
<div class="form-group">
<label for="create-description">Description</label>
<textarea id="create-description" name="description" rows="3"
placeholder="A brief description of the campaign"></textarea>
</div>
</div>
<div class="form-group">
<label for="create-email-subject">Email Subject *</label>
<input type="text" id="create-email-subject" name="email_subject" required
placeholder="Protect Alberta's Provincial Parks">
</div>
<div class="form-group">
<label for="create-email-body">Email Body *</label>
<textarea id="create-email-body" name="email_body" rows="8" required
placeholder="Dear [Representative Name],
I am writing as your constituent to express my concern about...
Sincerely,
[Your Name]"></textarea>
</div>
<div class="form-group">
<label for="create-call-to-action">Call to Action</label>
<textarea id="create-call-to-action" name="call_to_action" rows="3"
placeholder="Join thousands of Albertans in protecting our provincial parks. Send an email to your representatives today!"></textarea>
</div>
<div class="form-group">
<label>Campaign Settings</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="create-allow-smtp" name="allow_smtp_email" checked>
<label for="create-allow-smtp">Allow SMTP Email Sending</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-allow-mailto" name="allow_mailto_link" checked>
<label for="create-allow-mailto">Allow Mailto Links</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-collect-info" name="collect_user_info" checked>
<label for="create-collect-info">Collect User Information</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-show-count" name="show_email_count" checked>
<label for="create-show-count">Show Email Count</label>
</div>
</div>
</div>
<div class="form-group">
<label>Target Government Levels</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="create-federal" name="target_government_levels" value="Federal" checked>
<label for="create-federal">Federal (MPs)</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-provincial" name="target_government_levels" value="Provincial" checked>
<label for="create-provincial">Provincial (MLAs)</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-municipal" name="target_government_levels" value="Municipal" checked>
<label for="create-municipal">Municipal (Mayors, Councillors)</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-school" name="target_government_levels" value="School Board">
<label for="create-school">School Board</label>
</div>
</div>
</div>
<div class="form-row">
<button type="submit" class="btn btn-primary">Create Campaign</button>
<button type="button" class="btn btn-secondary" data-action="cancel-create">Cancel</button>
</div>
</form>
</div>
<!-- Edit Campaign Tab -->
<div id="edit-tab" class="tab-content">
<h2>Edit Campaign</h2>
<form id="edit-campaign-form">
<div class="form-grid">
<div class="form-group">
<label for="edit-title">Campaign Title *</label>
<input type="text" id="edit-title" name="title" required>
</div>
<div class="form-group">
<label for="edit-status">Status</label>
<select id="edit-status" name="status">
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="archived">Archived</option>
</select>
</div>
</div>
<div class="form-group">
<label for="edit-description">Description</label>
<textarea id="edit-description" name="description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="edit-email-subject">Email Subject *</label>
<input type="text" id="edit-email-subject" name="email_subject" required>
</div>
<div class="form-group">
<label for="edit-email-body">Email Body *</label>
<textarea id="edit-email-body" name="email_body" rows="8" required></textarea>
</div>
<div class="form-group">
<label for="edit-call-to-action">Call to Action</label>
<textarea id="edit-call-to-action" name="call_to_action" rows="3"></textarea>
</div>
<div class="form-group">
<label>Campaign Settings</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="edit-allow-smtp" name="allow_smtp_email">
<label for="edit-allow-smtp">Allow SMTP Email Sending</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-allow-mailto" name="allow_mailto_link">
<label for="edit-allow-mailto">Allow Mailto Links</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-collect-info" name="collect_user_info">
<label for="edit-collect-info">Collect User Information</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-show-count" name="show_email_count">
<label for="edit-show-count">Show Email Count</label>
</div>
</div>
</div>
<div class="form-group">
<label>Target Government Levels</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="edit-federal" name="target_government_levels" value="Federal">
<label for="edit-federal">Federal (MPs)</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-provincial" name="target_government_levels" value="Provincial">
<label for="edit-provincial">Provincial (MLAs)</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-municipal" name="target_government_levels" value="Municipal">
<label for="edit-municipal">Municipal (Mayors, Councillors)</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-school" name="target_government_levels" value="School Board">
<label for="edit-school">School Board</label>
</div>
</div>
</div>
<div class="form-row">
<button type="submit" class="btn btn-primary">Update Campaign</button>
<button type="button" class="btn btn-secondary" data-action="cancel-edit">Cancel</button>
</div>
</form>
</div>
</div>
<script src="js/api-client.js"></script>
<script src="js/auth.js"></script>
<script src="js/admin.js"></script>
</body>
</html>

View File

@ -0,0 +1,636 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Campaign Admin Panel - Alberta Influence Tool</title>
<link rel="stylesheet" href="css/styles.css">
<style>
/* Admin-specific styles */
.admin-header {
background: #2c3e50;
color: white;
padding: 1rem;
margin-bottom: 2rem;
}
.admin-nav {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.admin-nav button {
padding: 0.5rem 1rem;
border: none;
background: #34495e;
color: white;
cursor: pointer;
border-radius: 4px;
}
.admin-nav button.active {
background: #3498db;
}
.campaign-list {
display: grid;
gap: 1rem;
margin-bottom: 2rem;
}
.campaign-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: #f9f9f9;
}
.campaign-card h3 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.campaign-status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
}
.status-active { background: #d4edda; color: #155724; }
.status-draft { background: #fff3cd; color: #856404; }
.status-paused { background: #f8d7da; color: #721c24; }
.status-archived { background: #e2e3e5; color: #383d41; }
.campaign-actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
.campaign-actions button {
padding: 0.25rem 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.btn-edit { background: #3498db; color: white; }
.btn-view { background: #95a5a6; color: white; }
.btn-analytics { background: #f39c12; color: white; }
.btn-delete { background: #e74c3c; color: white; }
.campaign-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.form-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row .form-group {
flex: 1;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.analytics-card {
background: white;
padding: 1rem;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.analytics-number {
font-size: 2rem;
font-weight: bold;
color: #3498db;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 90%;
max-height: 90%;
overflow-y: auto;
width: 600px;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="admin-header">
<h1>Campaign Admin Panel</h1>
<p>Manage your influence campaigns and track engagement</p>
</div>
<div class="container">
<nav class="admin-nav">
<button class="nav-btn active" data-tab="campaigns">Campaigns</button>
<button class="nav-btn" data-tab="create">Create Campaign</button>
<button class="nav-btn" data-tab="analytics">Analytics</button>
</nav>
<!-- Success/Error Messages -->
<div id="message-container" class="hidden"></div>
<!-- Campaigns Tab -->
<div id="campaigns-tab" class="tab-content active">
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
<h2 style="margin: 0;">Active Campaigns</h2>
<button class="btn btn-primary" data-action="create-campaign">Create New Campaign</button>
</div>
<div id="campaigns-loading" class="loading hidden">
<div class="spinner"></div>
<p>Loading campaigns...</p>
</div>
<div id="campaigns-list" class="campaign-list">
<!-- Campaigns will be loaded here -->
</div>
</div>
<!-- Create Campaign Tab -->
<div id="create-tab" class="tab-content">
<h2 id="form-title">Create New Campaign</h2>
<form id="campaign-form" class="campaign-form">
<input type="hidden" id="campaign-id" name="id">
<div class="form-group">
<label for="title">Campaign Title *</label>
<input type="text" id="title" name="title" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="3" placeholder="Brief description of the campaign purpose"></textarea>
</div>
<div class="form-group">
<label for="call_to_action">Call to Action</label>
<textarea id="call_to_action" name="call_to_action" rows="2" placeholder="Motivational text to encourage participation"></textarea>
</div>
<div class="form-group">
<label for="email_subject">Email Subject *</label>
<input type="text" id="email_subject" name="email_subject" required placeholder="Subject line for the email to representatives">
</div>
<div class="form-group">
<label for="email_body">Email Body *</label>
<textarea id="email_body" name="email_body" rows="8" required placeholder="The message that will be sent to representatives"></textarea>
</div>
<div class="form-group">
<label>Target Government Levels</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="federal" name="target_government_levels" value="Federal" checked>
<label for="federal">Federal MPs</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="provincial" name="target_government_levels" value="Provincial" checked>
<label for="provincial">Provincial MLAs</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="municipal" name="target_government_levels" value="Municipal" checked>
<label for="municipal">Municipal Officials</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="school" name="target_government_levels" value="School Board">
<label for="school">School Board</label>
</div>
</div>
</div>
<div class="form-group">
<label>Campaign Settings</label>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="allow_smtp_email" name="allow_smtp_email" checked>
<label for="allow_smtp_email">Allow SMTP Email Sending</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="allow_mailto_link" name="allow_mailto_link" checked>
<label for="allow_mailto_link">Allow Mailto Links</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="collect_user_info" name="collect_user_info" checked>
<label for="collect_user_info">Collect User Information</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="show_email_count" name="show_email_count" checked>
<label for="show_email_count">Show Email Count on Campaign Page</label>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status">
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="archived">Archived</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Campaign</button>
<button type="button" class="btn btn-secondary" onclick="resetForm()">Reset</button>
<button type="button" class="btn btn-secondary" onclick="switchTab('campaigns')">Cancel</button>
</div>
</form>
</div>
<!-- Analytics Tab -->
<div id="analytics-tab" class="tab-content">
<h2>Campaign Analytics</h2>
<div id="analytics-loading" class="loading hidden">
<div class="spinner"></div>
<p>Loading analytics...</p>
</div>
<div id="analytics-content">
<!-- Analytics will be loaded here -->
</div>
</div>
</div>
<!-- Campaign Analytics Modal -->
<div id="analytics-modal" class="modal">
<div class="modal-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h2 id="analytics-modal-title">Campaign Analytics</h2>
<button onclick="closeAnalyticsModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="analytics-modal-content">
<!-- Analytics content will be loaded here -->
</div>
</div>
</div>
<script src="js/api-client.js"></script>
<script src="js/admin.js"></script>
</body>
</html>
constructor() {
this.currentCampaign = null;
this.campaigns = [];
this.init();
}
init() {
// Tab navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
this.switchTab(tab);
});
});
// Form submission
document.getElementById('campaign-form').addEventListener('submit', (e) => {
this.handleFormSubmit(e);
});
// Load campaigns on init
this.loadCampaigns();
}
switchTab(tab) {
// Update nav buttons
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
// Show/hide tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tab}-tab`).classList.add('active');
// Load data for specific tabs
if (tab === 'campaigns') {
this.loadCampaigns();
} else if (tab === 'analytics') {
this.loadOverallAnalytics();
}
}
async loadCampaigns() {
const loading = document.getElementById('campaigns-loading');
const list = document.getElementById('campaigns-list');
loading.classList.remove('hidden');
try {
const response = await window.apiClient.get('/admin/campaigns');
this.campaigns = response.campaigns || [];
this.renderCampaigns();
} catch (error) {
this.showMessage('Failed to load campaigns: ' + error.message, 'error');
} finally {
loading.classList.add('hidden');
}
}
renderCampaigns() {
const list = document.getElementById('campaigns-list');
if (this.campaigns.length === 0) {
list.innerHTML = '<p>No campaigns found. <a href="#" onclick="adminPanel.switchTab(\'create\')">Create your first campaign</a></p>';
return;
}
list.innerHTML = this.campaigns.map(campaign => `
<div class="campaign-card">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div>
<h3>${campaign.title}</h3>
<p>${campaign.description || 'No description'}</p>
<div style="margin: 0.5rem 0;">
<span class="campaign-status status-${campaign.status}">${campaign.status}</span>
<span style="margin-left: 1rem; color: #666;">
📧 ${campaign.emailCount || 0} emails sent
</span>
</div>
<p style="color: #666; font-size: 0.9rem;">
Campaign URL: <code>/campaign/${campaign.slug}</code>
</p>
</div>
</div>
<div class="campaign-actions">
<button class="btn-edit" onclick="adminPanel.editCampaign(${campaign.id})">Edit</button>
<button class="btn-view" onclick="window.open('/campaign/${campaign.slug}', '_blank')">View</button>
<button class="btn-analytics" onclick="adminPanel.showCampaignAnalytics(${campaign.id})">Analytics</button>
<button class="btn-delete" onclick="adminPanel.deleteCampaign(${campaign.id})">Delete</button>
</div>
</div>
`).join('');
}
async editCampaign(id) {
try {
const response = await window.apiClient.get(`/admin/campaigns/${id}`);
const campaign = response.campaign;
this.currentCampaign = campaign;
this.populateForm(campaign);
this.switchTab('create');
document.getElementById('form-title').textContent = 'Edit Campaign';
} catch (error) {
this.showMessage('Failed to load campaign: ' + error.message, 'error');
}
}
populateForm(campaign) {
document.getElementById('campaign-id').value = campaign.id;
document.getElementById('title').value = campaign.title;
document.getElementById('description').value = campaign.description || '';
document.getElementById('call_to_action').value = campaign.call_to_action || '';
document.getElementById('email_subject').value = campaign.email_subject;
document.getElementById('email_body').value = campaign.email_body;
document.getElementById('status').value = campaign.status;
// Handle checkboxes
document.getElementById('allow_smtp_email').checked = campaign.allow_smtp_email;
document.getElementById('allow_mailto_link').checked = campaign.allow_mailto_link;
document.getElementById('collect_user_info').checked = campaign.collect_user_info;
document.getElementById('show_email_count').checked = campaign.show_email_count;
// Handle target levels
document.querySelectorAll('input[name="target_government_levels"]').forEach(cb => cb.checked = false);
if (campaign.target_government_levels) {
const levels = campaign.target_government_levels.split(',');
levels.forEach(level => {
const checkbox = document.querySelector(`input[name="target_government_levels"][value="${level.trim()}"]`);
if (checkbox) checkbox.checked = true;
});
}
}
async handleFormSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const data = {};
// Handle regular fields
for (let [key, value] of formData.entries()) {
if (key !== 'target_government_levels') {
data[key] = value;
}
}
// Handle checkboxes
data.allow_smtp_email = document.getElementById('allow_smtp_email').checked;
data.allow_mailto_link = document.getElementById('allow_mailto_link').checked;
data.collect_user_info = document.getElementById('collect_user_info').checked;
data.show_email_count = document.getElementById('show_email_count').checked;
// Handle target government levels
const selectedLevels = [];
document.querySelectorAll('input[name="target_government_levels"]:checked').forEach(cb => {
selectedLevels.push(cb.value);
});
data.target_government_levels = selectedLevels;
try {
const campaignId = document.getElementById('campaign-id').value;
let response;
if (campaignId) {
// Update existing campaign
response = await window.apiClient.makeRequest(`/api/admin/campaigns/${campaignId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
} else {
// Create new campaign
response = await window.apiClient.post('/admin/campaigns', data);
}
this.showMessage('Campaign saved successfully!', 'success');
this.resetForm();
this.switchTab('campaigns');
} catch (error) {
this.showMessage('Failed to save campaign: ' + error.message, 'error');
}
}
resetForm() {
document.getElementById('campaign-form').reset();
document.getElementById('campaign-id').value = '';
document.getElementById('form-title').textContent = 'Create New Campaign';
this.currentCampaign = null;
}
async deleteCampaign(id) {
if (!confirm('Are you sure you want to delete this campaign? This action cannot be undone.')) {
return;
}
try {
await window.apiClient.makeRequest(`/api/admin/campaigns/${id}`, {
method: 'DELETE'
});
this.showMessage('Campaign deleted successfully!', 'success');
this.loadCampaigns();
} catch (error) {
this.showMessage('Failed to delete campaign: ' + error.message, 'error');
}
}
async showCampaignAnalytics(id) {
try {
const response = await window.apiClient.get(`/admin/campaigns/${id}/analytics`);
const analytics = response.analytics;
const campaign = this.campaigns.find(c => c.id === id);
document.getElementById('analytics-modal-title').textContent = `Analytics: ${campaign.title}`;
document.getElementById('analytics-modal-content').innerHTML = this.renderAnalytics(analytics);
document.getElementById('analytics-modal').style.display = 'block';
} catch (error) {
this.showMessage('Failed to load analytics: ' + error.message, 'error');
}
}
renderAnalytics(analytics) {
return `
<div class="analytics-grid">
<div class="analytics-card">
<div class="analytics-number">${analytics.totalEmails}</div>
<div>Total Emails</div>
</div>
<div class="analytics-card">
<div class="analytics-number">${analytics.smtpEmails}</div>
<div>SMTP Sent</div>
</div>
<div class="analytics-card">
<div class="analytics-number">${analytics.mailtoClicks}</div>
<div>Mailto Clicks</div>
</div>
<div class="analytics-card">
<div class="analytics-number">${analytics.successfulEmails}</div>
<div>Successful</div>
</div>
</div>
<h3>By Government Level</h3>
<div class="analytics-grid">
${Object.entries(analytics.byLevel).map(([level, count]) => `
<div class="analytics-card">
<div class="analytics-number">${count}</div>
<div>${level}</div>
</div>
`).join('')}
</div>
<h3>Recent Activity</h3>
<div style="max-height: 300px; overflow-y: auto;">
${analytics.recentEmails.map(email => `
<div style="border-bottom: 1px solid #eee; padding: 0.5rem 0;">
<strong>${email.user_name || 'Anonymous'}</strong> →
<strong>${email.recipient_name}</strong> (${email.recipient_level})
<br>
<small>${email.timestamp} • ${email.email_method} • ${email.status}</small>
</div>
`).join('')}
</div>
`;
}
showMessage(message, type) {
const container = document.getElementById('message-container');
container.innerHTML = `<div class="${type}-message">${message}</div>`;
container.classList.remove('hidden');
setTimeout(() => {
container.classList.add('hidden');
}, 5000);
}
}
// Global functions
function switchTab(tab) {
window.adminPanel.switchTab(tab);
}
function resetForm() {
window.adminPanel.resetForm();
}
function closeAnalyticsModal() {
document.getElementById('analytics-modal').style.display = 'none';
}
// Initialize admin panel
document.addEventListener('DOMContentLoaded', () => {
window.adminPanel = new AdminPanel();
});
</script>
</body>
</html>

View File

@ -0,0 +1,671 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="page-title">Campaign - Alberta Influence Tool</title>
<link rel="stylesheet" href="/css/styles.css">
<style>
.campaign-header {
background: linear-gradient(135deg, #3498db, #2c3e50);
color: white;
padding: 3rem 0;
text-align: center;
margin-bottom: 2rem;
}
.campaign-header h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.campaign-stats {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
text-align: center;
margin-bottom: 2rem;
border: 2px solid #e9ecef;
}
.email-count {
font-size: 2rem;
font-weight: bold;
color: #3498db;
margin-bottom: 0.5rem;
}
.campaign-content {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem;
}
.call-to-action {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: center;
}
.user-info-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.email-preview {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.email-preview h3 {
color: #495057;
margin-bottom: 1rem;
}
.email-subject {
font-weight: bold;
color: #2c3e50;
margin-bottom: 1rem;
}
.email-body {
line-height: 1.6;
color: #495057;
white-space: pre-wrap;
}
.representatives-grid {
display: grid;
gap: 1rem;
margin-bottom: 2rem;
}
.rep-card {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
background: white;
transition: transform 0.2s, box-shadow 0.2s;
}
.rep-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.rep-info {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.rep-photo {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
background: #e9ecef;
}
.rep-details h4 {
margin: 0 0 0.25rem 0;
color: #2c3e50;
}
.rep-details p {
margin: 0;
color: #6c757d;
font-size: 0.9rem;
}
.rep-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.email-method-toggle {
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 1rem;
}
.method-option {
display: flex;
align-items: center;
gap: 0.5rem;
}
.progress-steps {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.step {
flex: 1;
text-align: center;
position: relative;
padding: 0.5rem;
}
.step.active {
color: #3498db;
font-weight: bold;
}
.step.completed {
color: #27ae60;
}
.step:not(:last-child)::after {
content: '→';
position: absolute;
right: -50%;
color: #6c757d;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loading-content {
background: white;
padding: 2rem;
border-radius: 8px;
text-align: center;
}
@media (max-width: 768px) {
.campaign-header h1 {
font-size: 2rem;
}
.progress-steps {
flex-direction: column;
gap: 0.5rem;
}
.step:not(:last-child)::after {
content: '↓';
position: static;
}
}
</style>
</head>
<body>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay">
<div class="loading-content">
<div class="spinner"></div>
<p id="loading-message">Loading campaign...</p>
</div>
</div>
<!-- Campaign Header -->
<div class="campaign-header">
<div class="container">
<h1 id="campaign-title">Loading Campaign...</h1>
<p id="campaign-description"></p>
</div>
</div>
<div class="campaign-content">
<!-- Email Count Display -->
<div id="campaign-stats" class="campaign-stats" style="display: none;">
<div class="email-count" id="email-count">0</div>
<p>Albertans have sent emails through this campaign</p>
</div>
<!-- Call to Action -->
<div id="call-to-action" class="call-to-action" style="display: none;">
<!-- Content will be loaded dynamically -->
</div>
<!-- Progress Steps -->
<div class="progress-steps">
<div class="step active" id="step-info">Enter Your Info</div>
<div class="step" id="step-postal">Find Representatives</div>
<div class="step" id="step-send">Send Emails</div>
</div>
<!-- User Information Form -->
<div id="user-info-section" class="user-info-form">
<h2>Your Information</h2>
<p>We need some basic information to find your representatives and track campaign engagement.</p>
<form id="user-info-form">
<div class="form-group">
<label for="user-postal-code">Your Postal Code *</label>
<input type="text" id="user-postal-code" name="postalCode" required
placeholder="T5K 2M5" maxlength="7" style="text-transform: uppercase;">
</div>
<div id="optional-fields" style="display: none;">
<div class="form-group">
<label for="user-name">Your Name (Optional)</label>
<input type="text" id="user-name" name="userName" placeholder="Your full name">
</div>
<div class="form-group">
<label for="user-email">Your Email (Optional)</label>
<input type="email" id="user-email" name="userEmail" placeholder="your@email.com">
</div>
</div>
<button type="submit" class="btn btn-primary">Find My Representatives</button>
</form>
</div>
<!-- Email Preview -->
<div id="email-preview" class="email-preview" style="display: none;">
<h3>📧 Email Preview</h3>
<p>This is the message that will be sent to your representatives:</p>
<div class="email-subject" id="preview-subject"></div>
<div class="email-body" id="preview-body"></div>
</div>
<!-- Representatives Section -->
<div id="representatives-section" style="display: none;">
<h2>Your Representatives</h2>
<p>Select how you'd like to contact each representative:</p>
<!-- Email Method Selection -->
<div id="email-method-selection" class="email-method-toggle">
<div class="method-option">
<input type="radio" id="method-smtp" name="emailMethod" value="smtp" checked>
<label for="method-smtp">📧 Send via our system</label>
</div>
<div class="method-option">
<input type="radio" id="method-mailto" name="emailMethod" value="mailto">
<label for="method-mailto">📬 Open in your email app</label>
</div>
</div>
<div id="representatives-list" class="representatives-grid">
<!-- Representatives will be loaded here -->
</div>
</div>
<!-- Success Message -->
<div id="success-section" style="display: none; text-align: center; padding: 2rem;">
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>
<p>Your emails have been processed. Democracy works when people like you get involved.</p>
<button class="btn btn-secondary" data-action="reload-page">Send More Emails</button>
</div>
<!-- Error Messages -->
<div id="error-message" class="error-message" style="display: none;"></div>
</div>
<script>
class CampaignPage {
constructor() {
this.campaign = null;
this.representatives = [];
this.userInfo = {};
this.currentStep = 1;
this.init();
}
init() {
// Get campaign slug from URL
const pathParts = window.location.pathname.split('/');
this.campaignSlug = pathParts[pathParts.length - 1];
// Set up form handlers
document.getElementById('user-info-form').addEventListener('submit', (e) => {
this.handleUserInfoSubmit(e);
});
// Postal code formatting
document.getElementById('user-postal-code').addEventListener('input', (e) => {
this.formatPostalCode(e);
});
// Load campaign data
this.loadCampaign();
}
async loadCampaign() {
this.showLoading('Loading campaign...');
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load campaign');
}
this.campaign = data.campaign;
this.renderCampaign();
} catch (error) {
this.showError('Failed to load campaign: ' + error.message);
} finally {
this.hideLoading();
}
}
renderCampaign() {
// Update page title and meta
document.getElementById('page-title').textContent = this.campaign.title + ' - Alberta Influence Tool';
document.getElementById('campaign-title').textContent = this.campaign.title;
document.getElementById('campaign-description').textContent = this.campaign.description || '';
// Show email count if enabled
if (this.campaign.show_email_count && this.campaign.emailCount !== null) {
document.getElementById('email-count').textContent = this.campaign.emailCount;
document.getElementById('campaign-stats').style.display = 'block';
}
// Show call to action if present
if (this.campaign.call_to_action) {
document.getElementById('call-to-action').innerHTML = `<p>${this.campaign.call_to_action}</p>`;
document.getElementById('call-to-action').style.display = 'block';
}
// Show/hide optional user info fields
if (this.campaign.collect_user_info) {
document.getElementById('optional-fields').style.display = 'block';
}
// Show email preview
document.getElementById('preview-subject').textContent = this.campaign.email_subject;
document.getElementById('preview-body').textContent = this.campaign.email_body;
document.getElementById('email-preview').style.display = 'block';
// Configure email method options
if (!this.campaign.allow_smtp_email) {
document.getElementById('method-smtp').disabled = true;
document.getElementById('method-mailto').checked = true;
}
if (!this.campaign.allow_mailto_link) {
document.getElementById('method-mailto').disabled = true;
document.getElementById('method-smtp').checked = true;
}
}
formatPostalCode(e) {
let value = e.target.value.replace(/\s/g, '').toUpperCase();
if (value.length > 3) {
value = value.substring(0, 3) + ' ' + value.substring(3, 6);
}
e.target.value = value;
}
async handleUserInfoSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
this.userInfo = {
postalCode: formData.get('postalCode').replace(/\s/g, '').toUpperCase(),
userName: formData.get('userName') || '',
userEmail: formData.get('userEmail') || ''
};
await this.loadRepresentatives();
}
async loadRepresentatives() {
this.showLoading('Finding your representatives...');
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load representatives');
}
this.representatives = data.representatives;
this.renderRepresentatives();
this.setStep(2);
// Scroll to representatives section
document.getElementById('representatives-section').scrollIntoView({
behavior: 'smooth'
});
} catch (error) {
this.showError('Failed to load representatives: ' + error.message);
} finally {
this.hideLoading();
}
}
renderRepresentatives() {
const list = document.getElementById('representatives-list');
if (this.representatives.length === 0) {
list.innerHTML = '<p>No representatives found for your area. Please check your postal code.</p>';
return;
}
list.innerHTML = this.representatives.map(rep => `
<div class="rep-card">
<div class="rep-info">
${rep.photo_url ?
`<img src="${rep.photo_url}" alt="${rep.name}" class="rep-photo">` :
`<div class="rep-photo"></div>`
}
<div class="rep-details">
<h4>${rep.name}</h4>
<p>${rep.elected_office || 'Representative'}</p>
<p>${rep.party_name || ''}</p>
${rep.email ? `<p>📧 ${rep.email}</p>` : ''}
</div>
</div>
${rep.email ? `
<div class="rep-actions">
<button class="btn btn-primary" data-action="send-email"
data-email="${rep.email}"
data-name="${rep.name}"
data-title="${rep.elected_office || ''}"
data-level="${this.getGovernmentLevel(rep)}">
Send Email
</button>
</div>
` : '<p style="text-align: center; color: #6c757d;">No email available</p>'}
</div>
`).join('');
// Attach event listeners to send email buttons
this.attachEmailButtonListeners();
document.getElementById('representatives-section').style.display = 'block';
}
attachEmailButtonListeners() {
// Send email buttons
document.querySelectorAll('[data-action="send-email"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const email = e.target.dataset.email;
const name = e.target.dataset.name;
const title = e.target.dataset.title;
const level = e.target.dataset.level;
this.sendEmail(email, name, title, level);
});
});
// Reload page button
const reloadBtn = document.querySelector('[data-action="reload-page"]');
if (reloadBtn) {
reloadBtn.addEventListener('click', () => {
location.reload();
});
}
}
getGovernmentLevel(rep) {
const office = (rep.elected_office || '').toLowerCase();
if (office.includes('mp') || office.includes('member of parliament')) return 'Federal';
if (office.includes('mla') || office.includes('legislative assembly')) return 'Provincial';
if (office.includes('mayor') || office.includes('councillor')) return 'Municipal';
if (office.includes('school')) return 'School Board';
return 'Other';
}
async sendEmail(recipientEmail, recipientName, recipientTitle, recipientLevel) {
const emailMethod = document.querySelector('input[name="emailMethod"]:checked').value;
if (emailMethod === 'mailto') {
this.openMailtoLink(recipientEmail);
} else {
await this.sendSMTPEmail(recipientEmail, recipientName, recipientTitle, recipientLevel);
}
}
openMailtoLink(recipientEmail) {
const subject = encodeURIComponent(this.campaign.email_subject);
const body = encodeURIComponent(this.campaign.email_body);
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
// Track the mailto click
this.trackEmail(recipientEmail, '', '', '', 'mailto');
window.open(mailtoUrl);
}
async sendSMTPEmail(recipientEmail, recipientName, recipientTitle, recipientLevel) {
this.showLoading('Sending email...');
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod: 'smtp'
})
});
const data = await response.json();
if (data.success) {
this.showSuccess('Email sent successfully!');
} else {
throw new Error(data.error || 'Failed to send email');
}
} catch (error) {
this.showError('Failed to send email: ' + error.message);
} finally {
this.hideLoading();
}
}
async trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, emailMethod) {
try {
await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod
})
});
} catch (error) {
console.error('Failed to track email:', error);
}
}
setStep(step) {
// Reset all steps
document.querySelectorAll('.step').forEach(s => {
s.classList.remove('active', 'completed');
});
// Mark completed steps
for (let i = 1; i < step; i++) {
document.getElementById(`step-${this.getStepName(i)}`).classList.add('completed');
}
// Mark current step
document.getElementById(`step-${this.getStepName(step)}`).classList.add('active');
this.currentStep = step;
}
getStepName(step) {
const steps = ['', 'info', 'postal', 'send'];
return steps[step] || 'info';
}
showLoading(message) {
document.getElementById('loading-message').textContent = message;
document.getElementById('loading-overlay').style.display = 'flex';
}
hideLoading() {
document.getElementById('loading-overlay').style.display = 'none';
}
showError(message) {
const errorDiv = document.getElementById('error-message');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
setTimeout(() => {
errorDiv.style.display = 'none';
}, 5000);
}
showSuccess(message) {
// Update email count if enabled
if (this.campaign.show_email_count) {
const countElement = document.getElementById('email-count');
const currentCount = parseInt(countElement.textContent) || 0;
countElement.textContent = currentCount + 1;
}
// You could show a toast or update UI to indicate success
alert(message); // Simple for now, could be improved with better UI
}
}
// Initialize the campaign page
let campaignPage;
document.addEventListener('DOMContentLoaded', () => {
campaignPage = new CampaignPage();
});
</script>
</body>
</html>

View File

@ -0,0 +1,455 @@
* {
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: 40px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
header h1 {
color: #005a9c;
margin-bottom: 10px;
font-size: 2.5em;
}
header p {
color: #666;
font-size: 1.1em;
}
/* 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: 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.3s ease;
}
.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);
}
.input-group {
display: flex;
gap: 10px;
align-items: flex-start;
}
.input-group input {
flex: 1;
}
#postal-form {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 600px;
margin: 0 auto;
}
/* Buttons */
.btn {
padding: 12px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #005a9c;
color: white;
}
.btn-primary:hover {
background-color: #004a7c;
transform: translateY(-1px);
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* 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;
}
/* Success Messages */
.success-message {
background-color: #d4edda;
color: #155724;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #c3e6cb;
}
/* Representatives Section */
#representatives-section {
margin-top: 40px;
}
.location-info {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.location-info h3 {
color: #005a9c;
margin-bottom: 10px;
}
.rep-category {
margin-bottom: 40px;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.rep-category h3 {
color: #005a9c;
margin: 0 0 20px 0;
padding-bottom: 10px;
border-bottom: 2px solid #e9ecef;
}
.rep-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.rep-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
background: #fafafa;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
gap: 20px;
align-items: flex-start;
}
.rep-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.rep-photo {
flex-shrink: 0;
width: 80px;
height: 80px;
position: relative;
}
.rep-photo img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
border: 2px solid #005a9c;
}
.rep-photo-fallback {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(135deg, #005a9c, #007acc);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2em;
border: 2px solid #005a9c;
}
.rep-content {
flex: 1;
min-width: 0;
}
.rep-card h4 {
color: #005a9c;
margin-bottom: 10px;
font-size: 1.2em;
}
.rep-card .rep-info {
margin-bottom: 15px;
}
.rep-card .rep-info p {
margin: 5px 0;
color: #666;
}
.rep-card .rep-info strong {
color: #333;
}
.rep-card .rep-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* Modal Styles */
.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;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #ddd;
}
.modal-header h3 {
color: #005a9c;
margin: 0;
}
.close-btn {
font-size: 28px;
font-weight: bold;
cursor: pointer;
color: #999;
transition: color 0.3s ease;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 20px;
}
.recipient-info {
background-color: #f8f9fa;
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
border-left: 4px solid #005a9c;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.char-counter {
color: #666;
font-size: 0.9em;
text-align: right;
display: block;
margin-top: 5px;
}
/* Message Display */
.message-display {
position: fixed;
top: 20px;
right: 20px;
max-width: 400px;
padding: 15px;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
}
.message-display.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message-display.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* Footer */
footer {
margin-top: 60px;
text-align: center;
padding: 30px 20px;
color: #666;
border-top: 1px solid #ddd;
}
footer a {
color: #005a9c;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 10px;
}
header h1 {
font-size: 2em;
}
.input-group {
flex-direction: column;
}
.rep-cards {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column-reverse;
}
.modal-content {
width: 95%;
margin: 10px;
}
.message-display {
left: 10px;
right: 10px;
max-width: none;
}
}
@media (max-width: 480px) {
.rep-card {
flex-direction: column;
align-items: center;
text-align: center;
gap: 15px;
}
.rep-photo {
width: 100px;
height: 100px;
}
.rep-card .rep-actions {
flex-direction: column;
width: 100%;
}
.btn {
width: 100%;
text-align: center;
}
}

View File

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alberta Influence Campaign Tool</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Alberta Influence Campaign Tool</h1>
<p>Connect with your elected representatives across all levels of government</p>
</header>
<main>
<!-- Postal Code Lookup Section -->
<section id="postal-lookup">
<h2>Find Your Representatives</h2>
<form id="postal-form">
<div class="form-group">
<label for="postal-code">Enter your Alberta postal code:</label>
<div class="input-group">
<input
type="text"
id="postal-code"
name="postal-code"
placeholder="T5K 2M5"
maxlength="7"
required
pattern="^[Tt]\d[A-Za-z]\s?\d[A-Za-z]\d$"
title="Please enter a valid Alberta postal code (starting with T)"
>
<button type="submit" class="btn btn-primary">Search</button>
<button type="button" id="refresh-btn" class="btn btn-secondary" style="display: none;">Refresh</button>
</div>
</div>
</form>
<div id="error-message" class="error-message" style="display: none;"></div>
<div id="loading" class="loading" style="display: none;">
<div class="spinner"></div>
<p>Looking up your representatives...</p>
</div>
</section>
<!-- Representatives Display Section -->
<section id="representatives-section" style="display: none;">
<div id="location-info" class="location-info">
<h3>Your Location</h3>
<p id="location-details"></p>
</div>
<div id="representatives-container">
<!-- Representatives will be dynamically inserted here -->
</div>
</section>
<!-- Email Compose Modal -->
<div id="email-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Compose Email</h3>
<span class="close-btn" id="close-modal">&times;</span>
</div>
<div class="modal-body">
<form id="email-form">
<input type="hidden" id="recipient-email" name="recipient-email">
<input type="hidden" id="sender-postal-code" name="sender-postal-code">
<div class="form-group">
<label for="recipient-info">To:</label>
<div id="recipient-info" class="recipient-info"></div>
</div>
<div class="form-group">
<label for="sender-name">Your Name:</label>
<input type="text" id="sender-name" name="sender-name" required maxlength="100">
</div>
<div class="form-group">
<label for="sender-email">Your Email:</label>
<input type="email" id="sender-email" name="sender-email" required maxlength="200">
</div>
<div class="form-group">
<label for="email-subject">Subject:</label>
<input type="text" id="email-subject" name="email-subject" required maxlength="200">
</div>
<div class="form-group">
<label for="email-message">Message:</label>
<textarea
id="email-message"
name="email-message"
rows="10"
required
maxlength="5000"
placeholder="Write your message to your representative here..."
></textarea>
<small class="char-counter">5000 characters remaining</small>
</div>
<div class="form-actions">
<button type="button" id="cancel-email" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Send Email</button>
</div>
</form>
</div>
</div>
</div>
<!-- Success/Error Messages -->
<div id="message-display" class="message-display" style="display: none;"></div>
</main>
<footer>
<p>&copy; 2025 Alberta Influence Campaign Tool. Connect with democracy.</p>
<p><small>This tool uses the <a href="https://represent.opennorth.ca" target="_blank">Represent API</a> by Open North to find your representatives.</small></p>
</footer>
</div>
<script src="js/api-client.js"></script>
<script src="js/postal-lookup.js"></script>
<script src="js/representatives-display.js"></script>
<script src="js/email-composer.js"></script>
<script src="js/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,511 @@
// Admin Panel JavaScript
class AdminPanel {
constructor() {
this.currentCampaign = null;
this.campaigns = [];
this.authManager = null;
}
async init() {
// Check authentication first
if (typeof authManager !== 'undefined') {
this.authManager = authManager;
const isAuth = await this.authManager.checkSession();
if (!isAuth || !this.authManager.user?.isAdmin) {
window.location.href = '/login.html';
return;
}
this.setupUserInterface();
} else {
// Fallback if authManager not loaded
window.location.href = '/login.html';
return;
}
this.setupEventListeners();
this.setupFormInteractions();
this.loadCampaigns();
}
setupUserInterface() {
// Add user info to header
const adminHeader = document.querySelector('.admin-header .admin-container');
if (adminHeader && this.authManager.user) {
const userInfo = document.createElement('div');
userInfo.style.cssText = 'position: absolute; top: 1rem; right: 2rem; color: white; font-size: 0.9rem;';
userInfo.innerHTML = `
Welcome, ${this.authManager.user.name || this.authManager.user.email}
<button id="logout-btn" style="margin-left: 1rem; padding: 0.5rem 1rem; background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); color: white; border-radius: 4px; cursor: pointer;">Logout</button>
`;
adminHeader.style.position = 'relative';
adminHeader.appendChild(userInfo);
// Add logout event listener
document.getElementById('logout-btn').addEventListener('click', () => {
this.authManager.logout();
});
}
}
setupEventListeners() {
}
setupEventListeners() {
// Tab navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
this.switchTab(tab);
});
});
// Form submissions
document.getElementById('create-campaign-form').addEventListener('submit', (e) => {
this.handleCreateCampaign(e);
});
document.getElementById('edit-campaign-form').addEventListener('submit', (e) => {
this.handleUpdateCampaign(e);
});
// Cancel buttons - using event delegation for proper handling
document.addEventListener('click', (e) => {
if (e.target.matches('[data-action="cancel-create"]')) {
this.switchTab('campaigns');
}
if (e.target.matches('[data-action="cancel-edit"]')) {
this.switchTab('campaigns');
}
});
this.loadCampaigns();
}
setupFormInteractions() {
// Create campaign button
const createBtn = document.querySelector('[data-action="create-campaign"]');
if (createBtn) {
createBtn.addEventListener('click', () => this.switchTab('create'));
}
// Cancel buttons
const cancelCreateBtn = document.querySelector('[data-action="cancel-create"]');
if (cancelCreateBtn) {
cancelCreateBtn.addEventListener('click', () => this.switchTab('campaigns'));
}
const cancelEditBtn = document.querySelector('[data-action="cancel-edit"]');
if (cancelEditBtn) {
cancelEditBtn.addEventListener('click', () => this.switchTab('campaigns'));
}
// Handle checkbox changes for government levels
document.querySelectorAll('input[name="target_government_levels"]').forEach(checkbox => {
checkbox.addEventListener('change', () => {
this.updateGovernmentLevelsPreview();
});
});
// Handle settings toggles
document.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', () => {
this.handleSettingsChange(checkbox);
});
});
}
switchTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
// Remove active class from nav buttons
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.remove('active');
});
// Show selected tab
const targetTab = document.getElementById(`${tabName}-tab`);
if (targetTab) {
targetTab.classList.add('active');
}
// Update nav button
const targetNavBtn = document.querySelector(`[data-tab="${tabName}"]`);
if (targetNavBtn) {
targetNavBtn.classList.add('active');
}
// Special handling for different tabs
if (tabName === 'campaigns') {
this.loadCampaigns();
} else if (tabName === 'edit' && this.currentCampaign) {
this.populateEditForm();
}
}
async loadCampaigns() {
const loadingDiv = document.getElementById('campaigns-loading');
const listDiv = document.getElementById('campaigns-list');
loadingDiv.classList.remove('hidden');
listDiv.innerHTML = '';
try {
const response = await window.apiClient.get('/admin/campaigns');
if (response.success) {
this.campaigns = response.campaigns;
this.renderCampaignList();
} else {
throw new Error(response.error || 'Failed to load campaigns');
}
} catch (error) {
console.error('Load campaigns error:', error);
this.showMessage('Failed to load campaigns: ' + error.message, 'error');
} finally {
loadingDiv.classList.add('hidden');
}
}
renderCampaignList() {
const listDiv = document.getElementById('campaigns-list');
if (this.campaigns.length === 0) {
listDiv.innerHTML = `
<div class="empty-state">
<h3>No campaigns yet</h3>
<p>Create your first campaign to get started.</p>
</div>
`;
return;
}
listDiv.innerHTML = this.campaigns.map(campaign => `
<div class="campaign-card" data-campaign-id="${campaign.id}">
<div class="campaign-header">
<h3>${this.escapeHtml(campaign.title)}</h3>
<span class="status-badge status-${campaign.status}">${campaign.status}</span>
</div>
<div class="campaign-meta">
<p><strong>Slug:</strong> <code>/campaign/${campaign.slug}</code></p>
<p><strong>Email Count:</strong> ${campaign.emailCount || 0}</p>
<p><strong>Created:</strong> ${this.formatDate(campaign.created_at)}</p>
</div>
<div class="campaign-actions">
<button class="btn btn-secondary" data-action="edit-campaign" data-campaign-id="${campaign.id}">
Edit
</button>
<button class="btn btn-secondary" data-action="view-analytics" data-campaign-id="${campaign.id}">
Analytics
</button>
<a href="/campaign/${campaign.slug}" target="_blank" class="btn btn-secondary">
View Public Page
</a>
<button class="btn btn-danger" data-action="delete-campaign" data-campaign-id="${campaign.id}">
Delete
</button>
</div>
</div>
`).join('');
// Attach event listeners to campaign actions
this.attachCampaignActionListeners();
}
attachCampaignActionListeners() {
// Edit campaign buttons
document.querySelectorAll('[data-action="edit-campaign"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const campaignId = parseInt(e.target.dataset.campaignId);
this.editCampaign(campaignId);
});
});
// Delete campaign buttons
document.querySelectorAll('[data-action="delete-campaign"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const campaignId = parseInt(e.target.dataset.campaignId);
this.deleteCampaign(campaignId);
});
});
// Analytics buttons
document.querySelectorAll('[data-action="view-analytics"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const campaignId = parseInt(e.target.dataset.campaignId);
this.viewAnalytics(campaignId);
});
});
}
async handleCreateCampaign(e) {
e.preventDefault();
const formData = new FormData(e.target);
const campaignData = {
title: formData.get('title'),
description: formData.get('description'),
email_subject: formData.get('email_subject'),
email_body: formData.get('email_body'),
call_to_action: formData.get('call_to_action'),
allow_smtp_email: formData.get('allow_smtp_email') === 'on',
allow_mailto_link: formData.get('allow_mailto_link') === 'on',
collect_user_info: formData.get('collect_user_info') === 'on',
show_email_count: formData.get('show_email_count') === 'on',
target_government_levels: Array.from(formData.getAll('target_government_levels'))
};
try {
const response = await window.apiClient.post('/admin/campaigns', campaignData);
if (response.success) {
this.showMessage('Campaign created successfully!', 'success');
e.target.reset();
this.switchTab('campaigns');
} else {
throw new Error(response.error || 'Failed to create campaign');
}
} catch (error) {
console.error('Create campaign error:', error);
this.showMessage('Failed to create campaign: ' + error.message, 'error');
}
}
editCampaign(campaignId) {
this.currentCampaign = this.campaigns.find(c => c.id === campaignId);
if (this.currentCampaign) {
this.switchTab('edit');
}
}
populateEditForm() {
if (!this.currentCampaign) return;
const form = document.getElementById('edit-campaign-form');
const campaign = this.currentCampaign;
// Populate form fields
form.querySelector('[name="title"]').value = campaign.title || '';
form.querySelector('[name="description"]').value = campaign.description || '';
form.querySelector('[name="email_subject"]').value = campaign.email_subject || '';
form.querySelector('[name="email_body"]').value = campaign.email_body || '';
form.querySelector('[name="call_to_action"]').value = campaign.call_to_action || '';
// Status select
form.querySelector('[name="status"]').value = campaign.status || 'draft';
// Checkboxes
form.querySelector('[name="allow_smtp_email"]').checked = campaign.allow_smtp_email;
form.querySelector('[name="allow_mailto_link"]').checked = campaign.allow_mailto_link;
form.querySelector('[name="collect_user_info"]').checked = campaign.collect_user_info;
form.querySelector('[name="show_email_count"]').checked = campaign.show_email_count;
// Government levels
const targetLevels = campaign.target_government_levels ?
campaign.target_government_levels.split(',').map(l => l.trim()) : [];
form.querySelectorAll('[name="target_government_levels"]').forEach(checkbox => {
checkbox.checked = targetLevels.includes(checkbox.value);
});
}
async handleUpdateCampaign(e) {
e.preventDefault();
if (!this.currentCampaign) return;
const formData = new FormData(e.target);
const updates = {
title: formData.get('title'),
description: formData.get('description'),
email_subject: formData.get('email_subject'),
email_body: formData.get('email_body'),
call_to_action: formData.get('call_to_action'),
status: formData.get('status'),
allow_smtp_email: formData.get('allow_smtp_email') === 'on',
allow_mailto_link: formData.get('allow_mailto_link') === 'on',
collect_user_info: formData.get('collect_user_info') === 'on',
show_email_count: formData.get('show_email_count') === 'on',
target_government_levels: Array.from(formData.getAll('target_government_levels'))
};
try {
const response = await window.apiClient.makeRequest(`/admin/campaigns/${this.currentCampaign.id}`, {
method: 'PUT',
body: JSON.stringify(updates)
});
if (response.success) {
this.showMessage('Campaign updated successfully!', 'success');
this.switchTab('campaigns');
} else {
throw new Error(response.error || 'Failed to update campaign');
}
} catch (error) {
console.error('Update campaign error:', error);
this.showMessage('Failed to update campaign: ' + error.message, 'error');
}
}
async deleteCampaign(campaignId) {
const campaign = this.campaigns.find(c => c.id === campaignId);
if (!campaign) return;
if (!confirm(`Are you sure you want to delete the campaign "${campaign.title}"? This action cannot be undone.`)) {
return;
}
try {
const response = await window.apiClient.makeRequest(`/admin/campaigns/${campaignId}`, {
method: 'DELETE'
});
if (response.success) {
this.showMessage('Campaign deleted successfully!', 'success');
this.loadCampaigns();
} else {
throw new Error(response.error || 'Failed to delete campaign');
}
} catch (error) {
console.error('Delete campaign error:', error);
this.showMessage('Failed to delete campaign: ' + error.message, 'error');
}
}
async viewAnalytics(campaignId) {
try {
const response = await window.apiClient.get(`/admin/campaigns/${campaignId}/analytics`);
if (response.success) {
this.showAnalyticsModal(response.analytics);
} else {
throw new Error(response.error || 'Failed to load analytics');
}
} catch (error) {
console.error('Analytics error:', error);
this.showMessage('Failed to load analytics: ' + error.message, 'error');
}
}
showAnalyticsModal(analytics) {
// Create a simple analytics modal
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h2>Campaign Analytics</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="analytics-grid">
<div class="analytics-stat">
<h3>${analytics.totalEmails}</h3>
<p>Total Emails</p>
</div>
<div class="analytics-stat">
<h3>${analytics.smtpEmails}</h3>
<p>SMTP Emails</p>
</div>
<div class="analytics-stat">
<h3>${analytics.mailtoClicks}</h3>
<p>Mailto Clicks</p>
</div>
<div class="analytics-stat">
<h3>${analytics.successfulEmails}</h3>
<p>Successful</p>
</div>
</div>
${Object.keys(analytics.byLevel).length > 0 ? `
<h3>By Government Level</h3>
<div class="level-stats">
${Object.entries(analytics.byLevel).map(([level, count]) =>
`<div class="level-stat"><strong>${level}:</strong> ${count}</div>`
).join('')}
</div>
` : ''}
${analytics.recentEmails.length > 0 ? `
<h3>Recent Activity</h3>
<div class="recent-activity">
${analytics.recentEmails.slice(0, 5).map(email => `
<div class="activity-item">
<strong>${email.user_name || 'Anonymous'}</strong>
${email.recipient_name} (${email.recipient_level})
<span class="timestamp">${this.formatDate(email.timestamp)}</span>
</div>
`).join('')}
</div>
` : ''}
</div>
</div>
`;
document.body.appendChild(modal);
// Close modal handlers
modal.querySelector('.modal-close').addEventListener('click', () => {
document.body.removeChild(modal);
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
updateGovernmentLevelsPreview() {
const checkboxes = document.querySelectorAll('input[name="target_government_levels"]:checked');
const levels = Array.from(checkboxes).map(cb => cb.value);
// Could update a preview somewhere if needed
console.log('Selected government levels:', levels);
}
handleSettingsChange(checkbox) {
// Handle real-time settings changes if needed
console.log(`Setting ${checkbox.name} changed to:`, checkbox.checked);
}
showMessage(message, type = 'info') {
const container = document.getElementById('message-container');
container.className = `message-${type}`;
container.textContent = message;
container.classList.remove('hidden');
setTimeout(() => {
container.classList.add('hidden');
}, 5000);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatDate(dateString) {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('en-CA', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (error) {
return dateString;
}
}
}
// Initialize admin panel when DOM is loaded
document.addEventListener('DOMContentLoaded', async () => {
window.adminPanel = new AdminPanel();
await window.adminPanel.init();
});

View File

@ -0,0 +1,73 @@
// API Client for making requests to the backend
class APIClient {
constructor() {
this.baseURL = '/api';
}
async makeRequest(endpoint, options = {}) {
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
async get(endpoint) {
return this.makeRequest(endpoint, {
method: 'GET'
});
}
async post(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
// Health check
async checkHealth() {
return this.get('/health');
}
// Test Represent API connection
async testRepresent() {
return this.get('/test-represent');
}
// Get representatives by postal code
async getRepresentativesByPostalCode(postalCode) {
const cleanPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
return this.get(`/representatives/by-postal/${cleanPostalCode}`);
}
// Refresh representatives for postal code
async refreshRepresentatives(postalCode) {
const cleanPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
return this.post(`/representatives/refresh-postal/${cleanPostalCode}`);
}
// Send email to representative
async sendEmail(emailData) {
return this.post('/emails/send', emailData);
}
}
// Create global instance
window.apiClient = new APIClient();

View File

@ -0,0 +1,196 @@
// Authentication module for handling login/logout and session management
class AuthManager {
constructor() {
this.user = null;
this.isAuthenticated = false;
}
// Initialize authentication state
async init() {
await this.checkSession();
this.setupAuthListeners();
}
// Check current session status
async checkSession() {
try {
const response = await apiClient.get('/auth/session');
if (response.authenticated) {
this.isAuthenticated = true;
this.user = response.user;
this.updateUI();
return true;
} else {
this.isAuthenticated = false;
this.user = null;
this.updateUI();
return false;
}
} catch (error) {
console.error('Session check failed:', error);
this.isAuthenticated = false;
this.user = null;
this.updateUI();
return false;
}
}
// Login with email and password
async login(email, password) {
try {
const response = await apiClient.post('/auth/login', {
email,
password
});
if (response.success) {
this.isAuthenticated = true;
this.user = response.user;
this.updateUI();
return { success: true };
} else {
return { success: false, error: response.error };
}
} catch (error) {
console.error('Login error:', error);
return { success: false, error: error.message || 'Login failed' };
}
}
// Logout current user
async logout() {
try {
await apiClient.post('/auth/logout');
this.isAuthenticated = false;
this.user = null;
this.updateUI();
// Redirect to login page
window.location.href = '/login.html';
} catch (error) {
console.error('Logout error:', error);
// Force logout on client side even if server request fails
this.isAuthenticated = false;
this.user = null;
this.updateUI();
window.location.href = '/login.html';
}
}
// Update UI based on authentication state
updateUI() {
// Update user info display
const userInfo = document.getElementById('user-info');
if (userInfo) {
if (this.isAuthenticated && this.user) {
userInfo.innerHTML = `
<span>Welcome, ${this.user.name || this.user.email}</span>
<button id="logout-btn" class="btn btn-secondary">Logout</button>
`;
// Add logout button listener
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => this.logout());
}
} else {
userInfo.innerHTML = '';
}
}
// Show/hide admin elements
const adminElements = document.querySelectorAll('.admin-only');
adminElements.forEach(element => {
if (this.isAuthenticated && this.user?.isAdmin) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
});
// Show/hide authenticated elements
const authElements = document.querySelectorAll('.auth-only');
authElements.forEach(element => {
if (this.isAuthenticated) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
});
}
// Set up event listeners for auth-related actions
setupAuthListeners() {
// Global logout button
document.addEventListener('click', (e) => {
if (e.target.matches('[data-action="logout"]')) {
e.preventDefault();
this.logout();
}
});
// Login form submission
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const result = await this.login(email, password);
if (result.success) {
// Redirect to admin panel
window.location.href = '/admin.html';
} else {
// Show error message
const errorElement = document.getElementById('error-message');
if (errorElement) {
errorElement.textContent = result.error;
errorElement.style.display = 'block';
}
}
});
}
}
// Require authentication for current page
requireAuth() {
if (!this.isAuthenticated) {
window.location.href = '/login.html';
return false;
}
return true;
}
// Require admin access for current page
requireAdmin() {
if (!this.isAuthenticated) {
window.location.href = '/login.html';
return false;
}
if (!this.user?.isAdmin) {
alert('Admin access required');
window.location.href = '/';
return false;
}
return true;
}
}
// Create global auth manager instance
const authManager = new AuthManager();
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
authManager.init();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = AuthManager;
}

View File

@ -0,0 +1,237 @@
// Email Composer Module
class EmailComposer {
constructor() {
this.modal = document.getElementById('email-modal');
this.form = document.getElementById('email-form');
this.closeBtn = document.getElementById('close-modal');
this.cancelBtn = document.getElementById('cancel-email');
this.messageTextarea = document.getElementById('email-message');
this.charCounter = document.querySelector('.char-counter');
this.currentRecipient = null;
this.init();
}
init() {
// Modal controls
this.closeBtn.addEventListener('click', () => this.closeModal());
this.cancelBtn.addEventListener('click', () => this.closeModal());
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) this.closeModal();
});
// Form handling
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
// Character counter
this.messageTextarea.addEventListener('input', () => this.updateCharCounter());
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.modal.style.display === 'block') {
this.closeModal();
}
});
}
openModal(recipient) {
this.currentRecipient = recipient;
// Populate recipient info
document.getElementById('recipient-email').value = recipient.email;
document.getElementById('recipient-info').innerHTML = `
<strong>${recipient.name}</strong><br>
${recipient.office}<br>
${recipient.district}<br>
<em>${recipient.email}</em>
`;
// Set postal code from current lookup
const postalCode = window.postalLookup ? window.postalLookup.currentPostalCode : '';
document.getElementById('sender-postal-code').value = postalCode;
// Clear form fields
document.getElementById('sender-name').value = '';
document.getElementById('sender-email').value = '';
document.getElementById('email-subject').value = '';
document.getElementById('email-message').value = '';
// Set default subject
document.getElementById('email-subject').value = `Message from your constituent in ${postalCode}`;
this.updateCharCounter();
this.modal.style.display = 'block';
// Focus on first input
document.getElementById('sender-name').focus();
}
closeModal() {
this.modal.style.display = 'none';
this.currentRecipient = null;
}
updateCharCounter() {
const maxLength = 5000;
const currentLength = this.messageTextarea.value.length;
const remaining = maxLength - currentLength;
this.charCounter.textContent = `${remaining} characters remaining`;
if (remaining < 100) {
this.charCounter.style.color = '#dc3545'; // Red
} else if (remaining < 500) {
this.charCounter.style.color = '#ffc107'; // Yellow
} else {
this.charCounter.style.color = '#666'; // Default
}
}
validateForm() {
const errors = [];
const senderName = document.getElementById('sender-name').value.trim();
const senderEmail = document.getElementById('sender-email').value.trim();
const subject = document.getElementById('email-subject').value.trim();
const message = document.getElementById('email-message').value.trim();
if (!senderName) {
errors.push('Your name is required');
}
if (!senderEmail) {
errors.push('Your email is required');
} else if (!this.validateEmail(senderEmail)) {
errors.push('Please enter a valid email address');
}
if (!subject) {
errors.push('Subject is required');
}
if (!message) {
errors.push('Message is required');
} else if (message.length < 10) {
errors.push('Message must be at least 10 characters long');
}
// Check for suspicious content
if (this.containsSuspiciousContent(message) || this.containsSuspiciousContent(subject)) {
errors.push('Your message contains content that may not be appropriate');
}
return errors;
}
validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
containsSuspiciousContent(text) {
const suspiciousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe/i,
/<object/i,
/<embed/i
];
return suspiciousPatterns.some(pattern => pattern.test(text));
}
async handleSubmit(e) {
e.preventDefault();
const errors = this.validateForm();
if (errors.length > 0) {
window.messageDisplay.show(errors.join('<br>'), 'error');
return;
}
const submitButton = this.form.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
try {
submitButton.disabled = true;
submitButton.textContent = 'Sending...';
const emailData = {
recipientEmail: document.getElementById('recipient-email').value,
senderName: document.getElementById('sender-name').value.trim(),
senderEmail: document.getElementById('sender-email').value.trim(),
subject: document.getElementById('email-subject').value.trim(),
message: document.getElementById('email-message').value.trim(),
postalCode: document.getElementById('sender-postal-code').value
};
const result = await window.apiClient.sendEmail(emailData);
if (result.success) {
window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success');
this.closeModal();
} else {
throw new Error(result.message || 'Failed to send email');
}
} catch (error) {
console.error('Email send failed:', error);
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error');
} finally {
submitButton.disabled = false;
submitButton.textContent = originalText;
}
}
// Helper method to get template messages
getTemplateMessage(type) {
const templates = {
general: `Dear {{name}},
I am writing as your constituent from {{postalCode}} to express my views on an important matter.
[Please write your message here]
I would appreciate your response on this issue and would like to know your position.
Thank you for your time and service to our community.
Sincerely,
{{senderName}}`,
concern: `Dear {{name}},
I am writing to express my concern about [specific issue] as your constituent from {{postalCode}}.
[Describe your concern and its impact]
I urge you to [specific action you want them to take].
Thank you for considering my views on this important matter.
Best regards,
{{senderName}}`,
support: `Dear {{name}},
I am writing to express my support for [specific issue/bill/policy] as your constituent from {{postalCode}}.
[Explain why you support this and its importance]
I encourage you to continue supporting this initiative.
Thank you for your leadership on this matter.
Respectfully,
{{senderName}}`
};
return templates[type] || templates.general;
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.emailComposer = new EmailComposer();
});

View File

@ -0,0 +1,79 @@
// Login page specific functionality
document.addEventListener('DOMContentLoaded', function() {
const loginForm = document.getElementById('login-form');
const loginBtn = document.getElementById('login-btn');
const loginText = document.getElementById('login-text');
const loading = document.querySelector('.loading');
const errorMessage = document.getElementById('error-message');
// Check if already logged in
checkSession();
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
if (!email || !password) {
showError('Please enter both email and password');
return;
}
setLoading(true);
hideError();
try {
const response = await apiClient.post('/auth/login', {
email,
password
});
if (response.success) {
// Redirect to admin panel
window.location.href = '/admin.html';
} else {
showError(response.error || 'Login failed');
}
} catch (error) {
console.error('Login error:', error);
showError(error.message || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
});
async function checkSession() {
try {
const response = await apiClient.get('/auth/session');
if (response.authenticated) {
// Already logged in, redirect to admin
window.location.href = '/admin.html';
}
} catch (error) {
// Not logged in, continue with login form
console.log('Not logged in');
}
}
function setLoading(isLoading) {
loginBtn.disabled = isLoading;
loginText.style.display = isLoading ? 'none' : 'inline';
loading.style.display = isLoading ? 'block' : 'none';
}
function showError(message) {
errorMessage.textContent = message;
errorMessage.style.display = 'block';
}
function hideError() {
errorMessage.style.display = 'none';
}
// Check for URL parameters
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('expired') === 'true') {
showError('Your session has expired. Please log in again.');
}
});

View File

@ -0,0 +1,152 @@
// Main Application Module
class MainApp {
constructor() {
this.init();
}
init() {
// Initialize message display system
window.messageDisplay = new MessageDisplay();
// Check API health on startup
this.checkAPIHealth();
// Add global error handling
window.addEventListener('error', (e) => {
console.error('Global error:', e.error);
window.messageDisplay.show('An unexpected error occurred. Please refresh the page and try again.', 'error');
});
// Add unhandled promise rejection handling
window.addEventListener('unhandledrejection', (e) => {
console.error('Unhandled promise rejection:', e.reason);
window.messageDisplay.show('An unexpected error occurred. Please try again.', 'error');
e.preventDefault();
});
}
async checkAPIHealth() {
try {
await window.apiClient.checkHealth();
console.log('API health check passed');
} catch (error) {
console.error('API health check failed:', error);
window.messageDisplay.show('Connection to server failed. Please check your internet connection and try again.', 'error');
}
}
}
// Message Display System
class MessageDisplay {
constructor() {
this.container = document.getElementById('message-display');
this.timeouts = new Map();
}
show(message, type = 'info', duration = 5000) {
// Clear existing timeout for this container
if (this.timeouts.has(this.container)) {
clearTimeout(this.timeouts.get(this.container));
}
// Set message content and type
this.container.innerHTML = message;
this.container.className = `message-display ${type}`;
this.container.style.display = 'block';
// Auto-hide after duration
const timeout = setTimeout(() => {
this.hide();
}, duration);
this.timeouts.set(this.container, timeout);
// Add click to dismiss
this.container.style.cursor = 'pointer';
this.container.onclick = () => this.hide();
}
hide() {
this.container.style.display = 'none';
this.container.onclick = null;
// Clear timeout
if (this.timeouts.has(this.container)) {
clearTimeout(this.timeouts.get(this.container));
this.timeouts.delete(this.container);
}
}
}
// Utility functions
const Utils = {
// Format postal code consistently
formatPostalCode(postalCode) {
const cleaned = postalCode.replace(/\s/g, '').toUpperCase();
if (cleaned.length === 6) {
return `${cleaned.slice(0, 3)} ${cleaned.slice(3)}`;
}
return cleaned;
},
// Sanitize text input
sanitizeText(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// Debounce function for input handling
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// Check if we're on mobile
isMobile() {
return window.innerWidth <= 768;
},
// Format date for display
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-CA', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
};
// Make utils globally available
window.Utils = Utils;
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.mainApp = new MainApp();
// Add some basic accessibility improvements
document.addEventListener('keydown', (e) => {
// Allow Escape to close modals (handled in individual modules)
// Add tab navigation improvements if needed
});
// Add responsive behavior
window.addEventListener('resize', Utils.debounce(() => {
// Handle responsive layout changes if needed
const isMobile = Utils.isMobile();
document.body.classList.toggle('mobile', isMobile);
}, 250));
// Initial mobile class
document.body.classList.toggle('mobile', Utils.isMobile());
});

View File

@ -0,0 +1,158 @@
// Postal Code Lookup Module
class PostalLookup {
constructor() {
this.form = document.getElementById('postal-form');
this.input = document.getElementById('postal-code');
this.refreshBtn = document.getElementById('refresh-btn');
this.loadingDiv = document.getElementById('loading');
this.errorDiv = document.getElementById('error-message');
this.representativesSection = document.getElementById('representatives-section');
this.locationDetails = document.getElementById('location-details');
this.currentPostalCode = null;
this.init();
}
init() {
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
this.refreshBtn.addEventListener('click', () => this.handleRefresh());
this.input.addEventListener('input', (e) => this.formatPostalCode(e));
}
formatPostalCode(e) {
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
// Format as A1A 1A1
if (value.length > 3) {
value = value.slice(0, 3) + ' ' + value.slice(3, 6);
}
e.target.value = value;
}
validatePostalCode(postalCode) {
const cleaned = postalCode.replace(/\s/g, '');
// Check format: Letter-Number-Letter Number-Letter-Number
const regex = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
if (!regex.test(cleaned)) {
return { valid: false, message: 'Please enter a valid postal code format (A1A 1A1)' };
}
// Check if it's an Alberta postal code (starts with T)
if (!cleaned.startsWith('T')) {
return { valid: false, message: 'This tool is designed for Alberta postal codes only (starting with T)' };
}
return { valid: true };
}
showError(message) {
this.errorDiv.textContent = message;
this.errorDiv.style.display = 'block';
this.representativesSection.style.display = 'none';
}
hideError() {
this.errorDiv.style.display = 'none';
}
showLoading() {
this.loadingDiv.style.display = 'block';
this.hideError();
this.representativesSection.style.display = 'none';
}
hideLoading() {
this.loadingDiv.style.display = 'none';
}
async handleSubmit(e) {
e.preventDefault();
const postalCode = this.input.value.trim();
if (!postalCode) {
this.showError('Please enter a postal code');
return;
}
const validation = this.validatePostalCode(postalCode);
if (!validation.valid) {
this.showError(validation.message);
return;
}
await this.lookupRepresentatives(postalCode);
}
async handleRefresh() {
if (!this.currentPostalCode) return;
try {
this.showLoading();
this.refreshBtn.disabled = true;
const data = await window.apiClient.refreshRepresentatives(this.currentPostalCode);
this.displayResults(data);
window.messageDisplay.show('Representatives data refreshed successfully!', 'success');
} catch (error) {
console.error('Refresh failed:', error);
this.showError(`Failed to refresh data: ${error.message}`);
} finally {
this.hideLoading();
this.refreshBtn.disabled = false;
}
}
async lookupRepresentatives(postalCode) {
try {
this.showLoading();
const data = await window.apiClient.getRepresentativesByPostalCode(postalCode);
this.currentPostalCode = postalCode;
this.displayResults(data);
} catch (error) {
console.error('Lookup failed:', error);
this.showError(`Failed to find representatives: ${error.message}`);
} finally {
this.hideLoading();
}
}
displayResults(apiResponse) {
this.hideError();
this.hideLoading();
// Handle the API response structure
const data = apiResponse.data || apiResponse; // Handle both new and old response formats
// Update location info
let locationText = `Postal Code: ${data.postalCode}`;
if (data.location && data.location.city && data.location.province) {
locationText += `${data.location.city}, ${data.location.province}`;
} else if (data.city && data.province) {
locationText += `${data.city}, ${data.province}`;
}
if (data.source || apiResponse.source) {
locationText += ` • Data source: ${data.source || apiResponse.source}`;
}
locationText += ` • Data source: api`;
this.locationDetails.textContent = locationText;
// Show representatives
const representatives = data.representatives || [];
console.log('Displaying representatives:', representatives.length, representatives);
window.representativesDisplay.displayRepresentatives(representatives);
// Show section and refresh button
this.representativesSection.style.display = 'block';
this.refreshBtn.style.display = 'inline-block';
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.postalLookup = new PostalLookup();
});

View File

@ -0,0 +1,192 @@
// Representatives Display Module
class RepresentativesDisplay {
constructor() {
this.container = document.getElementById('representatives-container');
}
displayRepresentatives(representatives) {
if (!representatives || representatives.length === 0) {
this.container.innerHTML = `
<div class="rep-category">
<h3>No Representatives Found</h3>
<p>No representatives were found for this postal code. This might be due to:</p>
<ul>
<li>The postal code is not in our database</li>
<li>Temporary API issues</li>
<li>The postal code is not currently assigned to electoral districts</li>
</ul>
<p>Please try again later or verify your postal code.</p>
</div>
`;
return;
}
// Group representatives by level/type
const grouped = this.groupRepresentatives(representatives);
let html = '';
// Order of importance for display
const displayOrder = [
'Federal',
'Provincial',
'Municipal',
'School Board',
'Other'
];
displayOrder.forEach(level => {
if (grouped[level] && grouped[level].length > 0) {
html += this.renderRepresentativeCategory(level, grouped[level]);
}
});
this.container.innerHTML = html;
this.attachEventListeners();
}
groupRepresentatives(representatives) {
const groups = {
'Federal': [],
'Provincial': [],
'Municipal': [],
'School Board': [],
'Other': []
};
representatives.forEach(rep => {
const setName = rep.representative_set_name || '';
const office = rep.elected_office || '';
if (setName.toLowerCase().includes('house of commons') ||
setName.toLowerCase().includes('federal') ||
office.toLowerCase().includes('member of parliament') ||
office.toLowerCase().includes('mp')) {
groups['Federal'].push(rep);
} else if (setName.toLowerCase().includes('provincial') ||
setName.toLowerCase().includes('legislative assembly') ||
setName.toLowerCase().includes('mla') ||
office.toLowerCase().includes('mla')) {
groups['Provincial'].push(rep);
} else if (setName.toLowerCase().includes('municipal') ||
setName.toLowerCase().includes('city council') ||
setName.toLowerCase().includes('mayor') ||
office.toLowerCase().includes('councillor') ||
office.toLowerCase().includes('mayor')) {
groups['Municipal'].push(rep);
} else if (setName.toLowerCase().includes('school') ||
office.toLowerCase().includes('school') ||
office.toLowerCase().includes('trustee')) {
groups['School Board'].push(rep);
} else {
groups['Other'].push(rep);
}
});
return groups;
}
renderRepresentativeCategory(categoryName, representatives) {
const cards = representatives.map(rep => this.renderRepresentativeCard(rep)).join('');
return `
<div class="rep-category">
<h3>${categoryName} Representatives</h3>
<div class="rep-cards">
${cards}
</div>
</div>
`;
}
renderRepresentativeCard(rep) {
const name = rep.name || 'Name not available';
const email = rep.email || null;
const office = rep.elected_office || 'Office not specified';
const district = rep.district_name || 'District not specified';
const party = rep.party_name || 'Party not specified';
const photoUrl = rep.photo_url || null;
const emailButton = email ?
`<button class="btn btn-primary compose-email"
data-email="${email}"
data-name="${name}"
data-office="${office}"
data-district="${district}">
Send Email
</button>` :
'<span class="text-muted">No email available</span>';
const profileUrl = rep.url ?
`<a href="${rep.url}" target="_blank" class="btn btn-secondary">View Profile</a>` : '';
// Generate initials for fallback
const initials = name.split(' ')
.map(word => word.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2);
const photoElement = photoUrl ?
`<div class="rep-photo">
<img src="${photoUrl}"
alt="${name}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
loading="lazy">
<div class="rep-photo-fallback" style="display: none;">
${initials}
</div>
</div>` :
`<div class="rep-photo">
<div class="rep-photo-fallback">
${initials}
</div>
</div>`;
return `
<div class="rep-card">
${photoElement}
<div class="rep-content">
<div class="rep-header">
<h4>${name}</h4>
</div>
<div class="rep-info">
<p><strong>Office:</strong> ${office}</p>
<p><strong>District:</strong> ${district}</p>
${party !== 'Party not specified' ? `<p><strong>Party:</strong> ${party}</p>` : ''}
${email ? `<p><strong>Email:</strong> ${email}</p>` : ''}
</div>
<div class="rep-actions">
${emailButton}
${profileUrl}
</div>
</div>
</div>
`;
}
attachEventListeners() {
// Add event listeners for compose email buttons
const composeButtons = this.container.querySelectorAll('.compose-email');
composeButtons.forEach(button => {
button.addEventListener('click', (e) => {
const email = e.target.dataset.email;
const name = e.target.dataset.name;
const office = e.target.dataset.office;
const district = e.target.dataset.district;
window.emailComposer.openModal({
email,
name,
office,
district
});
});
});
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.representativesDisplay = new RepresentativesDisplay();
});

View File

@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Alberta Influence Campaign Tool</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css">
<style>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background-color: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
color: #2c3e50;
font-size: 28px;
margin-bottom: 10px;
}
.login-header p {
color: #666;
font-size: 16px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #2c3e50;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #3498db;
}
.btn-login {
width: 100%;
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
margin-top: 10px;
}
.btn-login:hover {
background: #2980b9;
}
.btn-login:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.error-message {
color: #e74c3c;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 8px;
padding: 12px;
margin-bottom: 20px;
font-size: 14px;
}
.loading {
display: none;
text-align: center;
margin-top: 20px;
}
.spinner {
border: 3px solid #f3f3f3;
border-radius: 50%;
border-top: 3px solid #3498db;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #3498db;
text-decoration: none;
font-size: 14px;
}
.back-link a:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>Admin Login</h1>
<p>Access the campaign management panel</p>
</div>
<div id="error-message" class="error-message" style="display: none;"></div>
<form id="login-form">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" autocomplete="email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" id="login-btn" class="btn-login">
<span id="login-text">Login</span>
<div class="loading">
<div class="spinner"></div>
</div>
</button>
</form>
<div class="back-link">
<a href="/">← Back to Campaign Tool</a>
</div>
</div>
</div>
<script src="js/api-client.js"></script>
<script src="js/login.js"></script>
</body>
</html>

View File

@ -0,0 +1,92 @@
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 campaignsController = require('../controllers/campaigns');
const rateLimiter = require('../utils/rate-limiter');
const { requireAdmin } = require('../middleware/auth');
// 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$/).withMessage('Invalid postal code format'),
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$/).withMessage('Invalid postal code format'),
handleValidationErrors,
representativesController.refreshPostalCode
);
// Email endpoints
router.post(
'/emails/send',
rateLimiter.email,
[
body('recipientEmail').isEmail().withMessage('Valid email is required'),
body('senderName').notEmpty().withMessage('Sender name is required'),
body('senderEmail').isEmail().withMessage('Valid sender email is required'),
body('subject').notEmpty().withMessage('Subject is required'),
body('message').notEmpty().withMessage('Message is required'),
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format')
],
handleValidationErrors,
emailsController.sendEmail
);
// Campaign endpoints (Admin) - Protected
router.get('/admin/campaigns', requireAdmin, rateLimiter.general, campaignsController.getAllCampaigns);
router.get('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsController.getCampaignById);
router.post(
'/admin/campaigns',
requireAdmin,
rateLimiter.general,
[
body('title').notEmpty().withMessage('Campaign title is required'),
body('email_subject').notEmpty().withMessage('Email subject is required'),
body('email_body').notEmpty().withMessage('Email body is required')
],
handleValidationErrors,
campaignsController.createCampaign
);
router.put('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsController.updateCampaign);
router.delete('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsController.deleteCampaign);
router.get('/admin/campaigns/:id/analytics', requireAdmin, rateLimiter.general, campaignsController.getCampaignAnalytics);
// Campaign endpoints (Public)
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
router.post(
'/campaigns/:slug/send-email',
rateLimiter.email,
[
body('recipientEmail').isEmail().withMessage('Valid recipient email is required'),
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format'),
body('emailMethod').isIn(['smtp', 'mailto']).withMessage('Email method must be smtp or mailto')
],
handleValidationErrors,
campaignsController.sendCampaignEmail
);
module.exports = router;

View File

@ -0,0 +1,15 @@
const express = require('express');
const authController = require('../controllers/authController');
const router = express.Router();
// POST /api/auth/login
router.post('/login', authController.login);
// POST /api/auth/logout
router.post('/logout', authController.logout);
// GET /api/auth/session
router.get('/session', authController.checkSession);
module.exports = router;

100
influence/app/server.js Normal file
View File

@ -0,0 +1,100 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const session = require('express-session');
const path = require('path');
require('dotenv').config();
const apiRoutes = require('./routes/api');
const authRoutes = require('./routes/auth');
const { requireAdmin } = require('./middleware/auth');
const app = express();
const PORT = process.env.PORT || 3333;
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
},
},
}));
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
app.use(express.static(path.join(__dirname, 'public')));
// Routes
app.use('/api/auth', authRoutes);
app.use('/api', apiRoutes);
// Serve the main page
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Serve login page
app.get('/login.html', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
// Serve admin panel (protected)
app.get('/admin.html', requireAdmin, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
// Serve the admin page (protected)
app.get('/admin', requireAdmin, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
// Serve campaign landing pages
app.get('/campaign/:slug', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'campaign.html'));
});
// Serve campaign pages
app.get('/campaign/:slug', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'campaign.html'));
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV}`);
});
module.exports = app;

View File

@ -0,0 +1,128 @@
const nodemailer = require('nodemailer');
class EmailService {
constructor() {
this.transporter = null;
this.initializeTransporter();
}
initializeTransporter() {
try {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_PORT === '465', // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
},
tls: {
rejectUnauthorized: false
}
});
console.log('Email transporter initialized successfully');
} catch (error) {
console.error('Failed to initialize email transporter:', error);
}
}
async testConnection() {
try {
if (!this.transporter) {
throw new Error('Email transporter not initialized');
}
await this.transporter.verify();
return {
success: true,
message: 'SMTP connection verified successfully'
};
} catch (error) {
return {
success: false,
message: 'SMTP connection failed',
error: error.message
};
}
}
async sendEmail(emailOptions) {
try {
if (!this.transporter) {
throw new Error('Email transporter not initialized');
}
const mailOptions = {
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
to: emailOptions.to,
replyTo: emailOptions.replyTo,
subject: emailOptions.subject,
text: emailOptions.text,
html: emailOptions.html
};
const info = await this.transporter.sendMail(mailOptions);
console.log('Email sent successfully:', info.messageId);
return {
success: true,
messageId: info.messageId,
response: info.response
};
} catch (error) {
console.error('Email send error:', error);
return {
success: false,
error: error.message
};
}
}
async sendBulkEmails(emails) {
const results = [];
for (const email of emails) {
try {
const result = await this.sendEmail(email);
results.push({
to: email.to,
success: result.success,
messageId: result.messageId,
error: result.error
});
// Add a small delay between emails to avoid overwhelming the SMTP server
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
results.push({
to: email.to,
success: false,
error: error.message
});
}
}
return results;
}
formatEmailTemplate(template, data) {
let formattedTemplate = template;
// Replace placeholders with actual data
Object.keys(data).forEach(key => {
const placeholder = `{{${key}}}`;
formattedTemplate = formattedTemplate.replace(new RegExp(placeholder, 'g'), data[key]);
});
return formattedTemplate;
}
validateEmailAddress(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
module.exports = new EmailService();

View File

@ -0,0 +1,459 @@
const axios = require('axios');
class NocoDBService {
constructor() {
// Accept either full API URL or base URL
const rawApiUrl = process.env.NOCODB_API_URL || process.env.NOCODB_URL;
this.apiToken = process.env.NOCODB_API_TOKEN;
this.projectId = process.env.NOCODB_PROJECT_ID;
this.timeout = 10000;
// Normalize base URL and API prefix to avoid double "/api/v1"
let baseUrl = rawApiUrl || '';
if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
// If env provided includes /api/v1, strip it from base and keep prefix
if (/\/api\/v1$/.test(baseUrl)) {
baseUrl = baseUrl.replace(/\/api\/v1$/, '');
}
this.baseUrl = baseUrl || '';
this.apiPrefix = '/api/v1';
// Table mapping from environment variables
this.tableIds = {
representatives: process.env.NOCODB_TABLE_REPRESENTATIVES,
emails: process.env.NOCODB_TABLE_EMAILS,
postalCodes: process.env.NOCODB_TABLE_POSTAL_CODES,
campaigns: process.env.NOCODB_TABLE_CAMPAIGNS,
campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS,
users: process.env.NOCODB_TABLE_USERS
};
// Validate that all table IDs are set
const missingTables = Object.entries(this.tableIds)
.filter(([key, value]) => !value)
.map(([key]) => key);
if (missingTables.length > 0) {
console.error('Missing NocoDB table IDs in environment variables:', missingTables);
console.error('Please run the build-nocodb.sh script to set up the database tables.');
}
// Create axios instance with normalized base URL
this.client = axios.create({
baseURL: this.baseUrl,
timeout: this.timeout,
headers: {
'xc-token': this.apiToken,
'Content-Type': 'application/json'
}
});
// Add response interceptor for error handling
this.client.interceptors.response.use(
response => response,
error => {
console.error('NocoDB API Error:', {
message: error.message,
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
data: error.response?.data
});
throw error;
}
);
}
// Build table URL using table ID
getTableUrl(tableId) {
// Always prefix with single "/api/v1"
return `${this.apiPrefix}/db/data/v1/${this.projectId}/${tableId}`;
}
// Get all records from a table
async getAll(tableId, params = {}) {
const url = this.getTableUrl(tableId);
const response = await this.client.get(url, { params });
return response.data;
}
// Create record
async create(tableId, data) {
try {
// Clean data to prevent ID conflicts
const cleanData = { ...data };
delete cleanData.ID;
delete cleanData.id;
delete cleanData.Id;
// Remove undefined values
Object.keys(cleanData).forEach(key => {
if (cleanData[key] === undefined) {
delete cleanData[key];
}
});
const url = this.getTableUrl(tableId);
const response = await this.client.post(url, cleanData);
return response.data;
} catch (error) {
console.error('Error creating record:', error);
throw error;
}
}
async storeRepresentatives(postalCode, representatives) {
try {
const stored = [];
for (const rep of representatives) {
const record = {
postal_code: postalCode,
name: rep.name || '',
email: rep.email || '',
district_name: rep.district_name || '',
elected_office: rep.elected_office || '',
party_name: rep.party_name || '',
representative_set_name: rep.representative_set_name || '',
url: rep.url || '',
photo_url: rep.photo_url || '',
cached_at: new Date().toISOString()
};
const result = await this.create(this.tableIds.representatives, record);
stored.push(result);
}
return { success: true, count: stored.length };
} catch (error) {
// If we get a server error, don't throw - just log and return failure
if (error.response && error.response.status >= 500) {
console.log('NocoDB server unavailable, cannot cache representatives');
return { success: false, error: 'Server unavailable' };
}
console.log('Error storing representatives:', error.response?.data?.msg || error.message);
return { success: false, error: error.message };
}
}
async getRepresentativesByPostalCode(postalCode) {
try {
// Try to query with the most likely column name
const response = await this.getAll(this.tableIds.representatives, {
where: `(postal_code,eq,${postalCode})`
});
return response.list || [];
} catch (error) {
// If we get a 502 or other server error, just return empty array
if (error.response && (error.response.status === 502 || error.response.status >= 500)) {
console.log('NocoDB server unavailable (502/5xx error), returning empty cache result');
return [];
}
// For other errors like column not found, also return empty array
console.log('NocoDB cache error, returning empty array:', error.response?.data?.msg || error.message);
return [];
}
}
async clearRepresentativesByPostalCode(postalCode) {
try {
// Get existing records
const existing = await this.getRepresentativesByPostalCode(postalCode);
// Delete each record using client
for (const record of existing) {
const url = `${this.getTableUrl(this.tableIds.representatives)}/${record.Id}`;
await this.client.delete(url);
}
return { success: true, deleted: existing.length };
} catch (error) {
console.error('Error clearing representatives:', error);
throw error;
}
}
async logEmailSend(emailData) {
try {
const record = {
recipient_email: emailData.recipientEmail,
sender_name: emailData.senderName,
sender_email: emailData.senderEmail,
subject: emailData.subject,
postal_code: emailData.postalCode,
status: emailData.status,
sent_at: emailData.timestamp
};
await this.create(this.tableIds.emails, record);
return { success: true };
} catch (error) {
console.error('Error logging email:', error);
throw error;
}
}
async getEmailLogs(filters = {}) {
try {
let whereClause = '';
const conditions = [];
if (filters.postalCode) {
conditions.push(`(postal_code,eq,${filters.postalCode})`);
}
if (filters.senderEmail) {
conditions.push(`(sender_email,eq,${filters.senderEmail})`);
}
if (filters.status) {
conditions.push(`(status,eq,${filters.status})`);
}
if (conditions.length > 0) {
whereClause = `?where=${conditions.join('~and')}`;
}
const params = {};
if (conditions.length > 0) {
params.where = conditions.join('~and');
}
params.sort = '-CreatedAt';
const response = await this.getAll(this.tableIds.emails, params);
return response.list || [];
} catch (error) {
console.error('Error getting email logs:', error);
return [];
}
}
async storePostalCodeInfo(postalCodeData) {
try {
const response = await this.create(this.tableIds.postalCodes, postalCodeData);
return response;
} catch (error) {
// Don't throw error for postal code caching failures
console.log('Postal code info storage failed:', error.message);
return null;
}
}
// Campaign management methods
async getAllCampaigns() {
try {
const response = await this.getAll(this.tableIds.campaigns, {
sort: '-CreatedAt'
});
return response.list || [];
} catch (error) {
console.error('Get all campaigns failed:', error);
throw error;
}
}
async getCampaignById(id) {
try {
// Use direct record endpoint to avoid casing issues on Id column
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
const response = await this.client.get(url);
return response.data || null;
} catch (error) {
console.error('Get campaign by ID failed:', error);
throw error;
}
}
async getCampaignBySlug(slug) {
try {
const response = await this.getAll(this.tableIds.campaigns, {
where: `(Campaign Slug,eq,${slug})`
});
return response.list && response.list.length > 0 ? response.list[0] : null;
} catch (error) {
console.error('Get campaign by slug failed:', error);
throw error;
}
}
async createCampaign(campaignData) {
try {
// Map field names to NocoDB column titles
const mappedData = {
'Campaign Slug': campaignData.slug,
'Campaign Title': campaignData.title,
'Description': campaignData.description,
'Email Subject': campaignData.email_subject,
'Email Body': campaignData.email_body,
'Call to Action': campaignData.call_to_action,
'Status': campaignData.status,
'Allow SMTP Email': campaignData.allow_smtp_email,
'Allow Mailto Link': campaignData.allow_mailto_link,
'Collect User Info': campaignData.collect_user_info,
'Show Email Count': campaignData.show_email_count,
'Target Government Levels': campaignData.target_government_levels
};
const response = await this.create(this.tableIds.campaigns, mappedData);
return response;
} catch (error) {
console.error('Create campaign failed:', error);
throw error;
}
}
async updateCampaign(id, updates) {
try {
// NocoDB update using direct API call
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
const response = await this.client.patch(url, updates);
return response.data;
} catch (error) {
console.error('Update campaign failed:', error);
throw error;
}
}
async deleteCampaign(id) {
try {
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
const response = await this.client.delete(url);
return response.data;
} catch (error) {
console.error('Delete campaign failed:', error);
throw error;
}
}
// Campaign email tracking methods
async logCampaignEmail(emailData) {
try {
const response = await this.create(this.tableIds.campaignEmails, emailData);
return response;
} catch (error) {
console.error('Log campaign email failed:', error);
throw error;
}
}
async getCampaignEmailCount(campaignId) {
try {
const response = await this.getAll(this.tableIds.campaignEmails, {
where: `(campaign_id,eq,${campaignId})`,
limit: 1000 // Get enough to count
});
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
} catch (error) {
console.error('Get campaign email count failed:', error);
return 0;
}
}
async getCampaignAnalytics(campaignId) {
try {
const response = await this.getAll(this.tableIds.campaignEmails, {
where: `(campaign_id,eq,${campaignId})`,
limit: 1000
});
const emails = response.list || [];
const analytics = {
totalEmails: emails.length,
smtpEmails: emails.filter(e => e.email_method === 'smtp').length,
mailtoClicks: emails.filter(e => e.email_method === 'mailto').length,
successfulEmails: emails.filter(e => e.status === 'sent' || e.status === 'clicked').length,
failedEmails: emails.filter(e => e.status === 'failed').length,
byLevel: {},
byDate: {},
recentEmails: emails.slice(0, 10).map(email => ({
timestamp: email.timestamp,
user_name: email.user_name,
recipient_name: email.recipient_name,
recipient_level: email.recipient_level,
email_method: email.email_method,
status: email.status
}))
};
// Group by government level
emails.forEach(email => {
const level = email.recipient_level || 'Other';
analytics.byLevel[level] = (analytics.byLevel[level] || 0) + 1;
});
// Group by date
emails.forEach(email => {
if (email.timestamp) {
const date = email.timestamp.split('T')[0]; // Get date part
analytics.byDate[date] = (analytics.byDate[date] || 0) + 1;
}
});
return analytics;
} catch (error) {
console.error('Get campaign analytics failed:', error);
return {
totalEmails: 0,
smtpEmails: 0,
mailtoClicks: 0,
successfulEmails: 0,
failedEmails: 0,
byLevel: {},
byDate: {},
recentEmails: []
};
}
}
// User management methods
async getUserByEmail(email) {
if (!this.tableIds.users) {
throw new Error('Users table not configured');
}
const response = await this.getAll(this.tableIds.users, {
where: `(Email,eq,${email})`,
limit: 1
});
return response.list?.[0] || null;
}
async createUser(userData) {
if (!this.tableIds.users) {
throw new Error('Users table not configured');
}
return await this.create(this.tableIds.users, userData);
}
async updateUser(userId, userData) {
if (!this.tableIds.users) {
throw new Error('Users table not configured');
}
return await this.update(this.tableIds.users, userId, userData);
}
async deleteUser(userId) {
if (!this.tableIds.users) {
throw new Error('Users table not configured');
}
return await this.delete(this.tableIds.users, userId);
}
async getAllUsers(params = {}) {
if (!this.tableIds.users) {
throw new Error('Users table not configured');
}
return await this.getAll(this.tableIds.users, params);
}
}
module.exports = new NocoDBService();

View File

@ -0,0 +1,375 @@
const axios = require('axios');
class NocoDBService {
constructor() {
this.apiUrl = process.env.NOCODB_API_URL;
this.apiToken = process.env.NOCODB_API_TOKEN;
this.projectId = process.env.NOCODB_PROJECT_ID;
this.timeout = 10000;
// Table mapping with actual table IDs from NocoDB
this.tableIds = {
representatives: 'm3slxjt2t9fspvn',
emails: 'mclckn23dlsiuvj',
postalCodes: 'mfsefv20htd6jy1',
campaigns: 'mrbky41y7nahz98',
campaignEmails: 'mlij85ls403d7c2'
};
// Create axios instance with defaults like the map service
this.client = axios.create({
baseURL: this.apiUrl,
timeout: this.timeout,
headers: {
'xc-token': this.apiToken,
'Content-Type': 'application/json'
}
});
// Add response interceptor for error handling
this.client.interceptors.response.use(
response => response,
error => {
console.error('NocoDB API Error:', {
message: error.message,
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
data: error.response?.data
});
throw error;
}
);
}
// Build table URL using table ID
getTableUrl(tableId) {
return `/db/data/v1/${this.projectId}/${tableId}`;
}
// Get all records from a table
async getAll(tableId, params = {}) {
const url = this.getTableUrl(tableId);
const response = await this.client.get(url, { params });
return response.data;
}
// Create record
async create(tableId, data) {
try {
// Clean data to prevent ID conflicts
const cleanData = { ...data };
delete cleanData.ID;
delete cleanData.id;
delete cleanData.Id;
// Remove undefined values
Object.keys(cleanData).forEach(key => {
if (cleanData[key] === undefined) {
delete cleanData[key];
}
});
const url = this.getTableUrl(tableId);
const response = await this.client.post(url, cleanData);
return response.data;
} catch (error) {
console.error('Error creating record:', error);
throw error;
}
}
async storeRepresentatives(postalCode, representatives) {
try {
const stored = [];
for (const rep of representatives) {
const record = {
postal_code: postalCode,
name: rep.name || '',
email: rep.email || '',
district_name: rep.district_name || '',
elected_office: rep.elected_office || '',
party_name: rep.party_name || '',
representative_set_name: rep.representative_set_name || '',
url: rep.url || '',
photo_url: rep.photo_url || '',
cached_at: new Date().toISOString()
};
const result = await this.create(this.tableIds.representatives, record);
stored.push(result);
}
return { success: true, count: stored.length };
} catch (error) {
// If we get a server error, don't throw - just log and return failure
if (error.response && error.response.status >= 500) {
console.log('NocoDB server unavailable, cannot cache representatives');
return { success: false, error: 'Server unavailable' };
}
console.log('Error storing representatives:', error.response?.data?.msg || error.message);
return { success: false, error: error.message };
}
}
async getRepresentativesByPostalCode(postalCode) {
try {
// Try to query with the most likely column name
const response = await this.getAll(this.tableIds.representatives, {
where: `(postal_code,eq,${postalCode})`
});
return response.list || [];
} catch (error) {
// If we get a 502 or other server error, just return empty array
if (error.response && (error.response.status === 502 || error.response.status >= 500)) {
console.log('NocoDB server unavailable (502/5xx error), returning empty cache result');
return [];
}
// For other errors like column not found, also return empty array
console.log('NocoDB cache error, returning empty array:', error.response?.data?.msg || error.message);
return [];
}
}
async clearRepresentativesByPostalCode(postalCode) {
try {
// Get existing records
const existing = await this.getRepresentativesByPostalCode(postalCode);
// Delete each record using client
for (const record of existing) {
const url = `${this.getTableUrl(this.tableIds.representatives)}/${record.Id}`;
await this.client.delete(url);
}
return { success: true, deleted: existing.length };
} catch (error) {
console.error('Error clearing representatives:', error);
throw error;
}
}
async logEmailSend(emailData) {
try {
const record = {
recipient_email: emailData.recipientEmail,
sender_name: emailData.senderName,
sender_email: emailData.senderEmail,
subject: emailData.subject,
postal_code: emailData.postalCode,
status: emailData.status,
sent_at: emailData.timestamp,
created_at: new Date().toISOString()
};
await this.create(this.tableIds.emails, record);
return { success: true };
} catch (error) {
console.error('Error logging email:', error);
throw error;
}
}
async getEmailLogs(filters = {}) {
try {
let whereClause = '';
const conditions = [];
if (filters.postalCode) {
conditions.push(`(postal_code,eq,${filters.postalCode})`);
}
if (filters.senderEmail) {
conditions.push(`(sender_email,eq,${filters.senderEmail})`);
}
if (filters.status) {
conditions.push(`(status,eq,${filters.status})`);
}
if (conditions.length > 0) {
whereClause = `?where=${conditions.join('~and')}`;
}
const params = {};
if (conditions.length > 0) {
params.where = conditions.join('~and');
}
params.sort = '-created_at';
const response = await this.getAll(this.tableIds.emails, params);
return response.list || [];
} catch (error) {
console.error('Error getting email logs:', error);
return [];
}
}
async storePostalCodeInfo(postalCodeData) {
try {
const response = await this.create(this.tableIds.postalCodes, postalCodeData);
return response;
} catch (error) {
// Don't throw error for postal code caching failures
console.log('Postal code info storage failed:', error.message);
return null;
}
}
// Campaign management methods
async getAllCampaigns() {
try {
const response = await this.getAll(this.tableIds.campaigns, {
sort: '-created_at'
});
return response.list || [];
} catch (error) {
console.error('Get all campaigns failed:', error);
throw error;
}
}
async getCampaignById(id) {
try {
const response = await this.getAll(this.tableIds.campaigns, {
where: `(id,eq,${id})`
});
return response.list && response.list.length > 0 ? response.list[0] : null;
} catch (error) {
console.error('Get campaign by ID failed:', error);
throw error;
}
}
async getCampaignBySlug(slug) {
try {
const response = await this.getAll(this.tableIds.campaigns, {
where: `(slug,eq,${slug})`
});
return response.list && response.list.length > 0 ? response.list[0] : null;
} catch (error) {
console.error('Get campaign by slug failed:', error);
throw error;
}
}
async createCampaign(campaignData) {
try {
const response = await this.create(this.tableIds.campaigns, campaignData);
return response;
} catch (error) {
console.error('Create campaign failed:', error);
throw error;
}
}
async updateCampaign(id, updates) {
try {
// NocoDB update using direct API call
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
const response = await this.client.patch(url, updates);
return response.data;
} catch (error) {
console.error('Update campaign failed:', error);
throw error;
}
}
async deleteCampaign(id) {
try {
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
const response = await this.client.delete(url);
return response.data;
} catch (error) {
console.error('Delete campaign failed:', error);
throw error;
}
}
// Campaign email tracking methods
async logCampaignEmail(emailData) {
try {
const response = await this.create(this.tableIds.campaignEmails, emailData);
return response;
} catch (error) {
console.error('Log campaign email failed:', error);
throw error;
}
}
async getCampaignEmailCount(campaignId) {
try {
const response = await this.getAll(this.tableIds.campaignEmails, {
where: `(campaign_id,eq,${campaignId})`,
limit: 1000 // Get enough to count
});
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
} catch (error) {
console.error('Get campaign email count failed:', error);
return 0;
}
}
async getCampaignAnalytics(campaignId) {
try {
const response = await this.getAll(this.tableIds.campaignEmails, {
where: `(campaign_id,eq,${campaignId})`,
limit: 1000
});
const emails = response.list || [];
const analytics = {
totalEmails: emails.length,
smtpEmails: emails.filter(e => e.email_method === 'smtp').length,
mailtoClicks: emails.filter(e => e.email_method === 'mailto').length,
successfulEmails: emails.filter(e => e.status === 'sent' || e.status === 'clicked').length,
failedEmails: emails.filter(e => e.status === 'failed').length,
byLevel: {},
byDate: {},
recentEmails: emails.slice(0, 10).map(email => ({
timestamp: email.timestamp,
user_name: email.user_name,
recipient_name: email.recipient_name,
recipient_level: email.recipient_level,
email_method: email.email_method,
status: email.status
}))
};
// Group by government level
emails.forEach(email => {
const level = email.recipient_level || 'Other';
analytics.byLevel[level] = (analytics.byLevel[level] || 0) + 1;
});
// Group by date
emails.forEach(email => {
if (email.timestamp) {
const date = email.timestamp.split('T')[0]; // Get date part
analytics.byDate[date] = (analytics.byDate[date] || 0) + 1;
}
});
return analytics;
} catch (error) {
console.error('Get campaign analytics failed:', error);
return {
totalEmails: 0,
smtpEmails: 0,
mailtoClicks: 0,
successfulEmails: 0,
failedEmails: 0,
byLevel: {},
byDate: {},
recentEmails: []
};
}
}
}
module.exports = new NocoDBService();

View File

@ -0,0 +1,146 @@
const axios = require('axios');
class RepresentAPIService {
constructor() {
this.baseURL = process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca';
this.rateLimit = parseInt(process.env.REPRESENT_API_RATE_LIMIT) || 60;
this.lastRequestTime = 0;
this.requestCount = 0;
this.resetTime = Date.now() + 60000; // Reset every minute
}
async checkRateLimit() {
const now = Date.now();
// Reset counter if a minute has passed
if (now > this.resetTime) {
this.requestCount = 0;
this.resetTime = now + 60000;
}
// Check if we're at the rate limit
if (this.requestCount >= this.rateLimit) {
const waitTime = this.resetTime - now;
throw new Error(`Rate limit exceeded. Please wait ${Math.ceil(waitTime / 1000)} seconds.`);
}
this.requestCount++;
this.lastRequestTime = now;
}
async makeRequest(endpoint) {
await this.checkRateLimit();
try {
const response = await axios.get(`${this.baseURL}${endpoint}`, {
timeout: 10000,
headers: {
'User-Agent': 'Alberta-Influence-Campaign-Tool/1.0'
}
});
return response.data;
} catch (error) {
if (error.response) {
throw new Error(`API Error: ${error.response.status} - ${error.response.statusText}`);
} else if (error.request) {
throw new Error('Network error: Unable to reach Represent API');
} else {
throw new Error(`Request error: ${error.message}`);
}
}
}
async testConnection() {
try {
const data = await this.makeRequest('/boundary-sets/?limit=1');
return {
success: true,
message: 'Successfully connected to Represent API',
sampleData: data
};
} catch (error) {
return {
success: false,
message: 'Failed to connect to Represent API',
error: error.message
};
}
}
async getRepresentativesByPostalCode(postalCode) {
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
// Validate Alberta postal code (should start with T)
if (!formattedPostalCode.startsWith('T')) {
throw new Error('This tool is designed for Alberta postal codes only (starting with T)');
}
try {
const endpoint = `/postcodes/${formattedPostalCode}/`;
console.log(`Making Represent API request to: ${this.baseURL}${endpoint}`);
const data = await this.makeRequest(endpoint);
console.log('Represent API Response:', JSON.stringify(data, null, 2));
console.log(`Representatives concordance count: ${data.representatives_concordance?.length || 0}`);
console.log(`Representatives centroid count: ${data.representatives_centroid?.length || 0}`);
return {
postalCode: formattedPostalCode,
city: data.city,
province: data.province,
centroid: data.centroid,
representatives_concordance: data.representatives_concordance || [],
representatives_centroid: data.representatives_centroid || [],
boundaries_concordance: data.boundaries_concordance || [],
boundaries_centroid: data.boundaries_centroid || []
};
} catch (error) {
console.error(`Represent API error for ${formattedPostalCode}:`, error.message);
throw new Error(`Failed to fetch data for postal code ${formattedPostalCode}: ${error.message}`);
}
}
async getRepresentativeDetails(representativeUrl) {
try {
// Extract the path from the URL
const urlPath = representativeUrl.replace(this.baseURL, '');
const data = await this.makeRequest(urlPath);
return data;
} catch (error) {
throw new Error(`Failed to fetch representative details: ${error.message}`);
}
}
async getBoundaryDetails(boundaryUrl) {
try {
// Extract the path from the URL
const urlPath = boundaryUrl.replace(this.baseURL, '');
const data = await this.makeRequest(urlPath);
return data;
} catch (error) {
throw new Error(`Failed to fetch boundary details: ${error.message}`);
}
}
async searchRepresentatives(filters = {}) {
try {
const queryParams = new URLSearchParams();
// Add filters as query parameters
Object.keys(filters).forEach(key => {
if (filters[key]) {
queryParams.append(key, filters[key]);
}
});
const endpoint = `/representatives/?${queryParams.toString()}`;
const data = await this.makeRequest(endpoint);
return data;
} catch (error) {
throw new Error(`Failed to search representatives: ${error.message}`);
}
}
}
module.exports = new RepresentAPIService();

View File

@ -0,0 +1,44 @@
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: {
error: 'Too many requests from this IP, please try again later.',
retryAfter: 15 * 60 // 15 minutes in seconds
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Email sending rate limiter
const email = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // limit each IP to 10 emails per hour
message: {
error: 'Too many emails sent from this IP, please try again later.',
retryAfter: 60 * 60 // 1 hour in seconds
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Don't skip counting successful requests
});
// Represent API rate limiter (more restrictive)
const representAPI = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // match the Represent API limit of 60 requests per minute
message: {
error: 'Represent API rate limit exceeded, please try again later.',
retryAfter: 60 // 1 minute in seconds
},
standardHeaders: true,
legacyHeaders: false,
});
module.exports = {
general,
email,
representAPI
};

View File

@ -0,0 +1,91 @@
// 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);
}
// Format postal code to standard format (A1A 1A1)
function formatPostalCode(postalCode) {
const cleaned = postalCode.replace(/\s/g, '').toUpperCase();
if (cleaned.length === 6) {
return `${cleaned.slice(0, 3)} ${cleaned.slice(3)}`;
}
return cleaned;
}
// Sanitize string input to prevent XSS
function sanitizeString(str) {
if (typeof str !== 'string') return str;
return str
.replace(/[<>]/g, '') // Remove angle brackets
.trim()
.substring(0, 1000); // Limit length
}
// Validate required fields in request body
function validateRequiredFields(body, requiredFields) {
const errors = [];
requiredFields.forEach(field => {
if (!body[field] || (typeof body[field] === 'string' && body[field].trim() === '')) {
errors.push(`${field} is required`);
}
});
return errors;
}
// Check if string contains potentially harmful content
function containsSuspiciousContent(str) {
const suspiciousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe/i,
/<object/i,
/<embed/i
];
return suspiciousPatterns.some(pattern => pattern.test(str));
}
// Generate URL-friendly slug from text
function generateSlug(text) {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
}
// Validate slug format
function validateSlug(slug) {
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
return slugPattern.test(slug) && slug.length >= 3 && slug.length <= 100;
}
module.exports = {
validatePostalCode,
validateAlbertaPostalCode,
validateEmail,
formatPostalCode,
sanitizeString,
validateRequiredFields,
containsSuspiciousContent,
generateSlug,
validateSlug
};

View File

@ -0,0 +1,15 @@
services:
app:
build:
context: ./app
dockerfile: Dockerfile
ports:
- "3333:3333"
environment:
- NODE_ENV=production
env_file:
- .env
volumes:
- ./app:/usr/src/app
- /usr/src/app/node_modules
restart: unless-stopped

View File

@ -0,0 +1,271 @@
# Alberta Influence Campaign Tool - File Structure Explanation
This document explains the purpose and functionality of each file in the Alberta Influence Campaign Tool.
## Authentication System
The application includes a complete authentication system for admin panel access, following the same patterns used in the map application. Authentication is implemented using NocoDB as the user database and express-session for session management.
## Root Directory Files
### Configuration Files
- **`.env`** - Environment variables (database URLs, SMTP config, API keys)
- **`.env.example`** - Template for environment configuration
- **`docker-compose.yml`** - Docker container orchestration for development/production
- **`Dockerfile`** - Container definition for the Node.js application
- **`package.json`** - Node.js dependencies and scripts
- **`package-lock.json`** - Locked dependency versions for reproducible builds
### Documentation
- **`README.MD`** - Main project documentation with setup and usage instructions
- **`files-explainer.md`** - This file, explaining the project structure
- **`instruct.md`** - Implementation instructions and development notes
## Application Structure (`app/`)
### Entry Point
- **`server.js`** - Express.js application entry point, middleware setup, route mounting
### Controllers (`app/controllers/`)
Business logic layer that handles HTTP requests and responses:
- **`authController.js`** - Authentication controller for admin login/logout
- `login()` - Handles admin login with email/password validation
- `logout()` - Destroys user session and logs out
- `checkSession()` - Verifies current authentication status
- Integrates with NocoDB users table for credential verification
- Updates last login timestamps and manages session state
- **`representatives.js`** - Core functionality for postal code lookups
- `getByPostalCode()` - Main endpoint for finding representatives
- `refreshPostalCode()` - Force refresh of cached data
- `testConnection()` - Health check for Represent API
- Handles caching logic and fallback to API when cache fails
- **`emails.js`** - Email composition and sending functionality
- `send()` - Process and send emails to representatives
- `getLogs()` - Retrieve email history with filtering
- Integrates with SMTP service and logs to database
### Routes (`app/routes/`)
API endpoint definitions and request validation:
- **`auth.js`** - Authentication API routes
- POST `/api/auth/login` - Admin login endpoint
- POST `/api/auth/logout` - Admin logout endpoint
- GET `/api/auth/session` - Session verification endpoint
- Handles authentication requests with proper validation and error handling
- **`api.js`** - Main API routes with validation middleware
- Representatives endpoints with postal code validation
- Email endpoints with input sanitization
- Health check and testing endpoints
- Rate limiting and error handling middleware
### Services (`app/services/`)
External system integrations and data access layer:
- **`nocodb.js`** - NocoDB database integration
- User management methods: `getUserByEmail()`, `createUser()`, `updateUser()`, `getAllUsers()`
- Handles users table operations for authentication system
- CRUD operations for representatives, emails, postal codes
- Table ID mapping and API client configuration
- Error handling with graceful degradation
- Caching logic with automatic retry mechanisms
- **`represent-api.js`** - Represent OpenNorth API integration
- Postal code lookup against Canadian electoral data
- Representative data fetching and processing
- API response transformation and error handling
- Support for both concordance and centroid representative data
- **`email.js`** - SMTP email service
- Email composition and HTML template rendering
- SMTP client configuration and sending
- Delivery confirmation and error handling
- Email logging and status tracking
### Utilities (`app/utils/`)
Helper functions and shared utilities:
- **`validation.js`** - Input validation and sanitization
- Postal code format validation (Alberta T-prefix)
- Email address validation
- HTML content sanitization for security
- **`rate-limiter.js`** - API rate limiting configuration
- Different limits for various endpoint types
- IP-based rate limiting with sliding windows
- Configurable limits via environment variables
### Middleware (`app/middleware/`)
Express.js middleware functions:
- **`auth.js`** - Authentication middleware for protecting routes
- `requireAuth()` - Ensures user is logged in
- `requireAdmin()` - Ensures user is logged in and has admin privileges
- Redirects unauthenticated requests to login page
- Sets up `req.user` object for authenticated requests
- Standardized error response formatting
- Error logging and classification
- Production vs development error detail levels
### Frontend Assets (`app/public/`)
#### HTML
- **`index.html`** - Main application interface
- **`login.html`** - Admin login page
- Clean, professional login interface for admin access
- Email/password form with client-side validation
- Integration with authentication API
- Automatic redirect to admin panel on successful login
- Session persistence and auto-login detection
- **`admin.html`** - Campaign management admin panel (protected)
- Requires admin authentication to access
- Includes authentication checks and user info display
- Logout functionality integrated into interface
- Postal code input form with validation
- Representatives display sections
- Email composition modal
- Responsive design with accessibility features
#### Stylesheets (`app/public/css/`)
- **`styles.css`** - Complete application styling
- Responsive grid layouts for representative cards
- Photo display with fallback styling
- Modal and form styling
- Mobile-first responsive design
- Loading states and error message styling
#### JavaScript (`app/public/js/`)
- **`main.js`** - Application initialization and utilities
- Global error handling and message display system
- Utility functions (postal code formatting, sanitization)
- Mobile detection and responsive behavior
- **`api-client.js`** - Frontend API communication
- HTTP client with error handling
- API endpoint wrappers for all backend services
- Request/response processing and error propagation
- **`postal-lookup.js`** - Postal code search functionality
- Form handling and input validation
- API integration for representative lookup
- Loading states and error display
- Results processing and display coordination
- **`representatives-display.js`** - Representative data presentation
- Dynamic HTML generation for representative cards
- Photo display with fallback to initials
- Government level categorization and sorting
- Contact information formatting and display
- **`auth.js`** - Client-side authentication management
- `AuthManager` class for handling login/logout operations
- Session state management and persistence
- UI updates based on authentication status
- Auto-redirect for protected pages requiring authentication
- Integration with login forms and logout buttons
- **`email-composer.js`** - Email composition interface
- Modal-based email composition form
- Pre-population of recipient data
- Form validation and submission handling
- Success/error feedback to users
## Scripts Directory (`scripts/`)
- **`build-nocodb.sh`** - Database setup automation
- Now includes users table creation for authentication system
- Creates influence_users table with email, name, password, admin flag, and last_login fields
- Updates .env file with NOCODB_TABLE_USERS configuration
- Creates NocoDB base and tables using v2 API
- Configures table schemas for representatives, emails, postal codes
- Handles authentication and error recovery
- Updates environment with new base ID
## Data Flow Architecture
### Request Processing Flow
1. **User Input** → Postal code entered in frontend form
2. **Validation** → Client-side and server-side validation
3. **Cache Check** → NocoDB queried for existing data
4. **API Fallback** → Represent API called if cache miss or NocoDB down
5. **Data Processing** → Representative data normalized and processed
6. **Caching** → Results stored in NocoDB for future requests
7. **Response** → Formatted data returned to frontend
8. **Display** → Representatives rendered with photos and contact info
### Email Flow
1. **Composition** → User fills email form in modal
2. **Validation** → Input sanitization and validation
3. **Processing** → Email formatted and prepared for sending
4. **SMTP Delivery** → Email sent via configured SMTP service
5. **Logging** → Email attempt logged to database
6. **Confirmation** → User notified of success/failure
### Error Handling Strategy
- **Graceful Degradation** → App works even if NocoDB is down
- **User Feedback** → Clear error messages and recovery suggestions
- **Logging** → Comprehensive error logging for debugging
- **Fallbacks** → Multiple data sources and retry mechanisms
## Key Integration Points
### Authentication System
- **Admin Login** → Email/password authentication for admin panel access
- **Session Management** → Express-session with secure cookie configuration
- **Route Protection** → Middleware-based protection for admin endpoints and pages
- **User Management** → NocoDB-based user storage with admin role support
- **Security** → Password validation, session expiration, and automatic logout
### NocoDB Integration
- **Authentication** → Token-based API authentication
- **User Storage** → Users table with email, password, admin flag, and login tracking
- **Table Management** → Dynamic table ID resolution
- **Error Recovery** → Automatic fallback to API when database unavailable
- **Performance** → Efficient caching with intelligent cache invalidation
### Represent API Integration
- **Data Sources** → Both concordance and centroid representative data
- **Rate Limiting** → Respectful API usage with built-in rate limiting
- **Error Handling** → Robust error handling with user-friendly messages
- **Data Processing** → Normalization of API responses for consistent display
### SMTP Integration
- **Security** → Secure authentication and encrypted connections
- **Reliability** → Error handling and delivery confirmation
- **Logging** → Complete audit trail of email activity
- **Configuration** → Flexible SMTP provider support
## Development Patterns
### Error Handling
- All async operations wrapped in try-catch blocks
- Consistent error response formatting across APIs
- User-friendly error messages with technical details logged
- Graceful degradation when external services unavailable
### Data Validation
- Input validation at both client and server levels
- Sanitization to prevent XSS and injection attacks
- Type checking and format validation throughout
- Clear validation error messages for users
### Performance Optimization
- Smart caching with configurable TTL
- Lazy loading of images and non-critical resources
- Efficient database queries with minimal data transfer
- Client-side debouncing for user inputs
### Security Measures
- **Authentication Protection** → Admin panel and API endpoints protected by login
- **Session Security** → HttpOnly cookies, secure session configuration, automatic expiration
- Rate limiting to prevent abuse
- Input sanitization and validation
- Secure SMTP configuration
- Environment variable protection of sensitive data
This architecture provides a robust, scalable, and maintainable solution for connecting Alberta residents with their elected representatives.

View File

@ -0,0 +1,296 @@
#!/bin/bash
# Fix Campaigns Table Script
# This script recreates the campaigns table with proper column options
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}$1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}$1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# Load environment variables
if [ -f ".env" ]; then
export $(cat .env | grep -v '^#' | xargs)
print_success "Environment variables loaded from .env"
else
print_error "No .env file found. Please create one based on .env.example"
exit 1
fi
# Validate required environment variables
if [ -z "$NOCODB_API_URL" ] || [ -z "$NOCODB_API_TOKEN" ] || [ -z "$NOCODB_PROJECT_ID" ]; then
print_error "Missing required environment variables: NOCODB_API_URL, NOCODB_API_TOKEN, NOCODB_PROJECT_ID"
exit 1
fi
print_status "Using NocoDB instance: $NOCODB_API_URL"
print_status "Project ID: $NOCODB_PROJECT_ID"
# Function to make API calls with proper error handling
make_api_call() {
local method="$1"
local url="$2"
local data="$3"
local description="$4"
print_status "Making $method request to: $url"
if [ -n "$description" ]; then
print_status "Purpose: $description"
fi
local response
local http_code
if [ "$method" = "DELETE" ]; then
response=$(curl -s -w "\n%{http_code}" -X DELETE \
-H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
"$url")
elif [ "$method" = "POST" ] && [ -n "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$url")
else
print_error "Invalid method or missing data for API call"
return 1
fi
# Extract HTTP code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
print_status "HTTP Status: $http_code"
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
print_success "API call successful"
echo "$response_body"
return 0
else
print_error "API call failed with status $http_code"
print_error "Response: $response_body"
return 1
fi
}
# Function to delete the campaigns table
delete_campaigns_table() {
local table_id="$1"
print_warning "Deleting existing campaigns table (ID: $table_id)..."
make_api_call "DELETE" \
"$NOCODB_API_URL/db/meta/tables/$table_id" \
"" \
"Delete campaigns table" > /dev/null
}
# Function to create the campaigns table with proper options
create_campaigns_table() {
local base_id="$1"
print_status "Creating new campaigns table..."
local table_data='{
"table_name": "influence_campaigns",
"title": "Campaigns",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "slug",
"title": "Campaign Slug",
"uidt": "SingleLineText",
"unique": true,
"rqd": true
},
{
"column_name": "title",
"title": "Campaign Title",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "description",
"title": "Description",
"uidt": "LongText"
},
{
"column_name": "email_subject",
"title": "Email Subject",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "email_body",
"title": "Email Body",
"uidt": "LongText",
"rqd": true
},
{
"column_name": "call_to_action",
"title": "Call to Action",
"uidt": "LongText"
},
{
"column_name": "status",
"title": "Status",
"uidt": "SingleSelect",
"colOptions": {
"options": [
{"title": "draft", "color": "#cfdffe"},
{"title": "active", "color": "#c2f5e8"},
{"title": "paused", "color": "#fee2d5"},
{"title": "archived", "color": "#ffeab6"}
]
},
"rqd": true,
"cdf": "draft"
},
{
"column_name": "allow_smtp_email",
"title": "Allow SMTP Email",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "allow_mailto_link",
"title": "Allow Mailto Link",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "collect_user_info",
"title": "Collect User Info",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "show_email_count",
"title": "Show Email Count",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "target_government_levels",
"title": "Target Government Levels",
"uidt": "MultiSelect",
"colOptions": {
"options": [
{"title": "Federal", "color": "#cfdffe"},
{"title": "Provincial", "color": "#d0f1fd"},
{"title": "Municipal", "color": "#c2f5e8"},
{"title": "School Board", "color": "#ffdaf6"}
]
}
},
{
"column_name": "created_at",
"title": "Created At",
"uidt": "DateTime",
"cdf": "now()"
},
{
"column_name": "updated_at",
"title": "Updated At",
"uidt": "DateTime",
"cdf": "now()"
}
]
}'
local response
response=$(make_api_call "POST" \
"$NOCODB_API_URL/db/meta/bases/$base_id/tables" \
"$table_data" \
"Create campaigns table with proper column options")
if [ $? -eq 0 ]; then
# Extract table ID from response
local table_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$table_id" ]; then
print_success "New campaigns table created with ID: $table_id"
echo "$table_id"
return 0
else
print_error "Could not extract table ID from response"
return 1
fi
else
return 1
fi
}
# Main execution
print_status "Starting campaigns table fix..."
# Check if campaigns table exists
if [ -n "$NOCODB_TABLE_CAMPAIGNS" ]; then
print_status "Found existing campaigns table ID: $NOCODB_TABLE_CAMPAIGNS"
# Delete existing table
if delete_campaigns_table "$NOCODB_TABLE_CAMPAIGNS"; then
print_success "Successfully deleted old campaigns table"
else
print_warning "Failed to delete old table, continuing anyway..."
fi
fi
# Create new table
NEW_TABLE_ID=$(create_campaigns_table "$NOCODB_PROJECT_ID")
if [ $? -eq 0 ] && [ -n "$NEW_TABLE_ID" ]; then
print_success "Successfully created new campaigns table!"
# Update .env file with new table ID
print_status "Updating .env file with new table ID..."
if grep -q "NOCODB_TABLE_CAMPAIGNS=" .env; then
# Replace existing NOCODB_TABLE_CAMPAIGNS
sed -i "s/NOCODB_TABLE_CAMPAIGNS=.*/NOCODB_TABLE_CAMPAIGNS=$NEW_TABLE_ID/" .env
print_success "Updated NOCODB_TABLE_CAMPAIGNS in .env file"
else
# Add new NOCODB_TABLE_CAMPAIGNS
echo "NOCODB_TABLE_CAMPAIGNS=$NEW_TABLE_ID" >> .env
print_success "Added NOCODB_TABLE_CAMPAIGNS to .env file"
fi
print_status ""
print_status "============================================================"
print_success "Campaigns table fix completed successfully!"
print_status "============================================================"
print_status ""
print_status "New table ID: $NEW_TABLE_ID"
print_status "Please restart your application to use the new table."
else
print_error "Failed to create new campaigns table"
exit 1
fi

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,52 @@ Wej are using NocoDB as a no-code database solution. You will need to set up a N
- **Testing.** Test new features locally and ensure they do not break existing functionality. - **Testing.** Test new features locally and ensure they do not break existing functionality.
- **Pagination** Use pagination for API endpoints returning large datasets to avoid performance issues. For example, getAll should be getAllPaginated - **Pagination** Use pagination for API endpoints returning large datasets to avoid performance issues. For example, getAll should be getAllPaginated
## NocoDB Development Best Practices
### Field Naming and Access
- **Use Column Titles, Not Column Names:** NocoDB expects column titles (e.g., "Campaign Slug") in API calls, not column names (e.g., "slug")
- **Consistent Mapping:** Always map between your application's field names and NocoDB's column titles in the service layer
- **Where Clauses:** Use column titles in where conditions: `(Campaign Slug,eq,value)` not `(slug,eq,value)`
### System Fields
- **Avoid System Field Conflicts:** Never create user-defined fields with names like `created_at`, `updated_at` as they conflict with NocoDB system fields
- **Use System Fields:** Leverage NocoDB's automatic system fields (`CreatedAt`, `UpdatedAt`, `CreatedBy`, etc.) instead of creating your own
- **Sorting:** Sort by system field titles: `-CreatedAt` not `-created_at`
### Select Field Configuration
- **Use colOptions:** For SingleSelect and MultiSelect fields, always use `colOptions` with an `options` array
- **Never use dtxp:** The `dtxp` parameter is deprecated and causes corrupted select options
- **Example Structure:**
```json
{
"uidt": "SingleSelect",
"colOptions": {
"options": [
{"title": "draft", "color": "#d0f1fd"},
{"title": "active", "color": "#c2f5e8"}
]
}
}
```
### Table Management
- **Clean Recreation:** When fixing table schema issues, delete and recreate tables rather than trying to modify corrupted structures
- **Environment Cleanup:** Remove duplicate table IDs from `.env` files to avoid using old/deleted tables
- **Restart After Changes:** Always restart the application after table recreation to pick up new table IDs
### API Endpoints
- **Use Correct API Versions:**
- Data operations: `/db/data/v1/{projectId}/{tableId}`
- Meta operations: `/db/meta/tables/{tableId}`
- **Field Validation:** Test field access directly via NocoDB API before implementing in application logic
- **Error Handling:** NocoDB returns specific error codes like `FIELD_NOT_FOUND`, `TABLE_NOT_FOUND` - handle these appropriately
### Debugging Tips
- **Direct API Testing:** Use curl to test NocoDB API directly before implementing in application
- **Check Table Metadata:** Use `/db/meta/tables/{tableId}` to inspect actual column names and titles
- **Verify System Fields:** Check which fields are marked as `"system": true` to avoid conflicts
- **Log API Responses:** Always log NocoDB API responses during development to understand the exact data structure returned
## How to Add a Feature ## How to Add a Feature
**First look through the existing codebase to understand where similar logic is implemented.** **First look through the existing codebase to understand where similar logic is implemented.**

930
influence/scripts/build-nocodb.sh Executable file
View File

@ -0,0 +1,930 @@
#!/bin/bash
# NocoDB Auto-Setup Script for Alberta Influence Campaign Tool
# Based on the successful map setup script
# This script creates tables in your existing NocoDB project
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1" >&2
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1" >&2
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
# Load environment variables
if [ -f ".env" ]; then
set -a
source .env
set +a
print_success "Environment variables loaded from .env"
else
print_error ".env file not found!"
exit 1
fi
# Validate required environment variables
if [ -z "$NOCODB_API_URL" ] || [ -z "$NOCODB_API_TOKEN" ]; then
print_error "Required environment variables NOCODB_API_URL and NOCODB_API_TOKEN not set!"
exit 1
fi
# Extract base URL from API URL and set up v2 API endpoints
BASE_URL=$(echo "$NOCODB_API_URL" | sed 's|/api/v1||')
API_BASE_V1="$NOCODB_API_URL"
API_BASE_V2="${BASE_URL}/api/v2"
# We'll create a new base for the influence campaign
BASE_ID=""
print_status "Using NocoDB instance: $BASE_URL"
print_status "Will create a new base for the Influence Campaign Tool"
# Function to make API calls with proper error handling
make_api_call() {
local method=$1
local endpoint=$2
local data=$3
local description=$4
local api_version=${5:-"v2"} # Default to v2
print_status "$description"
local response
local http_code
local full_url
if [[ "$api_version" == "v1" ]]; then
full_url="$API_BASE_V1$endpoint"
else
full_url="$API_BASE_V2$endpoint"
fi
if [ "$method" = "GET" ]; then
response=$(curl -s -w "%{http_code}" -H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
--max-time 60 \
"$full_url" 2>/dev/null)
curl_exit_code=$?
else
response=$(curl -s -w "%{http_code}" -X "$method" \
-H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
--max-time 60 \
-d "$data" \
"$full_url" 2>/dev/null)
curl_exit_code=$?
fi
if [[ $curl_exit_code -ne 0 ]]; then
print_error "Network error occurred while making API call (curl exit code: $curl_exit_code)"
return 1
fi
if [[ -z "$response" ]]; then
print_error "Empty response from API call"
return 1
fi
http_code="${response: -3}"
response_body="${response%???}"
print_status "HTTP Code: $http_code"
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
print_success "$description completed successfully"
echo "$response_body"
else
print_error "$description failed with HTTP code: $http_code"
print_error "Response: $response_body"
return 1
fi
}
# Function to test API connectivity
test_api_connectivity() {
print_status "Testing API connectivity..."
# Test basic connectivity first
if ! curl -s --max-time 10 -I "$BASE_URL" > /dev/null 2>&1; then
print_error "Cannot reach NocoDB instance at $BASE_URL"
return 1
fi
# Test API with token using v2 endpoint
local test_response
test_response=$(curl -s --max-time 10 -w "%{http_code}" -H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE_V2/meta/bases" 2>/dev/null || echo "CURL_ERROR")
if [[ "$test_response" == "CURL_ERROR" ]]; then
print_error "Network error when testing API"
return 1
fi
local http_code="${test_response: -3}"
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
print_success "API connectivity test successful"
return 0
else
print_error "API test failed with HTTP code: $http_code"
return 1
fi
}
# Function to create a table
create_table() {
local base_id=$1
local table_name=$2
local table_data=$3
local description=$4
local response
response=$(make_api_call "POST" "/meta/bases/$base_id/tables" "$table_data" "Creating table: $table_name ($description)" "v2")
if [[ $? -eq 0 && -n "$response" ]]; then
local table_id
table_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ -n "$table_id" ]]; then
print_success "Table '$table_name' created with ID: $table_id"
echo "$table_id"
else
print_error "Failed to extract table ID from response"
print_error "Response was: $response"
return 1
fi
else
print_error "Failed to create table: $table_name"
return 1
fi
}
# Function to validate table creation
validate_table_ids() {
local tables=("$@")
for table_id in "${tables[@]}"; do
if [[ -z "$table_id" || "$table_id" == "null" ]]; then
print_error "Invalid table ID detected: '$table_id'"
return 1
fi
done
return 0
}
# Function to create the representatives table
create_representatives_table() {
local base_id=$1
local table_data='{
"table_name": "influence_representatives",
"title": "Influence Representatives",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "postal_code",
"title": "Postal Code",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "name",
"title": "Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "email",
"title": "Email",
"uidt": "Email",
"rqd": false
},
{
"column_name": "district_name",
"title": "District Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "elected_office",
"title": "Elected Office",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "party_name",
"title": "Party Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "representative_set_name",
"title": "Representative Set Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "url",
"title": "Profile URL",
"uidt": "URL",
"rqd": false
},
{
"column_name": "photo_url",
"title": "Photo URL",
"uidt": "URL",
"rqd": false
},
{
"column_name": "cached_at",
"title": "Cached At",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "influence_representatives" "$table_data" "Representatives data from Represent API"
}
# Function to create the email logs table
create_email_logs_table() {
local base_id=$1
local table_data='{
"table_name": "influence_email_logs",
"title": "Influence Email Logs",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "recipient_email",
"title": "Recipient Email",
"uidt": "Email",
"rqd": true
},
{
"column_name": "sender_name",
"title": "Sender Name",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "sender_email",
"title": "Sender Email",
"uidt": "Email",
"rqd": true
},
{
"column_name": "subject",
"title": "Subject",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "postal_code",
"title": "Postal Code",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "status",
"title": "Status",
"uidt": "SingleSelect",
"rqd": false,
"colOptions": {
"options": [
{"title": "sent", "color": "#00ff00"},
{"title": "failed", "color": "#ff0000"}
]
}
},
{
"column_name": "sent_at",
"title": "Sent At",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "influence_email_logs" "$table_data" "Email sending logs"
}
# Function to create the postal codes table
create_postal_codes_table() {
local base_id=$1
local table_data='{
"table_name": "influence_postal_codes",
"title": "Influence Postal Codes",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "postal_code",
"title": "Postal Code",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "city",
"title": "City",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "province",
"title": "Province",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "centroid_lat",
"title": "Centroid Latitude",
"uidt": "Decimal",
"rqd": false,
"meta": {
"precision": 10,
"scale": 8
}
},
{
"column_name": "centroid_lng",
"title": "Centroid Longitude",
"uidt": "Decimal",
"rqd": false,
"meta": {
"precision": 11,
"scale": 8
}
},
{
"column_name": "last_updated",
"title": "Last Updated",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "influence_postal_codes" "$table_data" "Postal code information cache"
}
# Function to create the campaigns table
create_campaigns_table() {
local base_id="$1"
local table_data='{
"table_name": "influence_campaigns",
"title": "Campaigns",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID"
},
{
"column_name": "slug",
"title": "Campaign Slug",
"uidt": "SingleLineText",
"unique": true,
"rqd": true
},
{
"column_name": "title",
"title": "Campaign Title",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "description",
"title": "Description",
"uidt": "LongText"
},
{
"column_name": "email_subject",
"title": "Email Subject",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "email_body",
"title": "Email Body",
"uidt": "LongText",
"rqd": true
},
{
"column_name": "call_to_action",
"title": "Call to Action",
"uidt": "LongText"
},
{
"column_name": "status",
"title": "Status",
"uidt": "SingleSelect",
"rqd": true,
"colOptions": {
"options": [
{"title": "draft", "color": "#d0f1fd"},
{"title": "active", "color": "#c2f5e8"},
{"title": "paused", "color": "#ffdce5"},
{"title": "archived", "color": "#ffeab6"}
]
}
},
{
"column_name": "allow_smtp_email",
"title": "Allow SMTP Email",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "allow_mailto_link",
"title": "Allow Mailto Link",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "collect_user_info",
"title": "Collect User Info",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "show_email_count",
"title": "Show Email Count",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "target_government_levels",
"title": "Target Government Levels",
"uidt": "MultiSelect",
"colOptions": {
"options": [
{"title": "Federal", "color": "#cfdffe"},
{"title": "Provincial", "color": "#c2f5e8"},
{"title": "Municipal", "color": "#ffdaf6"},
{"title": "School Board", "color": "#ffeab6"}
]
}
}
]
}'
create_table "$base_id" "influence_campaigns" "$table_data" "Campaign definitions and settings"
}
# Function to create the campaign emails table
create_campaign_emails_table() {
local base_id="$1"
local table_data='{
"table_name": "influence_campaign_emails",
"title": "Campaign Emails",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID"
},
{
"column_name": "campaign_id",
"title": "Campaign ID",
"uidt": "Number",
"rqd": true
},
{
"column_name": "campaign_slug",
"title": "Campaign Slug",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "user_email",
"title": "User Email",
"uidt": "Email"
},
{
"column_name": "user_name",
"title": "User Name",
"uidt": "SingleLineText"
},
{
"column_name": "user_postal_code",
"title": "User Postal Code",
"uidt": "SingleLineText"
},
{
"column_name": "recipient_email",
"title": "Recipient Email",
"uidt": "Email",
"rqd": true
},
{
"column_name": "recipient_name",
"title": "Recipient Name",
"uidt": "SingleLineText"
},
{
"column_name": "recipient_title",
"title": "Recipient Title",
"uidt": "SingleLineText"
},
{
"column_name": "recipient_level",
"title": "Government Level",
"uidt": "SingleSelect",
"colOptions": {
"options": [
{"title": "Federal", "color": "#cfdffe"},
{"title": "Provincial", "color": "#c2f5e8"},
{"title": "Municipal", "color": "#ffdaf6"},
{"title": "School Board", "color": "#ffeab6"},
{"title": "Other", "color": "#d1f7c4"}
]
}
},
{
"column_name": "email_method",
"title": "Email Method",
"uidt": "SingleSelect",
"rqd": true,
"colOptions": {
"options": [
{"title": "smtp", "color": "#c2f5e8"},
{"title": "mailto", "color": "#ffdaf6"}
]
}
},
{
"column_name": "subject",
"title": "Subject",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "message",
"title": "Message",
"uidt": "LongText",
"rqd": true
},
{
"column_name": "status",
"title": "Status",
"uidt": "SingleSelect",
"rqd": true,
"colOptions": {
"options": [
{"title": "sent", "color": "#c2f5e8"},
{"title": "failed", "color": "#ffdce5"},
{"title": "clicked", "color": "#cfdffe"}
]
}
},
{
"column_name": "sent_at",
"title": "Sent At",
"uidt": "DateTime",
"cdf": "now()",
"rqd": false
}
]
}'
create_table "$base_id" "influence_campaign_emails" "$table_data" "Campaign email tracking"
}
# Function to create the users table
create_users_table() {
local base_id="$1"
local table_data='{
"table_name": "influence_users",
"title": "Users",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "email",
"title": "Email",
"uidt": "Email",
"unique": true,
"rqd": true
},
{
"column_name": "name",
"title": "Name",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "password",
"title": "Password",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "admin",
"title": "Admin",
"uidt": "Checkbox",
"cdf": "false"
},
{
"column_name": "last_login",
"title": "Last Login",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "influence_users" "$table_data" "User authentication and management"
}
# Function to create a new base
create_base() {
local base_data='{
"title": "Alberta Influence Campaign Tool",
"type": "database"
}'
local response
response=$(make_api_call "POST" "/meta/bases" "$base_data" "Creating new base: Alberta Influence Campaign Tool" "v2")
if [[ $? -eq 0 && -n "$response" ]]; then
local base_id
base_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ -n "$base_id" ]]; then
print_success "Base 'Alberta Influence Campaign Tool' created with ID: $base_id"
echo "$base_id"
else
print_error "Failed to extract base ID from response"
return 1
fi
else
print_error "Failed to create base"
return 1
fi
}
# Function to update .env file with table IDs
update_env_with_table_ids() {
local base_id=$1
local representatives_table_id=$2
local email_logs_table_id=$3
local postal_codes_table_id=$4
local campaigns_table_id=$5
local campaign_emails_table_id=$6
local users_table_id=$7
print_status "Updating .env file with NocoDB project and table IDs..."
# Create backup of .env file
if [ -f ".env" ]; then
cp ".env" ".env.backup.$(date +%Y%m%d_%H%M%S)"
print_status "Created backup of .env file"
fi
# Function to update or add environment variable
update_env_var() {
local var_name=$1
local var_value=$2
local env_file=${3:-".env"}
if grep -q "^${var_name}=" "$env_file"; then
# Variable exists, update it
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
print_status "Updated ${var_name} in .env"
else
# Variable doesn't exist, add it
echo "${var_name}=${var_value}" >> "$env_file"
print_status "Added ${var_name} to .env"
fi
}
# Update all environment variables
update_env_var "NOCODB_PROJECT_ID" "$base_id"
update_env_var "NOCODB_TABLE_REPRESENTATIVES" "$representatives_table_id"
update_env_var "NOCODB_TABLE_EMAILS" "$email_logs_table_id"
update_env_var "NOCODB_TABLE_POSTAL_CODES" "$postal_codes_table_id"
update_env_var "NOCODB_TABLE_CAMPAIGNS" "$campaigns_table_id"
update_env_var "NOCODB_TABLE_CAMPAIGN_EMAILS" "$campaign_emails_table_id"
update_env_var "NOCODB_TABLE_USERS" "$users_table_id"
print_success "Successfully updated .env file with all table IDs"
# Display the updated values
print_status ""
print_status "Updated .env with the following values:"
print_status "NOCODB_PROJECT_ID=$base_id"
print_status "NOCODB_TABLE_REPRESENTATIVES=$representatives_table_id"
print_status "NOCODB_TABLE_EMAILS=$email_logs_table_id"
print_status "NOCODB_TABLE_POSTAL_CODES=$postal_codes_table_id"
print_status "NOCODB_TABLE_CAMPAIGNS=$campaigns_table_id"
print_status "NOCODB_TABLE_CAMPAIGN_EMAILS=$campaign_emails_table_id"
print_status "NOCODB_TABLE_USERS=$users_table_id"
}
# Main execution
main() {
print_status "Starting NocoDB Setup for Alberta Influence Campaign Tool..."
print_status "============================================================"
# First test API connectivity
if ! test_api_connectivity; then
print_error "API connectivity test failed"
exit 1
fi
print_status ""
print_status "Creating new base for Influence Campaign Tool..."
# Create a new base
BASE_ID=$(create_base)
if [[ $? -ne 0 || -z "$BASE_ID" ]]; then
print_error "Failed to create base"
exit 1
fi
print_status "Created base with ID: $BASE_ID"
print_warning "This created a new NocoDB project for the Influence Campaign Tool"
# Create tables
print_status ""
print_status "Creating tables..."
# Create representatives table
REPRESENTATIVES_TABLE_ID=$(create_representatives_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create representatives table"
exit 1
fi
# Create email logs table
EMAIL_LOGS_TABLE_ID=$(create_email_logs_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create email logs table"
exit 1
fi
# Create postal codes table
POSTAL_CODES_TABLE_ID=$(create_postal_codes_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create postal codes table"
exit 1
fi
# Create campaigns table
CAMPAIGNS_TABLE_ID=$(create_campaigns_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create campaigns table"
exit 1
fi
# Create campaign emails table
CAMPAIGN_EMAILS_TABLE_ID=$(create_campaign_emails_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create campaign emails table"
exit 1
fi
# Create users table
USERS_TABLE_ID=$(create_users_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create users table"
exit 1
fi
# Validate all table IDs were created successfully
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID"; then
print_error "One or more table IDs are invalid"
exit 1
fi
# Wait a moment for tables to be fully created
sleep 3
print_status ""
print_status "============================================================"
print_success "NocoDB Setup completed successfully!"
print_status "============================================================"
print_status ""
print_status "Created new base: Alberta Influence Campaign Tool (ID: $BASE_ID)"
print_status "Created tables:"
print_status " - influence_representatives (ID: $REPRESENTATIVES_TABLE_ID)"
print_status " - influence_email_logs (ID: $EMAIL_LOGS_TABLE_ID)"
print_status " - influence_postal_codes (ID: $POSTAL_CODES_TABLE_ID)"
print_status " - influence_campaigns (ID: $CAMPAIGNS_TABLE_ID)"
print_status " - influence_campaign_emails (ID: $CAMPAIGN_EMAILS_TABLE_ID)"
print_status " - influence_users (ID: $USERS_TABLE_ID)"
# Automatically update .env file with new project ID
print_status ""
print_status "Updating .env file with new project ID..."
if [ -f ".env" ]; then
# Update existing .env file
if grep -q "NOCODB_PROJECT_ID=" .env; then
# Replace existing NOCODB_PROJECT_ID
sed -i "s/NOCODB_PROJECT_ID=.*/NOCODB_PROJECT_ID=$BASE_ID/" .env
print_success "Updated NOCODB_PROJECT_ID in .env file"
else
# Add new NOCODB_PROJECT_ID
echo "NOCODB_PROJECT_ID=$BASE_ID" >> .env
print_success "Added NOCODB_PROJECT_ID to .env file"
fi
else
print_error ".env file not found - please create one from .env.example"
exit 1
fi
# Update .env file with table IDs
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID"
print_status ""
print_status "============================================================"
print_success "Automated setup completed successfully!"
print_status "============================================================"
print_status ""
print_status "Created new base: Alberta Influence Campaign Tool (ID: $BASE_ID)"
print_status "Updated .env file with project ID and all table IDs"
print_status ""
print_status "Next steps:"
print_status "1. Check your NocoDB instance at: $BASE_URL"
print_status "2. Verify the tables were created successfully"
print_status "3. Start your influence campaign application with: docker compose up"
print_status "4. The application will be available at: http://localhost:3333"
print_status "5. Access the admin panel at: http://localhost:3333/admin.html"
print_status ""
print_success "Your Alberta Influence Campaign Tool is ready to use!"
}
# Check if script is being run directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
# Check for command line arguments
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --help, -h Show this help message"
echo ""
echo "Creates a new NocoDB base with all required tables for the Influence Campaign Tool"
exit 0
else
main "$@"
fi
fi