From e037017817d30e77c6dbed4393e82b21ed593c2f Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 20 Sep 2025 11:19:26 -0600 Subject: [PATCH] Pushing the new influence app in its current state --- influence/README.MD | 233 ++- influence/app/Dockerfile | 18 + influence/app/controllers/authController.js | 159 ++ influence/app/controllers/campaigns.js | 500 +++++++ influence/app/controllers/emails.js | 61 + influence/app/controllers/representatives.js | 170 +++ influence/app/middleware/auth.js | 71 + influence/app/package.json | 39 + influence/app/public/admin.html | 532 +++++++ influence/app/public/admin.html.broken | 636 ++++++++ influence/app/public/campaign.html | 671 +++++++++ influence/app/public/css/styles.css | 455 ++++++ influence/app/public/index.html | 130 ++ influence/app/public/js/admin.js | 511 +++++++ influence/app/public/js/api-client.js | 73 + influence/app/public/js/auth.js | 196 +++ influence/app/public/js/email-composer.js | 237 +++ influence/app/public/js/login.js | 79 + influence/app/public/js/main.js | 152 ++ influence/app/public/js/postal-lookup.js | 158 ++ .../app/public/js/representatives-display.js | 192 +++ influence/app/public/login.html | 184 +++ influence/app/routes/api.js | 92 ++ influence/app/routes/auth.js | 15 + influence/app/server.js | 100 ++ influence/app/services/email.js | 128 ++ influence/app/services/nocodb.js | 459 ++++++ influence/app/services/nocodb.js.backup | 375 +++++ influence/app/services/represent-api.js | 146 ++ influence/app/utils/rate-limiter.js | 44 + influence/app/utils/validators.js | 91 ++ influence/docker-compose.yml | 15 + influence/files-explainer.md | 271 ++++ influence/fix-campaigns-table.sh | 296 ++++ influence/influence-campaign-setup.md | 1288 +++++++++++++++++ influence/instruct.md | 46 + influence/scripts/build-nocodb.sh | 930 ++++++++++++ 37 files changed, 9751 insertions(+), 2 deletions(-) create mode 100644 influence/app/Dockerfile create mode 100644 influence/app/controllers/authController.js create mode 100644 influence/app/controllers/campaigns.js create mode 100644 influence/app/controllers/emails.js create mode 100644 influence/app/controllers/representatives.js create mode 100644 influence/app/middleware/auth.js create mode 100644 influence/app/package.json create mode 100644 influence/app/public/admin.html create mode 100644 influence/app/public/admin.html.broken create mode 100644 influence/app/public/campaign.html create mode 100644 influence/app/public/css/styles.css create mode 100644 influence/app/public/index.html create mode 100644 influence/app/public/js/admin.js create mode 100644 influence/app/public/js/api-client.js create mode 100644 influence/app/public/js/auth.js create mode 100644 influence/app/public/js/email-composer.js create mode 100644 influence/app/public/js/login.js create mode 100644 influence/app/public/js/main.js create mode 100644 influence/app/public/js/postal-lookup.js create mode 100644 influence/app/public/js/representatives-display.js create mode 100644 influence/app/public/login.html create mode 100644 influence/app/routes/api.js create mode 100644 influence/app/routes/auth.js create mode 100644 influence/app/server.js create mode 100644 influence/app/services/email.js create mode 100644 influence/app/services/nocodb.js create mode 100644 influence/app/services/nocodb.js.backup create mode 100644 influence/app/services/represent-api.js create mode 100644 influence/app/utils/rate-limiter.js create mode 100644 influence/app/utils/validators.js create mode 100644 influence/docker-compose.yml create mode 100644 influence/fix-campaigns-table.sh create mode 100644 influence/influence-campaign-setup.md create mode 100755 influence/scripts/build-nocodb.sh diff --git a/influence/README.MD b/influence/README.MD index 557022f..c1e5a68 100644 --- a/influence/README.MD +++ b/influence/README.MD @@ -1,4 +1,233 @@ -# README +# Alberta Influence Campaign Tool -Welcome to Influence, a tool for creating political change by targeting influential individuals within a community. This application is designed to help campaigns identify and engage with key figures who can sway public opinion and mobilize support. +A comprehensive web application that helps Alberta residents connect with their elected representatives across all levels of government. Users can find their representatives by postal code and send direct emails to advocate for important issues. + +## Features + +- **Representative Lookup**: Find elected officials by Alberta postal code (T prefixed) +- **Multi-Level Government**: Displays federal MPs, provincial MLAs, and municipal representatives +- **Contact Information**: Shows photos, email addresses, phone numbers, and office locations +- **Direct Email**: Built-in email composer to contact representatives +- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API +- **Responsive Design**: Works seamlessly on desktop and mobile devices +- **Real-time Data**: Integrates with Represent OpenNorth API for up-to-date information + +## Technology Stack + +- **Backend**: Node.js with Express.js +- **Database**: NocoDB (REST API) +- **External API**: Represent OpenNorth Canada API +- **Frontend**: Vanilla JavaScript, HTML5, CSS3 +- **Email**: SMTP integration +- **Deployment**: Docker with docker-compose +- **Rate Limiting**: Express rate limiter for API protection + +## Quick Start + +### Prerequisites +- Docker and Docker Compose +- Access to existing NocoDB instance +- SMTP email configuration + +### Installation + +1. **Clone and navigate to the project**: + ```bash + cd /path/to/changemaker.lite/influence + ``` + +2. **Configure environment**: + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + +3. **Set up NocoDB tables**: + ```bash + ./scripts/build-nocodb.sh + ``` + +4. **Start the application**: + ```bash + docker compose up --build + ``` + +5. **Access the application**: + - Open http://localhost:3333 + - Enter an Alberta postal code (e.g., T5N4B8) + - View your representatives and send emails + +## Configuration + +### Environment Variables (.env) + +```bash +# Server Configuration +NODE_ENV=production +PORT=3333 + +# NocoDB Configuration +NOCODB_API_URL=https://db.cmlite.org +NOCODB_API_TOKEN=your_nocodb_token +NOCODB_PROJECT_ID=your_project_id + +# Email Configuration (SMTP) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your_email@gmail.com +SMTP_PASS=your_app_password +SMTP_FROM_NAME=Alberta Influence Campaign +SMTP_FROM_EMAIL=your_email@gmail.com + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 +``` + +## API Endpoints + +### Representatives +- `GET /api/representatives/by-postal/:postalCode` - Get representatives by postal code +- `POST /api/representatives/refresh-postal/:postalCode` - Refresh cached data + +### Email +- `POST /api/emails/send` - Send email to representative +- `GET /api/emails/logs` - Get email sending logs (with filters) + +### Health +- `GET /api/health` - Application health check +- `GET /api/test-represent` - Test Represent API connection + +## Database Schema + +### Representatives Table +- postal_code, name, email, district_name +- elected_office, party_name, representative_set_name +- url, photo_url, cached_at + +### Emails Table +- recipient_email, recipient_name, sender_email +- subject, message, status, sent_at + +### Postal Codes Table +- postal_code, city, province +- centroid_lat, centroid_lng, last_updated + +## Development + +### Project Structure +``` +influence/ +├── app/ +│ ├── controllers/ # Business logic +│ ├── routes/ # API routes +│ ├── services/ # External integrations +│ ├── utils/ # Helper functions +│ ├── middleware/ # Express middleware +│ ├── public/ # Frontend assets +│ └── server.js # Express app entry point +├── scripts/ +│ └── build-nocodb.sh # Database setup +├── docker-compose.yml # Container orchestration +├── Dockerfile # Container definition +└── .env # Environment configuration +``` + +### Key Components + +- **RepresentativesController**: Handles postal code lookups and caching +- **EmailController**: Manages email composition and sending +- **NocoDBService**: Database operations with error handling +- **RepresentAPI**: Integration with OpenNorth Represent API +- **EmailService**: SMTP email functionality + +## Features in Detail + +### Smart Caching System +- First request fetches from Represent API and caches in NocoDB +- Subsequent requests served from cache for fast performance +- Graceful fallback to API when NocoDB is unavailable +- Automatic error recovery and retry logic + +### Representative Display +- Shows photo with fallback to initials +- Contact information including phone and address +- Party affiliation and government level +- Direct links to official profiles + +### Email Integration +- Modal-based email composer +- Pre-filled recipient information +- SMTP sending with delivery confirmation +- Email history and logging + +### Error Handling +- Comprehensive error logging +- User-friendly error messages +- API fallback mechanisms +- Rate limiting protection + +## Production Deployment + +### Docker Production +```bash +# Build and start in production mode +docker compose -f docker-compose.yml up -d --build + +# View logs +docker compose logs -f app + +# Scale if needed +docker compose up --scale app=2 +``` + +### Monitoring +- Health check endpoint: `/api/health` +- Application logs via Docker +- NocoDB integration status monitoring +- Email delivery tracking + +## Troubleshooting + +### Common Issues + +1. **NocoDB Connection Errors**: + - Check API URL and token in .env + - Run `./scripts/build-nocodb.sh` to setup tables + - Application works without NocoDB (API fallback) + +2. **Email Not Sending**: + - Verify SMTP credentials in .env + - Check spam/junk folders + - Review email logs via API endpoint + +3. **No Representatives Found**: + - Ensure postal code starts with 'T' (Alberta) + - Check Represent API status + - Try different postal code format + +### Log Analysis +```bash +# View application logs +docker compose logs app + +# Follow logs in real-time +docker compose logs -f app + +# Check specific errors +docker compose logs app | grep ERROR +``` + +## Contributing + +This is part of the larger changemaker.lite project. Follow the established patterns for: +- Error handling and logging +- API response formats +- Database integration +- Frontend component structure + +## License + +Part of the changemaker.lite project ecosystem. diff --git a/influence/app/Dockerfile b/influence/app/Dockerfile new file mode 100644 index 0000000..c1f84c0 --- /dev/null +++ b/influence/app/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +WORKDIR /usr/src/app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --only=production + +# Copy app files +COPY . . + +# Expose port +EXPOSE 3000 + +# Start the application +CMD ["node", "server.js"] \ No newline at end of file diff --git a/influence/app/controllers/authController.js b/influence/app/controllers/authController.js new file mode 100644 index 0000000..f94fc7e --- /dev/null +++ b/influence/app/controllers/authController.js @@ -0,0 +1,159 @@ +const nocodbService = require('../services/nocodb'); + +class AuthController { + async login(req, res) { + try { + const { email, password } = req.body; + + // Validate input + if (!email || !password) { + return res.status(400).json({ + success: false, + error: 'Email and password are required' + }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + error: 'Invalid email format' + }); + } + + console.log('Login attempt:', { + email, + ip: req.ip, + userAgent: req.headers['user-agent'] + }); + + // Fetch user from NocoDB + const user = await nocodbService.getUserByEmail(email); + + if (!user) { + console.warn(`No user found with email: ${email}`); + return res.status(401).json({ + success: false, + error: 'Invalid email or password' + }); + } + + // Check password + if (user.Password !== password && user.password !== password) { + console.warn(`Invalid password for email: ${email}`); + return res.status(401).json({ + success: false, + error: 'Invalid email or password' + }); + } + + // Update last login time + try { + const userId = user.Id || user.id; + await nocodbService.updateUser(userId, { + 'Last Login': new Date().toISOString() + }); + } catch (updateError) { + console.warn('Failed to update last login time:', updateError.message); + // Don't fail the login + } + + // Set session + req.session.authenticated = true; + req.session.userId = user.Id || user.id; + req.session.userEmail = user.Email || user.email; + req.session.userName = user.Name || user.name; + req.session.isAdmin = user.Admin || user.admin || false; + + console.log('User logged in successfully:', { + email: req.session.userEmail, + isAdmin: req.session.isAdmin + }); + + // Force session save + req.session.save((err) => { + if (err) { + console.error('Session save error:', err); + return res.status(500).json({ + success: false, + error: 'Session error. Please try again.' + }); + } + + res.json({ + success: true, + user: { + id: req.session.userId, + email: req.session.userEmail, + name: req.session.userName, + isAdmin: req.session.isAdmin + } + }); + }); + + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ + success: false, + error: 'Server error. Please try again later.' + }); + } + } + + async logout(req, res) { + try { + const userEmail = req.session?.userEmail; + + req.session.destroy((err) => { + if (err) { + console.error('Session destroy error:', err); + return res.status(500).json({ + success: false, + error: 'Logout failed' + }); + } + + console.log('User logged out:', userEmail); + res.json({ success: true }); + }); + } catch (error) { + console.error('Logout error:', error); + res.status(500).json({ + success: false, + error: 'Server error during logout' + }); + } + } + + async checkSession(req, res) { + try { + const isAuthenticated = (req.session && req.session.authenticated) || + (req.session && req.session.userId && req.session.userEmail); + + if (isAuthenticated) { + res.json({ + authenticated: true, + user: { + id: req.session.userId, + email: req.session.userEmail, + name: req.session.userName, + isAdmin: req.session.isAdmin + } + }); + } else { + res.json({ + authenticated: false + }); + } + } catch (error) { + console.error('Session check error:', error); + res.status(500).json({ + success: false, + error: 'Session check failed' + }); + } + } +} + +module.exports = new AuthController(); \ No newline at end of file diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js new file mode 100644 index 0000000..c88791b --- /dev/null +++ b/influence/app/controllers/campaigns.js @@ -0,0 +1,500 @@ +const nocoDB = require('../services/nocodb'); +const emailService = require('../services/email'); +const representAPI = require('../services/represent-api'); +const { generateSlug, validateSlug } = require('../utils/validators'); + +class CampaignsController { + // Get all campaigns (for admin panel) + async getAllCampaigns(req, res, next) { + try { + const campaigns = await nocoDB.getAllCampaigns(); + + // Get email counts for each campaign + const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => { + const id = campaign.Id ?? campaign.id; + let emailCount = 0; + if (id != null) { + emailCount = await nocoDB.getCampaignEmailCount(id); + } + // Normalize id property for frontend + return { + id, + ...campaign, + emailCount + }; + })); + + res.json({ + success: true, + campaigns: campaignsWithCounts + }); + } catch (error) { + console.error('Get campaigns error:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve campaigns', + message: error.message, + details: error.response?.data || null + }); + } + } + + // Get single campaign by ID (for admin) + async getCampaignById(req, res, next) { + try { + const { id } = req.params; + const campaign = await nocoDB.getCampaignById(id); + + if (!campaign) { + return res.status(404).json({ + success: false, + error: 'Campaign not found' + }); + } + + const normalizedId = campaign.Id ?? campaign.id ?? id; + const emailCount = await nocoDB.getCampaignEmailCount(normalizedId); + + res.json({ + success: true, + campaign: { + id: normalizedId, + ...campaign, + emailCount + } + }); + } catch (error) { + console.error('Get campaign error:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve campaign', + message: error.message, + details: error.response?.data || null + }); + } + } + + // Get campaign by slug (for public access) + async getCampaignBySlug(req, res, next) { + try { + const { slug } = req.params; + const campaign = await nocoDB.getCampaignBySlug(slug); + + if (!campaign) { + return res.status(404).json({ + success: false, + error: 'Campaign not found' + }); + } + + if (campaign.status !== 'active') { + return res.status(403).json({ + success: false, + error: 'Campaign is not currently active' + }); + } + + // Get email count if enabled + let emailCount = null; + if (campaign.show_email_count) { + const id = campaign.Id ?? campaign.id; + if (id != null) { + emailCount = await nocoDB.getCampaignEmailCount(id); + } + } + + res.json({ + success: true, + campaign: { + id: campaign.id, + slug: campaign.slug, + title: campaign.title, + description: campaign.description, + call_to_action: campaign.call_to_action, + email_subject: campaign.email_subject, + email_body: campaign.email_body, + allow_smtp_email: campaign.allow_smtp_email, + allow_mailto_link: campaign.allow_mailto_link, + collect_user_info: campaign.collect_user_info, + show_email_count: campaign.show_email_count, + target_government_levels: Array.isArray(campaign.target_government_levels) + ? campaign.target_government_levels + : (typeof campaign.target_government_levels === 'string' && campaign.target_government_levels.length > 0 + ? campaign.target_government_levels.split(',').map(s => s.trim()) + : []), + emailCount + } + }); + } catch (error) { + console.error('Get campaign by slug error:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve campaign', + message: error.message, + details: error.response?.data || null + }); + } + } + + // Create new campaign + async createCampaign(req, res, next) { + try { + const { + title, + description, + email_subject, + email_body, + call_to_action, + allow_smtp_email = true, + allow_mailto_link = true, + collect_user_info = true, + show_email_count = true, + target_government_levels = ['Federal', 'Provincial', 'Municipal'] + } = req.body; + + // Generate slug from title + let slug = generateSlug(title); + + // Ensure slug is unique + let counter = 1; + let originalSlug = slug; + while (await nocoDB.getCampaignBySlug(slug)) { + slug = `${originalSlug}-${counter}`; + counter++; + } + + const campaignData = { + slug, + title, + description, + email_subject, + email_body, + call_to_action, + status: 'draft', + allow_smtp_email, + allow_mailto_link, + collect_user_info, + show_email_count, + // NocoDB MultiSelect expects an array of values + target_government_levels: Array.isArray(target_government_levels) + ? target_government_levels + : (typeof target_government_levels === 'string' && target_government_levels.length > 0 + ? target_government_levels.split(',').map(s => s.trim()) + : []) + }; + + const campaign = await nocoDB.createCampaign(campaignData); + + res.status(201).json({ + success: true, + campaign: { + id: campaign.Id ?? campaign.id, + ...campaign + } + }); + } catch (error) { + console.error('Create campaign error:', error); + res.status(500).json({ + success: false, + error: 'Failed to create campaign', + message: error.message, + details: error.response?.data || null + }); + } + } + + // Update campaign + async updateCampaign(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + // If title is being updated, regenerate slug + if (updates.title) { + let slug = generateSlug(updates.title); + + // Ensure slug is unique (but allow current campaign to keep its slug) + const existingCampaign = await nocoDB.getCampaignBySlug(slug); + if (existingCampaign && existingCampaign.id !== parseInt(id)) { + let counter = 1; + let originalSlug = slug; + while (await nocoDB.getCampaignBySlug(slug)) { + slug = `${originalSlug}-${counter}`; + counter++; + } + } + updates.slug = slug; + } + + // Ensure target_government_levels remains an array for MultiSelect + if (updates.target_government_levels) { + updates.target_government_levels = Array.isArray(updates.target_government_levels) + ? updates.target_government_levels + : (typeof updates.target_government_levels === 'string' + ? updates.target_government_levels.split(',').map(s => s.trim()) + : []); + } + + updates.updated_at = new Date().toISOString(); + + const campaign = await nocoDB.updateCampaign(id, updates); + + res.json({ + success: true, + campaign: { + id: campaign.Id ?? campaign.id ?? id, + ...campaign + } + }); + } catch (error) { + console.error('Update campaign error:', error); + res.status(500).json({ + success: false, + error: 'Failed to update campaign', + message: error.message, + details: error.response?.data || null + }); + } + } + + // Delete campaign + async deleteCampaign(req, res, next) { + try { + const { id } = req.params; + + await nocoDB.deleteCampaign(id); + + res.json({ + success: true, + message: 'Campaign deleted successfully' + }); + } catch (error) { + console.error('Delete campaign error:', error); + res.status(500).json({ + success: false, + error: 'Failed to delete campaign', + message: error.message, + details: error.response?.data || null + }); + } + } + + // Send campaign email + async sendCampaignEmail(req, res, next) { + try { + const { slug } = req.params; + const { + userEmail, + userName, + postalCode, + recipientEmail, + recipientName, + recipientTitle, + recipientLevel, + emailMethod = 'smtp' + } = req.body; + + // Get campaign + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ + success: false, + error: 'Campaign not found' + }); + } + + if (campaign.status !== 'active') { + return res.status(403).json({ + success: false, + error: 'Campaign is not currently active' + }); + } + + // Check if the requested email method is allowed + if (emailMethod === 'smtp' && !campaign.allow_smtp_email) { + return res.status(403).json({ + success: false, + error: 'SMTP email sending is not enabled for this campaign' + }); + } + + if (emailMethod === 'mailto' && !campaign.allow_mailto_link) { + return res.status(403).json({ + success: false, + error: 'Mailto links are not enabled for this campaign' + }); + } + + const subject = campaign.email_subject; + const message = campaign.email_body; + + let emailResult = { success: true }; + + // Send email if SMTP method + if (emailMethod === 'smtp') { + emailResult = await emailService.sendEmail({ + to: recipientEmail, + from: { + email: process.env.SMTP_FROM_EMAIL, + name: process.env.SMTP_FROM_NAME + }, + replyTo: userEmail, + subject: subject, + text: message, + html: ` +

${message.replace(/\n/g, '
')}

+
+

This message was sent via the Alberta Influence Campaign Tool by ${userName || 'A constituent'} (${userEmail}) from postal code ${postalCode} as part of the "${campaign.title}" campaign.

+ ` + }); + } + + // Log the campaign email + await nocoDB.logCampaignEmail({ + campaign_id: campaign.Id ?? campaign.id, + campaign_slug: slug, + user_email: userEmail, + user_name: userName, + user_postal_code: postalCode, + recipient_email: recipientEmail, + recipient_name: recipientName, + recipient_title: recipientTitle, + recipient_level: recipientLevel, + email_method: emailMethod, + subject: subject, + message: message, + status: emailMethod === 'mailto' ? 'clicked' : (emailResult.success ? 'sent' : 'failed'), + timestamp: new Date().toISOString() + }); + + if (emailMethod === 'smtp') { + if (emailResult.success) { + res.json({ + success: true, + message: 'Email sent successfully' + }); + } else { + res.status(500).json({ + success: false, + error: 'Failed to send email', + message: emailResult.error + }); + } + } else { + // For mailto, just return success since we're tracking the click + res.json({ + success: true, + message: 'Email action tracked' + }); + } + + } catch (error) { + console.error('Send campaign email error:', error); + res.status(500).json({ + success: false, + error: 'Failed to process campaign email', + message: error.message, + details: error.response?.data || null + }); + } + } + + // Get representatives for postal code (for campaign use) + async getRepresentativesForCampaign(req, res, next) { + try { + const { slug, postalCode } = req.params; + + // Get campaign to check target levels + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ + success: false, + error: 'Campaign not found' + }); + } + + if (campaign.status !== 'active') { + return res.status(403).json({ + success: false, + error: 'Campaign is not currently active' + }); + } + + // Get representatives + const result = await representAPI.getRepresentativesByPostalCode(postalCode); + + if (!result.success) { + return res.status(result.status || 500).json(result); + } + + // Filter representatives by target government levels + const targetLevels = Array.isArray(campaign.target_government_levels) + ? campaign.target_government_levels + : (typeof campaign.target_government_levels === 'string' && campaign.target_government_levels.length > 0 + ? campaign.target_government_levels.split(',').map(level => level.trim()) + : ['Federal', 'Provincial', 'Municipal']); + + const filteredRepresentatives = result.representatives.filter(rep => { + const repLevel = rep.elected_office ? rep.elected_office.toLowerCase() : 'other'; + + return targetLevels.some(targetLevel => { + const target = targetLevel.toLowerCase(); + + if (target === 'federal' && (repLevel.includes('mp') || repLevel.includes('member of parliament'))) { + return true; + } + if (target === 'provincial' && (repLevel.includes('mla') || repLevel.includes('legislative assembly'))) { + return true; + } + if (target === 'municipal' && (repLevel.includes('mayor') || repLevel.includes('councillor') || repLevel.includes('council'))) { + return true; + } + if (target === 'school board' && repLevel.includes('school')) { + return true; + } + + return false; + }); + }); + + res.json({ + success: true, + representatives: filteredRepresentatives, + location: result.location + }); + + } catch (error) { + console.error('Get representatives for campaign error:', error); + res.status(500).json({ + success: false, + error: 'Failed to get representatives', + message: error.message, + details: error.response?.data || null + }); + } + } + + // Get campaign analytics + async getCampaignAnalytics(req, res, next) { + try { + const { id } = req.params; + + const analytics = await nocoDB.getCampaignAnalytics(id); + + res.json({ + success: true, + analytics + }); + } catch (error) { + console.error('Get campaign analytics error:', error); + res.status(500).json({ + success: false, + error: 'Failed to get campaign analytics', + message: error.message, + details: error.response?.data || null + }); + } + } +} + +module.exports = new CampaignsController(); \ No newline at end of file diff --git a/influence/app/controllers/emails.js b/influence/app/controllers/emails.js new file mode 100644 index 0000000..5203241 --- /dev/null +++ b/influence/app/controllers/emails.js @@ -0,0 +1,61 @@ +const emailService = require('../services/email'); +const nocoDB = require('../services/nocodb'); + +class EmailsController { + async sendEmail(req, res, next) { + try { + const { recipientEmail, senderName, senderEmail, subject, message, postalCode } = req.body; + + // Send the email + const emailResult = await emailService.sendEmail({ + to: recipientEmail, + from: { + email: process.env.SMTP_FROM_EMAIL, + name: process.env.SMTP_FROM_NAME + }, + replyTo: senderEmail, + subject: subject, + text: message, + html: ` +

${message.replace(/\n/g, '
')}

+
+

This message was sent via the Alberta Influence Campaign Tool by ${senderName} (${senderEmail}) from postal code ${postalCode}.

+ ` + }); + + // Log the email send event + await nocoDB.logEmailSend({ + recipientEmail, + senderName, + senderEmail, + subject, + postalCode, + status: emailResult.success ? 'sent' : 'failed', + timestamp: new Date().toISOString() + }); + + if (emailResult.success) { + res.json({ + success: true, + message: 'Email sent successfully', + messageId: emailResult.messageId + }); + } else { + res.status(500).json({ + success: false, + error: 'Failed to send email', + message: emailResult.error + }); + } + } catch (error) { + console.error('Send email error:', error); + res.status(500).json({ + success: false, + error: 'Failed to send email', + message: error.message + }); + } + } +} + +module.exports = new EmailsController(); \ No newline at end of file diff --git a/influence/app/controllers/representatives.js b/influence/app/controllers/representatives.js new file mode 100644 index 0000000..a977ba9 --- /dev/null +++ b/influence/app/controllers/representatives.js @@ -0,0 +1,170 @@ +const representAPI = require('../services/represent-api'); +const nocoDB = require('../services/nocodb'); + +// Helper function to cache representatives +async function cacheRepresentatives(postalCode, representatives, representData) { + try { + // Cache the postal code info + await nocoDB.storePostalCodeInfo({ + postal_code: postalCode, + city: representData.city, + province: representData.province + }); + + // Cache representatives using the existing method + await nocoDB.storeRepresentatives(postalCode, representatives); + + console.log(`Successfully cached representatives for ${postalCode}`); + } catch (error) { + console.log(`Failed to cache representatives for ${postalCode}:`, error.message); + // Don't throw - caching is optional + } +} + +class RepresentativesController { + async testConnection(req, res, next) { + try { + const result = await representAPI.testConnection(); + res.json(result); + } catch (error) { + console.error('Test connection error:', error); + res.status(500).json({ + error: 'Failed to test connection', + message: error.message + }); + } + } + + async getByPostalCode(req, res, next) { + try { + const { postalCode } = req.params; + const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase(); + + // Try to check cached data first, but don't fail if NocoDB is down + let cachedData = []; + try { + cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode); + console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`); + + if (cachedData && cachedData.length > 0) { + return res.json({ + source: 'cache', + postalCode: formattedPostalCode, + representatives: cachedData + }); + } + } catch (cacheError) { + console.log(`Cache unavailable for ${formattedPostalCode}, proceeding with API call:`, cacheError.message); + } + + // If not in cache, fetch from Represent API + console.log(`Fetching representatives from Represent API for ${postalCode}`); + const representData = await representAPI.getRepresentativesByPostalCode(postalCode); + + if (!representData) { + return res.json({ + success: false, + message: 'No data found for this postal code', + data: { + postalCode, + location: null, + representatives: [] + } + }); + } + + // Process representatives from both concordance and centroid + let representatives = []; + + // Add concordance representatives (if any) + if (representData.boundaries_concordance && representData.boundaries_concordance.length > 0) { + representatives = representatives.concat(representData.boundaries_concordance); + } + + // Add centroid representatives (if any) - these are the actual elected officials + if (representData.representatives_centroid && representData.representatives_centroid.length > 0) { + representatives = representatives.concat(representData.representatives_centroid); + } + + console.log(`Representatives concordance count: ${representData.boundaries_concordance ? representData.boundaries_concordance.length : 0}`); + console.log(`Representatives centroid count: ${representData.representatives_centroid ? representData.representatives_centroid.length : 0}`); + console.log(`Total representatives found: ${representatives.length}`); + + if (representatives.length === 0) { + return res.json({ + success: false, + message: 'No representatives found for this postal code', + data: { + postalCode, + location: { + city: representData.city, + province: representData.province + }, + representatives: [] + } + }); + } + + // Try to cache the results (will fail gracefully if NocoDB is down) + console.log(`Attempting to cache ${representatives.length} representatives for ${postalCode}`); + await cacheRepresentatives(postalCode, representatives, representData); + + res.json({ + success: true, + data: { + postalCode, + location: { + city: representData.city, + province: representData.province + }, + representatives + } + }); + } catch (error) { + console.error('Get representatives error:', error); + res.status(500).json({ + error: 'Failed to fetch representatives', + message: error.message + }); + } + } + + async refreshPostalCode(req, res, next) { + try { + const { postalCode } = req.params; + const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase(); + + // Clear cached data + await nocoDB.clearRepresentativesByPostalCode(formattedPostalCode); + + // Fetch fresh data from API + const representData = await representAPI.getRepresentativesByPostalCode(formattedPostalCode); + + if (!representData || !representData.representatives_concordance) { + return res.status(404).json({ + error: 'No representatives found for this postal code', + postalCode: formattedPostalCode + }); + } + + // Cache the fresh results + await nocoDB.storeRepresentatives(formattedPostalCode, representData.representatives_concordance); + + res.json({ + source: 'refreshed', + postalCode: formattedPostalCode, + representatives: representData.representatives_concordance, + city: representData.city, + province: representData.province + }); + } catch (error) { + console.error('Refresh representatives error:', error); + res.status(500).json({ + error: 'Failed to refresh representatives', + message: error.message + }); + } + } +} + +module.exports = new RepresentativesController(); \ No newline at end of file diff --git a/influence/app/middleware/auth.js b/influence/app/middleware/auth.js new file mode 100644 index 0000000..8641c54 --- /dev/null +++ b/influence/app/middleware/auth.js @@ -0,0 +1,71 @@ +const nocodbService = require('../services/nocodb'); + +const requireAuth = async (req, res, next) => { + const isAuthenticated = (req.session && req.session.authenticated) || + (req.session && req.session.userId && req.session.userEmail); + + if (isAuthenticated) { + // Set up req.user object for controllers that expect it + req.user = { + id: req.session.userId, + email: req.session.userEmail, + isAdmin: req.session.isAdmin || false + }; + + next(); + } else { + console.warn('Unauthorized access attempt', { + ip: req.ip, + path: req.path, + userAgent: req.get('User-Agent'), + method: req.method, + timestamp: new Date().toISOString() + }); + + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(401).json({ + success: false, + error: 'Authentication required' + }); + } else { + res.redirect('/login.html'); + } + } +}; + +const requireAdmin = async (req, res, next) => { + const isAuthenticated = (req.session && req.session.authenticated) || + (req.session && req.session.userId && req.session.userEmail); + + if (isAuthenticated && req.session.isAdmin) { + // Set up req.user object for controllers that expect it + req.user = { + id: req.session.userId, + email: req.session.userEmail, + isAdmin: req.session.isAdmin || false + }; + + next(); + } else { + console.warn('Unauthorized admin access attempt', { + ip: req.ip, + path: req.path, + user: req.session?.userEmail || 'anonymous', + userAgent: req.get('User-Agent') + }); + + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(403).json({ + success: false, + error: 'Admin access required' + }); + } else { + res.redirect('/login.html'); + } + } +}; + +module.exports = { + requireAuth, + requireAdmin +}; \ No newline at end of file diff --git a/influence/app/package.json b/influence/app/package.json new file mode 100644 index 0000000..f6c2e04 --- /dev/null +++ b/influence/app/package.json @@ -0,0 +1,39 @@ +{ + "name": "alberta-influence-campaign", + "version": "1.0.0", + "description": "A locally-hosted political influence campaign tool for Alberta constituents", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "test": "jest" + }, + "keywords": [ + "politics", + "alberta", + "campaign", + "represent", + "email" + ], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.0.0", + "dotenv": "^16.3.1", + "express-validator": "^7.0.1", + "express-rate-limit": "^6.8.1", + "axios": "^1.5.0", + "nodemailer": "^6.9.4", + "express-session": "^1.17.3", + "bcryptjs": "^2.4.3" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "jest": "^29.6.2" + }, + "engines": { + "node": ">=16.0.0" + } +} \ No newline at end of file diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html new file mode 100644 index 0000000..4c8268d --- /dev/null +++ b/influence/app/public/admin.html @@ -0,0 +1,532 @@ + + + + + + Admin Panel - Alberta Influence Campaign Tool + + + + + +
+
+

Campaign Admin Panel

+

Manage your influence campaigns

+
+
+ +
+ + + + + + + +
+
+

Active Campaigns

+ +
+ + + +
+ +
+
+ + +
+

Create New Campaign

+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ + +
+

Edit Campaign

+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/influence/app/public/admin.html.broken b/influence/app/public/admin.html.broken new file mode 100644 index 0000000..33d9a63 --- /dev/null +++ b/influence/app/public/admin.html.broken @@ -0,0 +1,636 @@ + + + + + + Campaign Admin Panel - Alberta Influence Tool + + + + +
+

Campaign Admin Panel

+

Manage your influence campaigns and track engagement

+
+ +
+ + + + + + +
+
+

Active Campaigns

+ +
+ + + +
+ +
+
+ + +
+

Create New Campaign

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+

Campaign Analytics

+ + + +
+ +
+
+
+ + + + + + + + + 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 = '

No campaigns found. Create your first campaign

'; + return; + } + + list.innerHTML = this.campaigns.map(campaign => ` +
+
+
+

${campaign.title}

+

${campaign.description || 'No description'}

+
+ ${campaign.status} + + 📧 ${campaign.emailCount || 0} emails sent + +
+

+ Campaign URL: /campaign/${campaign.slug} +

+
+
+
+ + + + +
+
+ `).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 ` +
+
+
${analytics.totalEmails}
+
Total Emails
+
+
+
${analytics.smtpEmails}
+
SMTP Sent
+
+
+
${analytics.mailtoClicks}
+
Mailto Clicks
+
+
+
${analytics.successfulEmails}
+
Successful
+
+
+ +

By Government Level

+
+ ${Object.entries(analytics.byLevel).map(([level, count]) => ` +
+
${count}
+
${level}
+
+ `).join('')} +
+ +

Recent Activity

+
+ ${analytics.recentEmails.map(email => ` +
+ ${email.user_name || 'Anonymous'} → + ${email.recipient_name} (${email.recipient_level}) +
+ ${email.timestamp} • ${email.email_method} • ${email.status} +
+ `).join('')} +
+ `; + } + + showMessage(message, type) { + const container = document.getElementById('message-container'); + container.innerHTML = `
${message}
`; + 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(); + }); + + + \ No newline at end of file diff --git a/influence/app/public/campaign.html b/influence/app/public/campaign.html new file mode 100644 index 0000000..12ae42b --- /dev/null +++ b/influence/app/public/campaign.html @@ -0,0 +1,671 @@ + + + + + + Campaign - Alberta Influence Tool + + + + + +
+
+
+

Loading campaign...

+
+
+ + +
+
+

Loading Campaign...

+

+
+
+ +
+ + + + + + + +
+
Enter Your Info
+
Find Representatives
+
Send Emails
+
+ + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/influence/app/public/css/styles.css b/influence/app/public/css/styles.css new file mode 100644 index 0000000..7d696f7 --- /dev/null +++ b/influence/app/public/css/styles.css @@ -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; + } +} \ No newline at end of file diff --git a/influence/app/public/index.html b/influence/app/public/index.html new file mode 100644 index 0000000..e662e32 --- /dev/null +++ b/influence/app/public/index.html @@ -0,0 +1,130 @@ + + + + + + Alberta Influence Campaign Tool + + + + +
+
+

Alberta Influence Campaign Tool

+

Connect with your elected representatives across all levels of government

+
+ +
+ +
+

Find Your Representatives

+
+
+ +
+ + + +
+
+
+ + + +
+ + + + + + + + + +
+ +
+

© 2025 Alberta Influence Campaign Tool. Connect with democracy.

+

This tool uses the Represent API by Open North to find your representatives.

+
+
+ + + + + + + + \ No newline at end of file diff --git a/influence/app/public/js/admin.js b/influence/app/public/js/admin.js new file mode 100644 index 0000000..9825f49 --- /dev/null +++ b/influence/app/public/js/admin.js @@ -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} + + `; + 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 = ` +
+

No campaigns yet

+

Create your first campaign to get started.

+
+ `; + return; + } + + listDiv.innerHTML = this.campaigns.map(campaign => ` +
+
+

${this.escapeHtml(campaign.title)}

+ ${campaign.status} +
+ +
+

Slug: /campaign/${campaign.slug}

+

Email Count: ${campaign.emailCount || 0}

+

Created: ${this.formatDate(campaign.created_at)}

+
+ +
+ + + + View Public Page + + +
+
+ `).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 = ` + + `; + + 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(); +}); \ No newline at end of file diff --git a/influence/app/public/js/api-client.js b/influence/app/public/js/api-client.js new file mode 100644 index 0000000..40b067d --- /dev/null +++ b/influence/app/public/js/api-client.js @@ -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(); \ No newline at end of file diff --git a/influence/app/public/js/auth.js b/influence/app/public/js/auth.js new file mode 100644 index 0000000..ac31073 --- /dev/null +++ b/influence/app/public/js/auth.js @@ -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 = ` + Welcome, ${this.user.name || this.user.email} + + `; + + // 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; +} \ No newline at end of file diff --git a/influence/app/public/js/email-composer.js b/influence/app/public/js/email-composer.js new file mode 100644 index 0000000..9573d4b --- /dev/null +++ b/influence/app/public/js/email-composer.js @@ -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 = ` + ${recipient.name}
+ ${recipient.office}
+ ${recipient.district}
+ ${recipient.email} + `; + + // 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 = [ + / + + + \ No newline at end of file diff --git a/influence/app/routes/api.js b/influence/app/routes/api.js new file mode 100644 index 0000000..967f910 --- /dev/null +++ b/influence/app/routes/api.js @@ -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; \ No newline at end of file diff --git a/influence/app/routes/auth.js b/influence/app/routes/auth.js new file mode 100644 index 0000000..e0e320d --- /dev/null +++ b/influence/app/routes/auth.js @@ -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; \ No newline at end of file diff --git a/influence/app/server.js b/influence/app/server.js new file mode 100644 index 0000000..ff5c6a6 --- /dev/null +++ b/influence/app/server.js @@ -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; \ No newline at end of file diff --git a/influence/app/services/email.js b/influence/app/services/email.js new file mode 100644 index 0000000..020dd5a --- /dev/null +++ b/influence/app/services/email.js @@ -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(); \ No newline at end of file diff --git a/influence/app/services/nocodb.js b/influence/app/services/nocodb.js new file mode 100644 index 0000000..0793231 --- /dev/null +++ b/influence/app/services/nocodb.js @@ -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(); \ No newline at end of file diff --git a/influence/app/services/nocodb.js.backup b/influence/app/services/nocodb.js.backup new file mode 100644 index 0000000..31e01eb --- /dev/null +++ b/influence/app/services/nocodb.js.backup @@ -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(); \ No newline at end of file diff --git a/influence/app/services/represent-api.js b/influence/app/services/represent-api.js new file mode 100644 index 0000000..ba08970 --- /dev/null +++ b/influence/app/services/represent-api.js @@ -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(); \ No newline at end of file diff --git a/influence/app/utils/rate-limiter.js b/influence/app/utils/rate-limiter.js new file mode 100644 index 0000000..11209fb --- /dev/null +++ b/influence/app/utils/rate-limiter.js @@ -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 +}; \ No newline at end of file diff --git a/influence/app/utils/validators.js b/influence/app/utils/validators.js new file mode 100644 index 0000000..2d04749 --- /dev/null +++ b/influence/app/utils/validators.js @@ -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 = [ + / + + + + +``` + +### app/public/css/styles.css +```css +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + margin-bottom: 40px; + padding: 20px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +header h1 { + color: #005a9c; + margin-bottom: 10px; +} + +/* Forms */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 600; + color: #555; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 16px; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #005a9c; + box-shadow: 0 0 0 2px rgba(0, 90, 156, 0.1); +} + +#postal-form { + background: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + max-width: 500px; + margin: 0 auto; +} + +#postal-form .form-group { + display: flex; + gap: 10px; +} + +#postal-form input { + flex: 1; +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-primary { + background-color: #005a9c; + color: white; +} + +.btn-primary:hover { + background-color: #004a7c; +} + +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +/* Loading Spinner */ +.loading { + text-align: center; + padding: 40px; +} + +.spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #005a9c; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 0 auto 20px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Error Messages */ +.error-message { + background-color: #f8d7da; + color: #721c24; + padding: 15px; + border-radius: 4px; + margin: 20px 0; + border: 1px solid #f5c6cb; +} + +/* Representatives Section */ +#representatives-section { + margin-top: 40px; +} + +.rep-category { + margin-bottom: 40px; + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.rep-category h3 { + color: #005a9c; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 2px solid #e9ecef; +} + +.rep-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.rep-card { + border: 1px solid #ddd; + border-radius: 8px; + padding: 20px; + background: #f8f9fa; + transition: transform 0.2s, box-shadow 0.2s; +} + +.rep-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.rep-card h4 { + color: #005a9c; + margin-bottom: 10px; +} + +.rep-card .rep-info { + margin-bottom: 10px; + color: #666; +} + +.rep-card .rep-info strong { + color: #333; +} + +.rep-card .contact-info { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #ddd; +} + +.rep-card .no-email { + color: #6c757d; + font-style: italic; +} + +/* Modal */ +.modal { + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background-color: white; + padding: 30px; + border-radius: 8px; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +.close { + position: absolute; + right: 20px; + top: 20px; + font-size: 28px; + font-weight: bold; + cursor: pointer; + color: #aaa; +} + +.close:hover { + color: #000; +} + +.modal h2 { + color: #005a9c; + margin-bottom: 20px; +} + +.form-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 20px; +} + +.char-count { + display: block; + text-align: right; + color: #6c757d; + font-size: 14px; + margin-top: 5px; +} + +.status-message { + padding: 15px; + border-radius: 4px; + margin-top: 20px; +} + +.status-message.success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status-message.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +/* Utility Classes */ +.hidden { + display: none !important; +} + +.help-text { + color: #6c757d; + font-size: 14px; + margin-top: 5px; +} + +/* Mobile Responsiveness */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + #postal-form .form-group { + flex-direction: column; + } + + .rep-cards { + grid-template-columns: 1fr; + } + + .modal-content { + padding: 20px; + width: 95%; + } + + .form-actions { + flex-direction: column; + } + + .form-actions .btn { + width: 100%; + } +} \ No newline at end of file diff --git a/influence/instruct.md b/influence/instruct.md index a9d2178..23c931f 100644 --- a/influence/instruct.md +++ b/influence/instruct.md @@ -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. - **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 **First look through the existing codebase to understand where similar logic is implemented.** diff --git a/influence/scripts/build-nocodb.sh b/influence/scripts/build-nocodb.sh new file mode 100755 index 0000000..3b57f57 --- /dev/null +++ b/influence/scripts/build-nocodb.sh @@ -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 \ No newline at end of file