Pushing the new influence app in its current state
This commit is contained in:
parent
83f5055471
commit
e037017817
@ -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
18
influence/app/Dockerfile
Normal 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"]
|
||||||
159
influence/app/controllers/authController.js
Normal file
159
influence/app/controllers/authController.js
Normal 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();
|
||||||
500
influence/app/controllers/campaigns.js
Normal file
500
influence/app/controllers/campaigns.js
Normal 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();
|
||||||
61
influence/app/controllers/emails.js
Normal file
61
influence/app/controllers/emails.js
Normal 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();
|
||||||
170
influence/app/controllers/representatives.js
Normal file
170
influence/app/controllers/representatives.js
Normal 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();
|
||||||
71
influence/app/middleware/auth.js
Normal file
71
influence/app/middleware/auth.js
Normal 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
|
||||||
|
};
|
||||||
39
influence/app/package.json
Normal file
39
influence/app/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
532
influence/app/public/admin.html
Normal file
532
influence/app/public/admin.html
Normal 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>
|
||||||
636
influence/app/public/admin.html.broken
Normal file
636
influence/app/public/admin.html.broken
Normal 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;">×</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>
|
||||||
671
influence/app/public/campaign.html
Normal file
671
influence/app/public/campaign.html
Normal 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>
|
||||||
455
influence/app/public/css/styles.css
Normal file
455
influence/app/public/css/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
influence/app/public/index.html
Normal file
130
influence/app/public/index.html
Normal 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">×</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>© 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>
|
||||||
511
influence/app/public/js/admin.js
Normal file
511
influence/app/public/js/admin.js
Normal 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">×</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();
|
||||||
|
});
|
||||||
73
influence/app/public/js/api-client.js
Normal file
73
influence/app/public/js/api-client.js
Normal 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();
|
||||||
196
influence/app/public/js/auth.js
Normal file
196
influence/app/public/js/auth.js
Normal 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;
|
||||||
|
}
|
||||||
237
influence/app/public/js/email-composer.js
Normal file
237
influence/app/public/js/email-composer.js
Normal 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();
|
||||||
|
});
|
||||||
79
influence/app/public/js/login.js
Normal file
79
influence/app/public/js/login.js
Normal 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.');
|
||||||
|
}
|
||||||
|
});
|
||||||
152
influence/app/public/js/main.js
Normal file
152
influence/app/public/js/main.js
Normal 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());
|
||||||
|
});
|
||||||
158
influence/app/public/js/postal-lookup.js
Normal file
158
influence/app/public/js/postal-lookup.js
Normal 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();
|
||||||
|
});
|
||||||
192
influence/app/public/js/representatives-display.js
Normal file
192
influence/app/public/js/representatives-display.js
Normal 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();
|
||||||
|
});
|
||||||
184
influence/app/public/login.html
Normal file
184
influence/app/public/login.html
Normal 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>
|
||||||
92
influence/app/routes/api.js
Normal file
92
influence/app/routes/api.js
Normal 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;
|
||||||
15
influence/app/routes/auth.js
Normal file
15
influence/app/routes/auth.js
Normal 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
100
influence/app/server.js
Normal 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;
|
||||||
128
influence/app/services/email.js
Normal file
128
influence/app/services/email.js
Normal 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();
|
||||||
459
influence/app/services/nocodb.js
Normal file
459
influence/app/services/nocodb.js
Normal 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();
|
||||||
375
influence/app/services/nocodb.js.backup
Normal file
375
influence/app/services/nocodb.js.backup
Normal 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();
|
||||||
146
influence/app/services/represent-api.js
Normal file
146
influence/app/services/represent-api.js
Normal 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();
|
||||||
44
influence/app/utils/rate-limiter.js
Normal file
44
influence/app/utils/rate-limiter.js
Normal 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
|
||||||
|
};
|
||||||
91
influence/app/utils/validators.js
Normal file
91
influence/app/utils/validators.js
Normal 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
|
||||||
|
};
|
||||||
15
influence/docker-compose.yml
Normal file
15
influence/docker-compose.yml
Normal 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
|
||||||
@ -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.
|
||||||
296
influence/fix-campaigns-table.sh
Normal file
296
influence/fix-campaigns-table.sh
Normal 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
|
||||||
1288
influence/influence-campaign-setup.md
Normal file
1288
influence/influence-campaign-setup.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
930
influence/scripts/build-nocodb.sh
Executable 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
|
||||||
Loading…
x
Reference in New Issue
Block a user