Add health check utility, logger, metrics, backup, and SMTP toggle scripts

- Implemented a comprehensive health check utility to monitor system dependencies including NocoDB, SMTP, Represent API, disk space, and memory usage.
- Created a logger utility using Winston for structured logging with daily rotation and various log levels.
- Developed a metrics utility using Prometheus client to track application performance metrics such as email sends, HTTP requests, and user activity.
- Added a backup script for automated backups of NocoDB data, uploaded files, and environment configurations with optional S3 support.
- Introduced a toggle script to switch between development (MailHog) and production (ProtonMail) SMTP configurations.
This commit is contained in:
2025-10-23 11:33:00 -06:00
parent c95beb31f5
commit f50c515eaa
34 changed files with 3872 additions and 1357 deletions

View File

@@ -2,6 +2,9 @@ FROM node:18-alpine
WORKDIR /usr/src/app
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy package files
COPY package*.json ./

View File

@@ -0,0 +1,104 @@
const csrf = require('csurf');
const logger = require('../utils/logger');
// Create CSRF protection middleware
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
sameSite: 'strict',
maxAge: 3600000 // 1 hour
}
});
/**
* Middleware to handle CSRF token errors
*/
const csrfErrorHandler = (err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
logger.warn('CSRF token validation failed', {
ip: req.ip,
path: req.path,
method: req.method,
userAgent: req.get('user-agent')
});
return res.status(403).json({
success: false,
error: 'Invalid CSRF token',
message: 'Your session has expired or the request is invalid. Please refresh the page and try again.'
});
}
next(err);
};
/**
* Middleware to inject CSRF token into response
* Adds csrfToken to all JSON responses and as a header
*/
const injectCsrfToken = (req, res, next) => {
// Add token to response locals for template rendering
res.locals.csrfToken = req.csrfToken();
// Override json method to automatically include CSRF token
const originalJson = res.json.bind(res);
res.json = function(data) {
if (data && typeof data === 'object' && !data.csrfToken) {
data.csrfToken = res.locals.csrfToken;
}
return originalJson(data);
};
next();
};
/**
* Skip CSRF protection for specific routes (e.g., webhooks, public APIs)
*/
const csrfExemptRoutes = [
'/api/health',
'/api/metrics',
'/api/config',
'/api/auth/login', // Login uses credentials for authentication
'/api/auth/session', // Session check is read-only
'/api/representatives/postal/', // Read-only operation
'/api/campaigns/public' // Public read operations
];
const conditionalCsrfProtection = (req, res, next) => {
// Skip CSRF for exempt routes
const isExempt = csrfExemptRoutes.some(route => req.path.startsWith(route));
// Skip CSRF for GET, HEAD, OPTIONS (safe methods)
const isSafeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method);
if (isExempt || isSafeMethod) {
return next();
}
// Apply CSRF protection for state-changing operations
csrfProtection(req, res, (err) => {
if (err) {
return csrfErrorHandler(err, req, res, next);
}
injectCsrfToken(req, res, next);
});
};
/**
* Helper to get CSRF token for client-side use
*/
const getCsrfToken = (req, res) => {
res.json({
csrfToken: req.csrfToken()
});
};
module.exports = {
csrfProtection,
csrfErrorHandler,
injectCsrfToken,
conditionalCsrfProtection,
getCsrfToken
};

View File

@@ -29,7 +29,16 @@
"express-session": "^1.17.3",
"bcryptjs": "^2.4.3",
"multer": "^1.4.5-lts.1",
"qrcode": "^1.5.3"
"qrcode": "^1.5.3",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1",
"compression": "^1.7.4",
"csurf": "^1.11.0",
"cookie-parser": "^1.4.6",
"bull": "^4.12.0",
"prom-client": "^15.1.0",
"sharp": "^0.33.0",
"ioredis": "^5.3.2"
},
"devDependencies": {
"nodemon": "^3.0.1",

View File

@@ -130,6 +130,75 @@
border-color: rgba(40, 167, 69, 1);
}
.share-more-container {
position: relative;
display: inline-block;
}
.share-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
padding: 0.5rem;
z-index: 1000;
min-width: 200px;
}
.share-dropdown.show {
display: block;
}
.share-dropdown-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.share-dropdown-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem;
border-radius: 6px;
background: rgba(52, 152, 219, 0.1);
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
color: #2c3e50;
}
.share-dropdown-item:hover {
background: rgba(52, 152, 219, 0.2);
transform: translateY(-2px);
}
.share-dropdown-item svg {
width: 24px;
height: 24px;
margin-bottom: 0.25rem;
fill: #2c3e50;
}
.share-dropdown-item span {
font-size: 0.7rem;
text-align: center;
line-height: 1.2;
}
.share-btn-small.more-btn {
position: relative;
}
.share-btn-small.more-btn.active {
background: rgba(255, 255, 255, 0.4);
}
.campaign-content {
max-width: 800px;
margin: 0 auto;
@@ -463,40 +532,110 @@
<!-- Social Share Buttons in Header -->
<div class="share-buttons-header">
<button class="share-btn-small" id="share-facebook" title="Share on Facebook">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
<button class="share-btn-small" id="share-linkedin" title="Share on LinkedIn">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
<button class="share-btn-small" id="share-whatsapp" title="Share on WhatsApp">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-email" title="Share via Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</button>
<button class="share-btn-small" id="share-copy" title="Copy Link">
<!-- Expandable Social Menu -->
<div class="share-socials-container">
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
</svg>
<span>Socials</span>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
<!-- Expandable Social Options -->
<div class="share-socials-menu" id="share-socials-menu">
<button class="share-btn-small" id="share-facebook" title="Facebook">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-instagram" title="Instagram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/>
</svg>
</button>
<button class="share-btn-small" id="share-reddit" title="Reddit">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
</svg>
</button>
<button class="share-btn-small" id="share-threads" title="Threads">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/>
</svg>
</button>
<button class="share-btn-small" id="share-telegram" title="Telegram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
</button>
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
</svg>
</button>
<button class="share-btn-small" id="share-sms" title="SMS">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/>
</svg>
</button>
<button class="share-btn-small" id="share-slack" title="Slack">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</button>
<button class="share-btn-small" id="share-discord" title="Discord">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</button>
<button class="share-btn-small" id="share-print" title="Print">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/>
</svg>
</button>
<button class="share-btn-small" id="share-email" title="Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</button>
</div>
</div>
<!-- Always-visible buttons -->
<button class="share-btn-primary" id="share-copy" title="Copy Link">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
<span>Copy Link</span>
</button>
<button class="share-btn-small" id="share-qrcode" title="Show QR Code">
<button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
</svg>
<span>QR Code</span>
</button>
</div>
</div>
@@ -544,15 +683,6 @@
</form>
</div>
<!-- Response Wall Button -->
<div id="response-wall-section" class="response-wall-container" style="display: none;">
<h3>💬 See What People Are Saying</h3>
<p>Check out responses to people who have taken action on this campaign</p>
<a href="#" id="response-wall-link" class="response-wall-button">
View Response Wall
</a>
</div>
<!-- Email Preview -->
<div id="email-preview" class="email-preview preview-mode" style="display: none;">
<h3>📧 Email Preview</h3>
@@ -599,6 +729,15 @@
</div>
</div>
<!-- Response Wall Button -->
<div id="response-wall-section" class="response-wall-container" style="display: none;">
<h3>💬 See What People Are Saying</h3>
<p>Check out responses to people who have taken action on this campaign</p>
<a href="#" id="response-wall-link" class="response-wall-button">
View Response Wall
</a>
</div>
<!-- Success Message -->
<div id="success-section" style="display: none; text-align: center; padding: 2rem;">
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>

View File

@@ -98,10 +98,185 @@
/* Social Share Buttons in Header */
.share-buttons-header {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
/* Primary Share Buttons */
.share-btn-primary {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.share-btn-primary:hover {
background: rgba(255, 255, 255, 0.35);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.share-btn-primary svg {
width: 20px;
height: 20px;
fill: white;
}
.share-btn-primary.copied {
background: rgba(40, 167, 69, 0.9);
border-color: rgba(40, 167, 69, 1);
}
/* Expandable Social Menu Container */
.share-socials-container {
position: relative;
display: inline-block;
}
.share-socials-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 0.75rem;
display: none;
flex-wrap: wrap;
gap: 0.5rem;
z-index: 1000;
min-width: 280px;
max-width: 320px;
opacity: 0;
transform: translateX(-50%) translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.share-socials-menu.show {
display: flex;
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Chevron icon rotation */
.share-btn-primary .chevron-icon {
width: 16px;
height: 16px;
fill: white;
transition: transform 0.3s ease;
}
.share-btn-primary.active .chevron-icon {
transform: rotate(180deg);
}
/* Share icon */
.share-btn-primary .share-icon {
width: 20px;
height: 20px;
fill: white;
}
/* Social buttons inside menu */
.share-socials-menu button {
border: none;
border-radius: 8px;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
color: white;
}
.share-socials-menu button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
filter: brightness(1.1);
}
.share-socials-menu button svg {
width: 22px;
height: 22px;
fill: white;
}
/* Platform-specific colors */
#share-facebook {
background: #1877f2;
}
#share-twitter {
background: #000000;
}
#share-linkedin {
background: #0077b5;
}
#share-whatsapp {
background: #25d366;
}
#share-bluesky {
background: #1185fe;
}
#share-instagram {
background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
}
#share-reddit {
background: #ff4500;
}
#share-threads {
background: #000000;
}
#share-telegram {
background: #0088cc;
}
#share-mastodon {
background: #6364ff;
}
#share-sms {
background: #34c759;
}
#share-slack {
background: #4a154b;
}
#share-discord {
background: #5865f2;
}
#share-print {
background: #6c757d;
}
#share-email {
background: #ea4335;
}
.share-btn-small {

View File

@@ -1686,6 +1686,17 @@ footer a:hover {
.campaigns-section-header {
text-align: center;
margin-bottom: 40px;
opacity: 0;
animation: fadeIn 0.8s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.campaigns-section-header h2 {
@@ -1717,8 +1728,37 @@ footer a:hover {
flex-direction: column;
height: 100%;
outline: none;
opacity: 0;
transform: translateY(30px);
animation: campaignFadeInUp 0.6s ease forwards;
}
@keyframes campaignFadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Staggered animation delays for campaign cards */
.campaign-card:nth-child(1) { animation-delay: 0.1s; }
.campaign-card:nth-child(2) { animation-delay: 0.2s; }
.campaign-card:nth-child(3) { animation-delay: 0.3s; }
.campaign-card:nth-child(4) { animation-delay: 0.4s; }
.campaign-card:nth-child(5) { animation-delay: 0.5s; }
.campaign-card:nth-child(6) { animation-delay: 0.6s; }
.campaign-card:nth-child(7) { animation-delay: 0.7s; }
.campaign-card:nth-child(8) { animation-delay: 0.8s; }
.campaign-card:nth-child(9) { animation-delay: 0.9s; }
.campaign-card:nth-child(10) { animation-delay: 1.0s; }
.campaign-card:nth-child(11) { animation-delay: 1.1s; }
.campaign-card:nth-child(12) { animation-delay: 1.2s; }
/* For campaigns beyond 12, they'll still animate but without additional delay */
.campaign-card:hover,
.campaign-card:focus {
transform: translateY(-5px);
@@ -1735,6 +1775,33 @@ footer a:hover {
height: 200px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.campaign-card-image::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: inherit;
transform: scale(1.1);
animation: imageZoomIn 0.8s ease forwards;
animation-delay: inherit;
z-index: 0;
}
@keyframes imageZoomIn {
from {
transform: scale(1.2);
}
to {
transform: scale(1);
}
}
.campaign-card-overlay {
@@ -1743,12 +1810,13 @@ footer a:hover {
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.7) 100%);
transition: background 0.3s ease;
z-index: 1;
}
.campaign-card:hover .campaign-card-overlay {
background: rgba(0, 0, 0, 0.4);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.8) 100%);
}
.campaign-card-content {
@@ -1759,11 +1827,22 @@ footer a:hover {
}
.campaign-card-title {
color: #005a9c;
font-size: 1.4em;
margin-bottom: 12px;
font-weight: 600;
color: #ffffff;
font-size: 1.5em;
margin: 0;
font-weight: 700;
line-height: 1.3;
text-align: center;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
position: relative;
z-index: 2;
width: 100%;
transition: transform 0.3s ease, text-shadow 0.3s ease;
}
.campaign-card:hover .campaign-card-title {
transform: translateY(-2px);
text-shadow: 0 3px 12px rgba(0, 0, 0, 0.6), 0 6px 20px rgba(0, 0, 0, 0.4);
}
.campaign-card-description {
@@ -1771,6 +1850,7 @@ footer a:hover {
font-size: 0.95em;
line-height: 1.6;
margin-bottom: 16px;
margin-top: 0;
flex: 1;
}
@@ -1960,6 +2040,8 @@ footer a:hover {
text-align: center;
padding: 60px 20px;
color: #666;
opacity: 0;
animation: fadeIn 0.5s ease forwards;
}
.campaigns-loading .spinner {
@@ -2142,3 +2224,187 @@ footer a:hover {
font-size: 1.25rem;
}
}
/* Expandable Social Share Menu Styles */
.share-buttons-header {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
/* Primary Share Buttons */
.share-btn-primary {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.share-btn-primary:hover {
background: rgba(255, 255, 255, 0.35);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.share-btn-primary svg {
width: 20px;
height: 20px;
fill: white;
}
.share-btn-primary.copied {
background: rgba(40, 167, 69, 0.9);
border-color: rgba(40, 167, 69, 1);
}
/* Expandable Social Menu Container */
.share-socials-container {
position: relative;
display: inline-block;
}
.share-socials-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 0.75rem;
display: none;
flex-wrap: wrap;
gap: 0.5rem;
z-index: 1000;
min-width: 280px;
max-width: 320px;
opacity: 0;
transform: translateX(-50%) translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.share-socials-menu.show {
display: flex;
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Chevron icon rotation */
.share-btn-primary .chevron-icon {
width: 16px;
height: 16px;
fill: white;
transition: transform 0.3s ease;
}
.share-btn-primary.active .chevron-icon {
transform: rotate(180deg);
}
/* Share icon */
.share-btn-primary .share-icon {
width: 20px;
height: 20px;
fill: white;
}
/* Social buttons inside menu */
.share-socials-menu button {
border: none;
border-radius: 8px;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
color: white;
}
.share-socials-menu button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
filter: brightness(1.1);
}
.share-socials-menu button svg {
width: 22px;
height: 22px;
fill: white;
}
/* Platform-specific colors */
#share-facebook {
background: #1877f2;
}
#share-twitter {
background: #000000;
}
#share-linkedin {
background: #0077b5;
}
#share-whatsapp {
background: #25d366;
}
#share-bluesky {
background: #1185fe;
}
#share-instagram {
background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
}
#share-reddit {
background: #ff4500;
}
#share-threads {
background: #000000;
}
#share-telegram {
background: #0088cc;
}
#share-mastodon {
background: #6364ff;
}
#share-sms {
background: #34c759;
}
#share-slack {
background: #4a154b;
}
#share-discord {
background: #5865f2;
}
#share-print {
background: #6c757d;
}
#share-email {
background: #ea4335;
}

View File

@@ -33,7 +33,27 @@ class CampaignPage {
setupShareButtons() {
// Get current URL
const shareUrl = window.location.href;
// Social menu toggle
const socialsToggle = document.getElementById('share-socials-toggle');
const socialsMenu = document.getElementById('share-socials-menu');
if (socialsToggle && socialsMenu) {
socialsToggle.addEventListener('click', (e) => {
e.stopPropagation();
socialsMenu.classList.toggle('show');
socialsToggle.classList.toggle('active');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.share-socials-container')) {
socialsMenu.classList.remove('show');
socialsToggle.classList.remove('active');
}
});
}
// Facebook share
document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
@@ -60,6 +80,93 @@ class CampaignPage {
window.open(url, '_blank');
});
// Bluesky share
document.getElementById('share-bluesky')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
document.getElementById('share-instagram')?.addEventListener('click', () => {
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Reddit share
document.getElementById('share-reddit')?.addEventListener('click', () => {
const title = this.campaign ? `${this.campaign.title}` : 'Check out this campaign';
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
window.open(url, '_blank', 'width=800,height=600');
});
// Threads share
document.getElementById('share-threads')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Telegram share
document.getElementById('share-telegram')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Mastodon share
document.getElementById('share-mastodon')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
// Mastodon requires instance selection - opens a composer with text
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
if (instance) {
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
}
});
// SMS share
document.getElementById('share-sms')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const body = text + ' ' + shareUrl;
// Use Web Share API if available, otherwise fallback to SMS protocol
if (navigator.share) {
navigator.share({
title: this.campaign ? this.campaign.title : 'Campaign',
text: body
}).catch(() => {
// Fallback to SMS protocol
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
});
} else {
// SMS protocol (works on mobile)
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
}
});
// Slack share
document.getElementById('share-slack')?.addEventListener('click', () => {
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Discord share
document.getElementById('share-discord')?.addEventListener('click', () => {
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Print/PDF share
document.getElementById('share-print')?.addEventListener('click', () => {
window.print();
});
// Email share
document.getElementById('share-email')?.addEventListener('click', () => {
const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign';

View File

@@ -72,11 +72,27 @@ class CampaignsGrid {
const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
this.container.innerHTML = campaignsHTML;
// Trigger animations by forcing a reflow
this.triggerAnimations();
// Add click event listeners to campaign cards (no inline handlers)
this.attachCardClickHandlers();
}
triggerAnimations() {
// Force reflow to restart CSS animations when campaigns are re-rendered
const cards = this.container.querySelectorAll('.campaign-card');
cards.forEach((card, index) => {
// Remove and re-add animation to restart it
card.style.animation = 'none';
// Force reflow
void card.offsetHeight;
// Re-apply animation with staggered delay
card.style.animation = `campaignFadeInUp 0.6s ease ${0.1 * (index + 1)}s forwards`;
});
}
attachCardClickHandlers() {
const campaignCards = this.container.querySelectorAll('.campaign-card');
campaignCards.forEach(card => {
@@ -157,9 +173,9 @@ class CampaignsGrid {
<div class="campaign-card" data-slug="${campaign.slug}">
<div class="campaign-card-image" style="${coverPhotoStyle}">
<div class="campaign-card-overlay"></div>
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
</div>
<div class="campaign-card-content">
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
<p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p>
${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''}
<div class="campaign-card-stats">

View File

@@ -342,7 +342,27 @@ function renderCampaignHeader() {
// Setup social share buttons
function setupShareButtons() {
const shareUrl = window.location.href;
// Social menu toggle
const socialsToggle = document.getElementById('share-socials-toggle');
const socialsMenu = document.getElementById('share-socials-menu');
if (socialsToggle && socialsMenu) {
socialsToggle.addEventListener('click', (e) => {
e.stopPropagation();
socialsMenu.classList.toggle('show');
socialsToggle.classList.toggle('active');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.share-socials-container')) {
socialsMenu.classList.remove('show');
socialsToggle.classList.remove('active');
}
});
}
// Facebook share
document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
@@ -369,6 +389,93 @@ function setupShareButtons() {
window.open(url, '_blank');
});
// Bluesky share
document.getElementById('share-bluesky')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
document.getElementById('share-instagram')?.addEventListener('click', () => {
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Reddit share
document.getElementById('share-reddit')?.addEventListener('click', () => {
const title = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
window.open(url, '_blank', 'width=800,height=600');
});
// Threads share
document.getElementById('share-threads')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Telegram share
document.getElementById('share-telegram')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Mastodon share
document.getElementById('share-mastodon')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
// Mastodon requires instance selection - opens a composer with text
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
if (instance) {
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
}
});
// SMS share
document.getElementById('share-sms')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const body = text + ' ' + shareUrl;
// Use Web Share API if available, otherwise fallback to SMS protocol
if (navigator.share) {
navigator.share({
title: currentCampaign ? currentCampaign.title : 'Response Wall',
text: body
}).catch(() => {
// Fallback to SMS protocol
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
});
} else {
// SMS protocol (works on mobile)
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
}
});
// Slack share
document.getElementById('share-slack')?.addEventListener('click', () => {
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Discord share
document.getElementById('share-discord')?.addEventListener('click', () => {
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Print/PDF share
document.getElementById('share-print')?.addEventListener('click', () => {
window.print();
});
// Email share
document.getElementById('share-email')?.addEventListener('click', () => {
const subject = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';

View File

@@ -26,40 +26,80 @@
<!-- Social Share Buttons in Header -->
<div class="share-buttons-header">
<button class="share-btn-small" id="share-facebook" title="Share on Facebook">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
<button class="share-btn-small" id="share-linkedin" title="Share on LinkedIn">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
<button class="share-btn-small" id="share-whatsapp" title="Share on WhatsApp">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-email" title="Share via Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</button>
<button class="share-btn-small" id="share-copy" title="Copy Link">
<!-- Expandable Social Menu -->
<div class="share-socials-container">
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
</svg>
<span>Socials</span>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
<!-- Expandable Social Options -->
<div class="share-socials-menu" id="share-socials-menu">
<button class="share-btn-small" id="share-facebook" title="Facebook">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</button>
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</button>
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</button>
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
</button>
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/></svg>
</button>
<button class="share-btn-small" id="share-instagram" title="Instagram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/></svg>
</button>
<button class="share-btn-small" id="share-reddit" title="Reddit">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
</button>
<button class="share-btn-small" id="share-threads" title="Threads">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/></svg>
</button>
<button class="share-btn-small" id="share-telegram" title="Telegram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
</button>
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
</button>
<button class="share-btn-small" id="share-sms" title="SMS">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/></svg>
</button>
<button class="share-btn-small" id="share-slack" title="Slack">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>
</button>
<button class="share-btn-small" id="share-discord" title="Discord">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</button>
<button class="share-btn-small" id="share-print" title="Print">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/></svg>
</button>
<button class="share-btn-small" id="share-email" title="Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
</button>
</div>
</div>
<!-- Always-visible buttons -->
<button class="share-btn-primary" id="share-copy" title="Copy Link">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
<span>Copy Link</span>
</button>
<button class="share-btn-small" id="share-qrcode" title="Show QR Code">
<button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
</svg>
<span>QR Code</span>
</button>
</div>
</div>

View File

@@ -2,9 +2,16 @@ const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const session = require('express-session');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const path = require('path');
require('dotenv').config();
const logger = require('./utils/logger');
const metrics = require('./utils/metrics');
const healthCheck = require('./utils/health-check');
const { conditionalCsrfProtection, getCsrfToken } = require('./middleware/csrf');
const apiRoutes = require('./routes/api');
const authRoutes = require('./routes/auth');
const { requireAdmin, requireAuth } = require('./middleware/auth');
@@ -16,6 +23,17 @@ const PORT = process.env.PORT || 3333;
// Only trust Docker internal networks for better security
app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']);
// Compression middleware for better performance
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
level: 6 // Balance between speed and compression ratio
}));
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
@@ -31,22 +49,89 @@ app.use(helmet({
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(cookieParser());
// Session configuration
// Metrics middleware - track all HTTP requests
app.use(metrics.middleware);
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.logRequest(req, res, duration);
});
next();
});
// Session configuration - PRODUCTION HARDENED
app.use(session({
secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
name: 'influence.sid', // Custom session name for security
cookie: {
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true', // HTTPS only in production
httpOnly: true, // Prevent JavaScript access
maxAge: 3600000, // 1 hour (reduced from 24 hours)
sameSite: 'strict' // CSRF protection
}
}));
app.use(express.static(path.join(__dirname, 'public')));
// CSRF Protection - Applied conditionally
app.use(conditionalCsrfProtection);
// Static files with proper caching
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0,
etag: true,
lastModified: true,
setHeaders: (res, filePath) => {
// Cache images and assets longer
if (filePath.match(/\.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$/)) {
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
}
// Cache CSS and JS for 1 day
else if (filePath.match(/\.(css|js)$/)) {
res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day
}
}
}));
// Health check endpoint - COMPREHENSIVE
app.get('/api/health', async (req, res) => {
try {
const health = await healthCheck.checkAll();
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
} catch (error) {
logger.error('Health check failed', { error: error.message });
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// Metrics endpoint for Prometheus
app.get('/api/metrics', async (req, res) => {
try {
res.set('Content-Type', metrics.getContentType());
const metricsData = await metrics.getMetrics();
res.end(metricsData);
} catch (error) {
logger.error('Metrics endpoint failed', { error: error.message });
res.status(500).json({ error: 'Failed to generate metrics' });
}
});
// CSRF token endpoint
app.get('/api/csrf-token', getCsrfToken);
// Routes
app.use('/api/auth', authRoutes);
@@ -100,21 +185,54 @@ app.get('/campaign/:slug', (req, res) => {
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
logger.error('Application error', {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip
});
res.status(err.status || 500).json({
error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});
// 404 handler
app.use((req, res) => {
logger.warn('Route not found', {
path: req.path,
method: req.method,
ip: req.ip
});
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}`);
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down gracefully');
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down gracefully');
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});
const server = app.listen(PORT, () => {
logger.info('Server started', {
port: PORT,
environment: process.env.NODE_ENV,
nodeVersion: process.version
});
});
module.exports = app;

View File

@@ -0,0 +1,368 @@
const Queue = require('bull');
const logger = require('./logger');
const metrics = require('./metrics');
const emailService = require('../services/email');
// Configure Redis connection for Bull
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0'),
// Retry strategy for connection failures
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
// Enable offline queue
enableOfflineQueue: true,
maxRetriesPerRequest: 3
};
// Create email queue
const emailQueue = new Queue('email-queue', {
redis: redisConfig,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000 // Start with 2 seconds, then 4, 8, etc.
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24 hours
count: 1000 // Keep last 1000 completed jobs
},
removeOnFail: {
age: 7 * 24 * 3600 // Keep failed jobs for 7 days
},
timeout: 30000 // 30 seconds timeout per job
}
});
// Process email jobs
emailQueue.process(async (job) => {
const { type, data } = job.data;
const start = Date.now();
logger.info('Processing email job', {
jobId: job.id,
type,
attempt: job.attemptsMade + 1,
maxAttempts: job.opts.attempts
});
try {
let result;
switch (type) {
case 'campaign':
result = await emailService.sendCampaignEmail(data);
break;
case 'verification':
result = await emailService.sendVerificationEmail(data);
break;
case 'login-details':
result = await emailService.sendLoginDetails(data);
break;
case 'broadcast':
result = await emailService.sendBroadcast(data);
break;
case 'response-verification':
result = await emailService.sendResponseVerificationEmail(data);
break;
default:
throw new Error(`Unknown email type: ${type}`);
}
const duration = (Date.now() - start) / 1000;
// Record metrics
metrics.recordEmailSent(
data.campaignId || 'system',
data.representativeLevel || 'unknown'
);
metrics.observeEmailSendDuration(
data.campaignId || 'system',
duration
);
logger.logEmailSent(
data.to || data.email,
data.campaignId || type,
'success'
);
return result;
} catch (error) {
const duration = (Date.now() - start) / 1000;
// Record failure metrics
metrics.recordEmailFailed(
data.campaignId || 'system',
error.code || 'unknown'
);
logger.logEmailFailed(
data.to || data.email,
data.campaignId || type,
error
);
// Throw error to trigger retry
throw error;
}
});
// Queue event handlers
emailQueue.on('completed', (job, result) => {
logger.info('Email job completed', {
jobId: job.id,
type: job.data.type,
duration: Date.now() - job.timestamp
});
});
emailQueue.on('failed', (job, err) => {
logger.error('Email job failed', {
jobId: job.id,
type: job.data.type,
attempt: job.attemptsMade,
maxAttempts: job.opts.attempts,
error: err.message,
willRetry: job.attemptsMade < job.opts.attempts
});
});
emailQueue.on('stalled', (job) => {
logger.warn('Email job stalled', {
jobId: job.id,
type: job.data.type
});
});
emailQueue.on('error', (error) => {
logger.error('Email queue error', { error: error.message });
});
// Update queue size metric every 10 seconds
setInterval(async () => {
try {
const counts = await emailQueue.getJobCounts();
const queueSize = counts.waiting + counts.active;
metrics.setEmailQueueSize(queueSize);
} catch (error) {
logger.warn('Failed to update queue metrics', { error: error.message });
}
}, 10000);
/**
* Email Queue Service
* Provides methods to enqueue different types of emails
*/
class EmailQueueService {
/**
* Send campaign email (to representative)
*/
async sendCampaignEmail(emailData) {
const job = await emailQueue.add(
{
type: 'campaign',
data: emailData
},
{
priority: 2, // Normal priority
jobId: `campaign-${emailData.campaignId}-${Date.now()}`
}
);
logger.info('Campaign email queued', {
jobId: job.id,
campaignId: emailData.campaignId,
recipient: emailData.to
});
return { jobId: job.id, queued: true };
}
/**
* Send email verification
*/
async sendVerificationEmail(emailData) {
const job = await emailQueue.add(
{
type: 'verification',
data: emailData
},
{
priority: 1, // High priority - user waiting
jobId: `verification-${emailData.email}-${Date.now()}`
}
);
logger.info('Verification email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Send login details
*/
async sendLoginDetails(emailData) {
const job = await emailQueue.add(
{
type: 'login-details',
data: emailData
},
{
priority: 1, // High priority
jobId: `login-${emailData.email}-${Date.now()}`
}
);
logger.info('Login details email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Send broadcast to users
*/
async sendBroadcast(emailData) {
const job = await emailQueue.add(
{
type: 'broadcast',
data: emailData
},
{
priority: 3, // Lower priority - batch operation
jobId: `broadcast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
);
logger.info('Broadcast email queued', {
jobId: job.id,
recipientCount: emailData.recipients?.length || 1
});
return { jobId: job.id, queued: true };
}
/**
* Send response verification email
*/
async sendResponseVerificationEmail(emailData) {
const job = await emailQueue.add(
{
type: 'response-verification',
data: emailData
},
{
priority: 2,
jobId: `response-verification-${emailData.email}-${Date.now()}`
}
);
logger.info('Response verification email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Get job status
*/
async getJobStatus(jobId) {
const job = await emailQueue.getJob(jobId);
if (!job) {
return { status: 'not_found' };
}
const state = await job.getState();
const progress = job.progress();
return {
jobId: job.id,
status: state,
progress,
attempts: job.attemptsMade,
data: job.data,
createdAt: job.timestamp,
processedAt: job.processedOn,
finishedAt: job.finishedOn,
failedReason: job.failedReason
};
}
/**
* Get queue statistics
*/
async getQueueStats() {
const counts = await emailQueue.getJobCounts();
const jobs = {
waiting: await emailQueue.getWaiting(0, 10),
active: await emailQueue.getActive(0, 10),
completed: await emailQueue.getCompleted(0, 10),
failed: await emailQueue.getFailed(0, 10)
};
return {
counts,
samples: {
waiting: jobs.waiting.map(j => ({ id: j.id, type: j.data.type })),
active: jobs.active.map(j => ({ id: j.id, type: j.data.type })),
completed: jobs.completed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type })),
failed: jobs.failed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type, reason: j.failedReason }))
}
};
}
/**
* Clean old jobs
*/
async cleanQueue(grace = 24 * 3600 * 1000) {
const cleaned = await emailQueue.clean(grace, 'completed');
logger.info('Queue cleaned', { removedJobs: cleaned.length });
return { cleaned: cleaned.length };
}
/**
* Pause queue
*/
async pauseQueue() {
await emailQueue.pause();
logger.warn('Email queue paused');
return { paused: true };
}
/**
* Resume queue
*/
async resumeQueue() {
await emailQueue.resume();
logger.info('Email queue resumed');
return { resumed: true };
}
/**
* Get queue instance (for advanced operations)
*/
getQueue() {
return emailQueue;
}
}
module.exports = new EmailQueueService();

View File

@@ -0,0 +1,350 @@
const logger = require('./logger');
/**
* Analytics tracking utilities for the Influence application
* Provides helpers for tracking campaign conversion, representative response rates,
* user retention, and geographic participation
*/
class Analytics {
constructor() {
this.cache = {
campaignStats: new Map(),
userRetention: new Map(),
geographicData: new Map()
};
}
/**
* Track campaign conversion rate
* @param {string} campaignId - Campaign identifier
* @param {number} visitors - Number of unique visitors
* @param {number} participants - Number of participants who took action
*/
trackCampaignConversion(campaignId, visitors, participants) {
const conversionRate = visitors > 0 ? (participants / visitors) * 100 : 0;
const stats = {
campaignId,
visitors,
participants,
conversionRate: conversionRate.toFixed(2),
timestamp: new Date().toISOString()
};
this.cache.campaignStats.set(campaignId, stats);
logger.info('Campaign conversion tracked', {
event: 'analytics_campaign_conversion',
...stats
});
return stats;
}
/**
* Calculate representative response rate
* @param {string} representativeLevel - Level (Federal, Provincial, Municipal)
* @param {number} emailsSent - Total emails sent
* @param {number} responsesReceived - Total responses received
*/
calculateRepresentativeResponseRate(representativeLevel, emailsSent, responsesReceived) {
const responseRate = emailsSent > 0 ? (responsesReceived / emailsSent) * 100 : 0;
const stats = {
representativeLevel,
emailsSent,
responsesReceived,
responseRate: responseRate.toFixed(2),
timestamp: new Date().toISOString()
};
logger.info('Representative response rate calculated', {
event: 'analytics_response_rate',
...stats
});
return stats;
}
/**
* Track user retention
* @param {string} userId - User identifier
* @param {string} action - Action type (login, campaign_participation, etc.)
*/
trackUserRetention(userId, action) {
let userData = this.cache.userRetention.get(userId) || {
userId,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
actionCount: 0,
actions: []
};
userData.lastSeen = new Date().toISOString();
userData.actionCount += 1;
userData.actions.push({
action,
timestamp: new Date().toISOString()
});
// Keep only last 100 actions per user
if (userData.actions.length > 100) {
userData.actions = userData.actions.slice(-100);
}
this.cache.userRetention.set(userId, userData);
// Determine if user is one-time or repeat
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
const isRepeatUser = daysBetween > 0 || userData.actionCount > 1;
logger.info('User retention tracked', {
event: 'analytics_user_retention',
userId,
action,
isRepeatUser,
totalActions: userData.actionCount
});
return { ...userData, isRepeatUser };
}
/**
* Track geographic participation by postal code
* @param {string} postalCode - Canadian postal code
* @param {string} campaignId - Campaign identifier
*/
trackGeographicParticipation(postalCode, campaignId) {
const postalPrefix = postalCode.substring(0, 3).toUpperCase();
let geoData = this.cache.geographicData.get(postalPrefix) || {
postalPrefix,
participationCount: 0,
campaigns: new Set()
};
geoData.participationCount += 1;
geoData.campaigns.add(campaignId);
this.cache.geographicData.set(postalPrefix, geoData);
logger.info('Geographic participation tracked', {
event: 'analytics_geographic_participation',
postalPrefix,
campaignId,
totalParticipation: geoData.participationCount
});
return {
postalPrefix,
participationCount: geoData.participationCount,
uniqueCampaigns: geoData.campaigns.size
};
}
/**
* Get geographic heatmap data
* @returns {Array} Array of postal code prefixes with participation counts
*/
getGeographicHeatmap() {
const heatmapData = [];
for (const [postalPrefix, data] of this.cache.geographicData.entries()) {
heatmapData.push({
postalPrefix,
participationCount: data.participationCount,
uniqueCampaigns: data.campaigns.size
});
}
return heatmapData.sort((a, b) => b.participationCount - a.participationCount);
}
/**
* Analyze peak usage times
* @param {Array} events - Array of event objects with timestamps
* @returns {Object} Peak usage analysis
*/
analyzePeakUsageTimes(events) {
const hourCounts = new Array(24).fill(0);
const dayOfWeekCounts = new Array(7).fill(0);
events.forEach(event => {
const date = new Date(event.timestamp);
const hour = date.getHours();
const dayOfWeek = date.getDay();
hourCounts[hour] += 1;
dayOfWeekCounts[dayOfWeek] += 1;
});
const peakHour = hourCounts.indexOf(Math.max(...hourCounts));
const peakDay = dayOfWeekCounts.indexOf(Math.max(...dayOfWeekCounts));
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const analysis = {
peakHour: `${peakHour}:00`,
peakDay: dayNames[peakDay],
hourlyDistribution: hourCounts,
dailyDistribution: dayOfWeekCounts,
totalEvents: events.length
};
logger.info('Peak usage times analyzed', {
event: 'analytics_peak_usage',
...analysis
});
return analysis;
}
/**
* Track device breakdown
* @param {string} userAgent - User agent string
*/
trackDeviceType(userAgent) {
const isMobile = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent);
const isTablet = /iPad|Android.*Tablet/i.test(userAgent);
const isDesktop = !isMobile && !isTablet;
const deviceType = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
logger.info('Device type tracked', {
event: 'analytics_device_type',
deviceType,
userAgent: userAgent.substring(0, 100)
});
return deviceType;
}
/**
* Track email deliverability
* @param {string} status - Status (success, bounce, spam, failed)
* @param {string} campaignId - Campaign identifier
* @param {Object} details - Additional details
*/
trackEmailDeliverability(status, campaignId, details = {}) {
const deliverabilityData = {
status,
campaignId,
timestamp: new Date().toISOString(),
...details
};
logger.info('Email deliverability tracked', {
event: 'analytics_email_deliverability',
...deliverabilityData
});
return deliverabilityData;
}
/**
* Generate campaign analytics report
* @param {string} campaignId - Campaign identifier
* @param {Object} data - Campaign data including visitors, participants, emails sent, etc.
*/
generateCampaignReport(campaignId, data) {
const {
visitors = 0,
participants = 0,
emailsSent = 0,
emailsFailed = 0,
responsesReceived = 0,
shareCount = 0,
avgTimeOnPage = 0
} = data;
const conversionRate = visitors > 0 ? ((participants / visitors) * 100).toFixed(2) : 0;
const emailSuccessRate = emailsSent > 0 ? (((emailsSent - emailsFailed) / emailsSent) * 100).toFixed(2) : 0;
const responseRate = emailsSent > 0 ? ((responsesReceived / emailsSent) * 100).toFixed(2) : 0;
const shareRate = participants > 0 ? ((shareCount / participants) * 100).toFixed(2) : 0;
const report = {
campaignId,
metrics: {
visitors,
participants,
conversionRate: `${conversionRate}%`,
emailsSent,
emailsFailed,
emailSuccessRate: `${emailSuccessRate}%`,
responsesReceived,
responseRate: `${responseRate}%`,
shareCount,
shareRate: `${shareRate}%`,
avgTimeOnPage: `${avgTimeOnPage}s`
},
insights: {
performance: conversionRate > 5 ? 'excellent' : conversionRate > 2 ? 'good' : 'needs improvement',
emailHealth: emailSuccessRate > 95 ? 'excellent' : emailSuccessRate > 85 ? 'good' : 'poor',
engagement: responseRate > 10 ? 'high' : responseRate > 3 ? 'medium' : 'low'
},
generatedAt: new Date().toISOString()
};
logger.info('Campaign report generated', {
event: 'analytics_campaign_report',
campaignId,
...report.metrics
});
return report;
}
/**
* Get user retention summary
* @returns {Object} User retention statistics
*/
getUserRetentionSummary() {
let oneTimeUsers = 0;
let repeatUsers = 0;
let totalActions = 0;
for (const [userId, userData] of this.cache.userRetention.entries()) {
totalActions += userData.actionCount;
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
if (daysBetween > 0 || userData.actionCount > 1) {
repeatUsers += 1;
} else {
oneTimeUsers += 1;
}
}
const totalUsers = oneTimeUsers + repeatUsers;
const retentionRate = totalUsers > 0 ? ((repeatUsers / totalUsers) * 100).toFixed(2) : 0;
return {
totalUsers,
oneTimeUsers,
repeatUsers,
retentionRate: `${retentionRate}%`,
avgActionsPerUser: totalUsers > 0 ? (totalActions / totalUsers).toFixed(2) : 0
};
}
/**
* Helper: Calculate days between two dates
*/
getDaysBetween(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Clear cache (for testing or memory management)
*/
clearCache() {
this.cache.campaignStats.clear();
this.cache.userRetention.clear();
this.cache.geographicData.clear();
logger.info('Analytics cache cleared');
}
}
module.exports = new Analytics();

View File

@@ -0,0 +1,380 @@
const axios = require('axios');
const os = require('os');
const fs = require('fs').promises;
const path = require('path');
const nodemailer = require('nodemailer');
const logger = require('./logger');
/**
* Health check utility for monitoring all system dependencies
*/
class HealthCheck {
constructor() {
this.services = {
nocodb: { name: 'NocoDB', healthy: false, lastCheck: null, details: {} },
smtp: { name: 'SMTP Server', healthy: false, lastCheck: null, details: {} },
representAPI: { name: 'Represent API', healthy: false, lastCheck: null, details: {} },
disk: { name: 'Disk Space', healthy: false, lastCheck: null, details: {} },
memory: { name: 'Memory', healthy: false, lastCheck: null, details: {} },
};
}
/**
* Check NocoDB connectivity and API health
*/
async checkNocoDB() {
const start = Date.now();
try {
const response = await axios.get(`${process.env.NOCODB_API_URL}/db/meta/projects`, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN
},
timeout: 5000
});
const duration = Date.now() - start;
this.services.nocodb = {
name: 'NocoDB',
healthy: response.status === 200,
lastCheck: new Date().toISOString(),
details: {
status: response.status,
responseTime: `${duration}ms`,
url: process.env.NOCODB_API_URL
}
};
logger.logHealthCheck('nocodb', 'healthy', { responseTime: duration });
return this.services.nocodb;
} catch (error) {
this.services.nocodb = {
name: 'NocoDB',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message,
url: process.env.NOCODB_API_URL
}
};
logger.logHealthCheck('nocodb', 'unhealthy', { error: error.message });
return this.services.nocodb;
}
}
/**
* Check SMTP server connectivity
*/
async checkSMTP() {
const start = Date.now();
// Skip SMTP check if in test mode
if (process.env.EMAIL_TEST_MODE === 'true') {
this.services.smtp = {
name: 'SMTP Server',
healthy: true,
lastCheck: new Date().toISOString(),
details: {
mode: 'test',
message: 'SMTP test mode enabled - emails logged only'
}
};
return this.services.smtp;
}
try {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
},
connectionTimeout: 5000
});
await transporter.verify();
const duration = Date.now() - start;
this.services.smtp = {
name: 'SMTP Server',
healthy: true,
lastCheck: new Date().toISOString(),
details: {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
responseTime: `${duration}ms`
}
};
logger.logHealthCheck('smtp', 'healthy', { responseTime: duration });
return this.services.smtp;
} catch (error) {
this.services.smtp = {
name: 'SMTP Server',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message,
host: process.env.SMTP_HOST
}
};
logger.logHealthCheck('smtp', 'unhealthy', { error: error.message });
return this.services.smtp;
}
}
/**
* Check Represent API availability
*/
async checkRepresentAPI() {
const start = Date.now();
const testUrl = `${process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca'}/representatives/house-of-commons/`;
try {
const response = await axios.get(testUrl, {
timeout: 5000
});
const duration = Date.now() - start;
this.services.representAPI = {
name: 'Represent API',
healthy: response.status === 200,
lastCheck: new Date().toISOString(),
details: {
status: response.status,
responseTime: `${duration}ms`,
url: testUrl
}
};
logger.logHealthCheck('represent-api', 'healthy', { responseTime: duration });
return this.services.representAPI;
} catch (error) {
this.services.representAPI = {
name: 'Represent API',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message,
url: testUrl
}
};
logger.logHealthCheck('represent-api', 'unhealthy', { error: error.message });
return this.services.representAPI;
}
}
/**
* Check disk space for uploads directory
*/
async checkDiskSpace() {
try {
const uploadsDir = path.join(__dirname, '../public/uploads');
// Ensure directory exists
try {
await fs.access(uploadsDir);
} catch {
await fs.mkdir(uploadsDir, { recursive: true });
}
// Get directory size
const directorySize = await this.getDirectorySize(uploadsDir);
// Get system disk usage (platform-specific approximation)
const freeSpace = os.freemem();
const totalSpace = os.totalmem();
const usedPercentage = ((totalSpace - freeSpace) / totalSpace) * 100;
// Consider healthy if less than 90% full
const healthy = usedPercentage < 90;
this.services.disk = {
name: 'Disk Space',
healthy,
lastCheck: new Date().toISOString(),
details: {
uploadsSize: this.formatBytes(directorySize),
systemUsedPercentage: `${usedPercentage.toFixed(2)}%`,
freeSpace: this.formatBytes(freeSpace),
totalSpace: this.formatBytes(totalSpace),
path: uploadsDir
}
};
logger.logHealthCheck('disk', healthy ? 'healthy' : 'unhealthy', this.services.disk.details);
return this.services.disk;
} catch (error) {
this.services.disk = {
name: 'Disk Space',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message
}
};
logger.logHealthCheck('disk', 'unhealthy', { error: error.message });
return this.services.disk;
}
}
/**
* Check memory usage
*/
async checkMemory() {
try {
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const usedMemory = totalMemory - freeMemory;
const usedPercentage = (usedMemory / totalMemory) * 100;
// Get Node.js process memory
const processMemory = process.memoryUsage();
// Consider healthy if less than 85% used
const healthy = usedPercentage < 85;
this.services.memory = {
name: 'Memory',
healthy,
lastCheck: new Date().toISOString(),
details: {
system: {
total: this.formatBytes(totalMemory),
used: this.formatBytes(usedMemory),
free: this.formatBytes(freeMemory),
usedPercentage: `${usedPercentage.toFixed(2)}%`
},
process: {
rss: this.formatBytes(processMemory.rss),
heapTotal: this.formatBytes(processMemory.heapTotal),
heapUsed: this.formatBytes(processMemory.heapUsed),
external: this.formatBytes(processMemory.external)
}
}
};
logger.logHealthCheck('memory', healthy ? 'healthy' : 'unhealthy', this.services.memory.details);
return this.services.memory;
} catch (error) {
this.services.memory = {
name: 'Memory',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message
}
};
logger.logHealthCheck('memory', 'unhealthy', { error: error.message });
return this.services.memory;
}
}
/**
* Run all health checks
*/
async checkAll() {
const results = await Promise.allSettled([
this.checkNocoDB(),
this.checkSMTP(),
this.checkRepresentAPI(),
this.checkDiskSpace(),
this.checkMemory()
]);
const overall = {
status: Object.values(this.services).every(s => s.healthy) ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
services: this.services,
uptime: process.uptime(),
version: process.env.npm_package_version || '1.0.0',
environment: process.env.NODE_ENV || 'development'
};
// Check if any critical services are down
// Note: In development, NocoDB might not be available yet
// Only mark as unhealthy if multiple critical services fail
const criticalServices = ['nocodb'];
const criticalDown = criticalServices.filter(name => !this.services[name].healthy);
// Allow degraded state if only external services (NocoDB, Represent API) are down
// Only fail health check if core services (disk, memory) are unhealthy
const coreServicesHealthy = this.services.disk.healthy && this.services.memory.healthy;
if (!coreServicesHealthy) {
overall.status = 'unhealthy';
} else if (criticalDown.length > 0) {
// Mark as degraded (not unhealthy) if external services are down
// This allows the container to start even if NocoDB isn't ready
overall.status = 'degraded';
}
return overall;
}
/**
* Get health check for a specific service
*/
async checkService(serviceName) {
const checkMethods = {
nocodb: () => this.checkNocoDB(),
smtp: () => this.checkSMTP(),
representAPI: () => this.checkRepresentAPI(),
disk: () => this.checkDiskSpace(),
memory: () => this.checkMemory()
};
if (checkMethods[serviceName]) {
return await checkMethods[serviceName]();
}
throw new Error(`Unknown service: ${serviceName}`);
}
/**
* Helper: Get directory size recursively
*/
async getDirectorySize(directory) {
let size = 0;
try {
const files = await fs.readdir(directory);
for (const file of files) {
const filePath = path.join(directory, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
size += await this.getDirectorySize(filePath);
} else {
size += stats.size;
}
}
} catch (error) {
logger.warn(`Error calculating directory size: ${error.message}`);
}
return size;
}
/**
* Helper: Format bytes to human readable
*/
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
module.exports = new HealthCheck();

View File

@@ -0,0 +1,190 @@
const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const path = require('path');
// Define log levels
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
// Define colors for each level
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'white',
};
// Tell winston about our colors
winston.addColors(colors);
// Define which level to log based on environment
const level = () => {
const env = process.env.NODE_ENV || 'development';
const isDevelopment = env === 'development';
return isDevelopment ? 'debug' : 'info';
};
// Define log format
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
);
// Console format for development (pretty print)
const consoleFormat = winston.format.combine(
winston.format.colorize({ all: true }),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}${info.stack ? '\n' + info.stack : ''}`
)
);
// Define transports
const transports = [
// Console output
new winston.transports.Console({
format: consoleFormat,
handleExceptions: true,
}),
// Error logs - rotate daily
new DailyRotateFile({
filename: path.join(__dirname, '../../logs/error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
format: logFormat,
maxSize: '20m',
maxFiles: '14d',
handleExceptions: true,
}),
// Combined logs - rotate daily
new DailyRotateFile({
filename: path.join(__dirname, '../../logs/combined-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
format: logFormat,
maxSize: '20m',
maxFiles: '14d',
}),
// HTTP logs - rotate daily
new DailyRotateFile({
filename: path.join(__dirname, '../../logs/http-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'http',
format: logFormat,
maxSize: '20m',
maxFiles: '7d',
}),
];
// Create the logger
const logger = winston.createLogger({
level: level(),
levels,
format: logFormat,
transports,
exitOnError: false,
});
// Create a stream object for Morgan HTTP logger
logger.stream = {
write: (message) => {
logger.http(message.trim());
},
};
// Helper methods for common logging patterns
logger.logRequest = (req, res, duration) => {
const logData = {
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${duration}ms`,
ip: req.ip,
userAgent: req.get('user-agent'),
};
if (res.statusCode >= 500) {
logger.error('Request failed', logData);
} else if (res.statusCode >= 400) {
logger.warn('Request error', logData);
} else {
logger.http('Request completed', logData);
}
};
logger.logEmailSent = (recipient, campaign, status) => {
logger.info('Email sent', {
event: 'email_sent',
recipient,
campaign,
status,
timestamp: new Date().toISOString(),
});
};
logger.logEmailFailed = (recipient, campaign, error) => {
logger.error('Email failed', {
event: 'email_failed',
recipient,
campaign,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
});
};
logger.logAuth = (action, username, success, ip) => {
const level = success ? 'info' : 'warn';
logger[level]('Auth action', {
event: 'auth',
action,
username,
success,
ip,
timestamp: new Date().toISOString(),
});
};
logger.logCampaignAction = (action, campaignId, userId, ip) => {
logger.info('Campaign action', {
event: 'campaign_action',
action,
campaignId,
userId,
ip,
timestamp: new Date().toISOString(),
});
};
logger.logRateLimitHit = (endpoint, ip, limit) => {
logger.warn('Rate limit hit', {
event: 'rate_limit',
endpoint,
ip,
limit,
timestamp: new Date().toISOString(),
});
};
logger.logHealthCheck = (service, status, details = {}) => {
const level = status === 'healthy' ? 'info' : 'error';
logger[level]('Health check', {
event: 'health_check',
service,
status,
...details,
timestamp: new Date().toISOString(),
});
};
module.exports = logger;

View File

@@ -0,0 +1,276 @@
const client = require('prom-client');
// Create a Registry to register the metrics
const register = new client.Registry();
// Add default metrics (CPU, memory, etc.)
client.collectDefaultMetrics({
register,
prefix: 'influence_app_'
});
// Custom metrics for the Influence application
// Email metrics
const emailsSentTotal = new client.Counter({
name: 'influence_emails_sent_total',
help: 'Total number of emails sent successfully',
labelNames: ['campaign_id', 'representative_level'],
registers: [register]
});
const emailsFailedTotal = new client.Counter({
name: 'influence_emails_failed_total',
help: 'Total number of emails that failed to send',
labelNames: ['campaign_id', 'error_type'],
registers: [register]
});
const emailQueueSize = new client.Gauge({
name: 'influence_email_queue_size',
help: 'Current number of emails in the queue',
registers: [register]
});
const emailSendDuration = new client.Histogram({
name: 'influence_email_send_duration_seconds',
help: 'Time taken to send an email',
labelNames: ['campaign_id'],
buckets: [0.1, 0.5, 1, 2, 5, 10],
registers: [register]
});
// API metrics
const httpRequestDuration = new client.Histogram({
name: 'influence_http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
registers: [register]
});
const httpRequestsTotal = new client.Counter({
name: 'influence_http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code'],
registers: [register]
});
// User metrics
const activeUsersGauge = new client.Gauge({
name: 'influence_active_users_total',
help: 'Number of currently active users',
registers: [register]
});
const userRegistrationsTotal = new client.Counter({
name: 'influence_user_registrations_total',
help: 'Total number of user registrations',
registers: [register]
});
const loginAttemptsTotal = new client.Counter({
name: 'influence_login_attempts_total',
help: 'Total number of login attempts',
labelNames: ['status'],
registers: [register]
});
// Campaign metrics
const campaignCreationsTotal = new client.Counter({
name: 'influence_campaign_creations_total',
help: 'Total number of campaigns created',
registers: [register]
});
const campaignParticipationTotal = new client.Counter({
name: 'influence_campaign_participation_total',
help: 'Total number of campaign participations',
labelNames: ['campaign_id'],
registers: [register]
});
const activeCampaignsGauge = new client.Gauge({
name: 'influence_active_campaigns_total',
help: 'Number of currently active campaigns',
registers: [register]
});
const campaignConversionRate = new client.Gauge({
name: 'influence_campaign_conversion_rate',
help: 'Campaign conversion rate (participants / visitors)',
labelNames: ['campaign_id'],
registers: [register]
});
// Representative metrics
const representativeLookupTotal = new client.Counter({
name: 'influence_representative_lookup_total',
help: 'Total number of representative lookups',
labelNames: ['lookup_type'],
registers: [register]
});
const representativeResponsesTotal = new client.Counter({
name: 'influence_representative_responses_total',
help: 'Total number of representative responses received',
labelNames: ['representative_level'],
registers: [register]
});
const representativeResponseRate = new client.Gauge({
name: 'influence_representative_response_rate',
help: 'Representative response rate (responses / emails sent)',
labelNames: ['representative_level'],
registers: [register]
});
// Rate limiting metrics
const rateLimitHitsTotal = new client.Counter({
name: 'influence_rate_limit_hits_total',
help: 'Total number of rate limit hits',
labelNames: ['endpoint', 'limit_type'],
registers: [register]
});
// External service metrics
const externalServiceRequestsTotal = new client.Counter({
name: 'influence_external_service_requests_total',
help: 'Total number of requests to external services',
labelNames: ['service', 'status'],
registers: [register]
});
const externalServiceLatency = new client.Histogram({
name: 'influence_external_service_latency_seconds',
help: 'Latency of external service requests',
labelNames: ['service'],
buckets: [0.1, 0.5, 1, 2, 5, 10],
registers: [register]
});
// Geographic metrics
const participationByPostalCode = new client.Counter({
name: 'influence_participation_by_postal_code_total',
help: 'Participation count by postal code prefix',
labelNames: ['postal_prefix'],
registers: [register]
});
// Middleware to track HTTP requests
const metricsMiddleware = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
const route = req.route ? req.route.path : req.path;
const labels = {
method: req.method,
route: route,
status_code: res.statusCode
};
httpRequestDuration.observe(labels, duration);
httpRequestsTotal.inc(labels);
});
next();
};
// Helper functions for common metric operations
const metrics = {
// Email metrics
recordEmailSent: (campaignId, representativeLevel) => {
emailsSentTotal.inc({ campaign_id: campaignId, representative_level: representativeLevel });
},
recordEmailFailed: (campaignId, errorType) => {
emailsFailedTotal.inc({ campaign_id: campaignId, error_type: errorType });
},
setEmailQueueSize: (size) => {
emailQueueSize.set(size);
},
observeEmailSendDuration: (campaignId, durationSeconds) => {
emailSendDuration.observe({ campaign_id: campaignId }, durationSeconds);
},
// User metrics
setActiveUsers: (count) => {
activeUsersGauge.set(count);
},
recordUserRegistration: () => {
userRegistrationsTotal.inc();
},
recordLoginAttempt: (success) => {
loginAttemptsTotal.inc({ status: success ? 'success' : 'failed' });
},
// Campaign metrics
recordCampaignCreation: () => {
campaignCreationsTotal.inc();
},
recordCampaignParticipation: (campaignId) => {
campaignParticipationTotal.inc({ campaign_id: campaignId });
},
setActiveCampaigns: (count) => {
activeCampaignsGauge.set(count);
},
setCampaignConversionRate: (campaignId, rate) => {
campaignConversionRate.set({ campaign_id: campaignId }, rate);
},
// Representative metrics
recordRepresentativeLookup: (lookupType) => {
representativeLookupTotal.inc({ lookup_type: lookupType });
},
recordRepresentativeResponse: (representativeLevel) => {
representativeResponsesTotal.inc({ representative_level: representativeLevel });
},
setRepresentativeResponseRate: (representativeLevel, rate) => {
representativeResponseRate.set({ representative_level: representativeLevel }, rate);
},
// Rate limiting metrics
recordRateLimitHit: (endpoint, limitType) => {
rateLimitHitsTotal.inc({ endpoint, limit_type: limitType });
},
// External service metrics
recordExternalServiceRequest: (service, success) => {
externalServiceRequestsTotal.inc({ service, status: success ? 'success' : 'failed' });
},
observeExternalServiceLatency: (service, durationSeconds) => {
externalServiceLatency.observe({ service }, durationSeconds);
},
// Geographic metrics
recordParticipationByPostalCode: (postalCode) => {
const prefix = postalCode.substring(0, 3).toUpperCase();
participationByPostalCode.inc({ postal_prefix: prefix });
},
// Get metrics endpoint handler
getMetrics: async () => {
return await register.metrics();
},
// Get content type for metrics
getContentType: () => {
return register.contentType;
},
// Middleware
middleware: metricsMiddleware
};
module.exports = metrics;

View File

@@ -16,24 +16,127 @@ function cleanupExpiredEntries() {
// Clean up expired entries every minute
setInterval(cleanupExpiredEntries, 60 * 1000);
// 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
// Custom key generator that's safer with trust proxy
const safeKeyGenerator = (req) => {
return req.ip || req.connection?.remoteAddress || 'unknown';
};
// Production-grade rate limiting configuration
const rateLimitConfig = {
// Email sending - very restrictive (5 emails per hour per IP)
emailSend: {
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: {
error: 'Email rate limit exceeded',
message: 'Too many emails sent from this IP. Maximum 5 emails per hour allowed.',
retryAfter: 3600
}
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
// Use a custom key generator that's safer with trust proxy
keyGenerator: (req) => {
// Fallback to connection remote address if req.ip is not available
return req.ip || req.connection?.remoteAddress || 'unknown';
// Representative lookup - moderate (30 lookups per minute)
representativeLookup: {
windowMs: 60 * 1000, // 1 minute
max: 30,
message: {
error: 'Representative lookup rate limit exceeded',
message: 'Too many representative lookups. Maximum 30 per minute allowed.',
retryAfter: 60
}
},
// Login attempts - strict (5 attempts per 15 minutes)
login: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: {
error: 'Login rate limit exceeded',
message: 'Too many login attempts. Please try again in 15 minutes.',
retryAfter: 900
}
},
// Public API - standard (100 requests per minute)
publicAPI: {
windowMs: 60 * 1000, // 1 minute
max: 100,
message: {
error: 'API rate limit exceeded',
message: 'Too many requests from this IP. Please try again later.',
retryAfter: 60
}
},
// Campaign creation/editing - moderate (10 per hour)
campaignMutation: {
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
message: {
error: 'Campaign mutation rate limit exceeded',
message: 'Too many campaign operations. Maximum 10 per hour allowed.',
retryAfter: 3600
}
},
// General fallback - legacy compatibility
general: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: {
error: 'Too many requests from this IP, please try again later.',
retryAfter: 900
}
}
};
// Create rate limiter instances
const emailSend = rateLimit({
...rateLimitConfig.emailSend,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false,
keyGenerator: safeKeyGenerator
});
// Email sending rate limiter (general - keeps existing behavior)
const representativeLookup = rateLimit({
...rateLimitConfig.representativeLookup,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: safeKeyGenerator
});
const login = rateLimit({
...rateLimitConfig.login,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Count all attempts
keyGenerator: safeKeyGenerator
});
const publicAPI = rateLimit({
...rateLimitConfig.publicAPI,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: safeKeyGenerator
});
const campaignMutation = rateLimit({
...rateLimitConfig.campaignMutation,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false,
keyGenerator: safeKeyGenerator
});
// General API rate limiter (legacy - kept for backward compatibility)
const general = rateLimit({
...rateLimitConfig.general,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: safeKeyGenerator
});
// Email sending rate limiter (legacy - kept for backward compatibility)
const email = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // limit each IP to 10 emails per hour
@@ -43,12 +146,8 @@ const email = rateLimit({
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Don't skip counting successful requests
// Use a custom key generator that's safer with trust proxy
keyGenerator: (req) => {
// Fallback to connection remote address if req.ip is not available
return req.ip || req.connection?.remoteAddress || 'unknown';
},
skipSuccessfulRequests: false,
keyGenerator: safeKeyGenerator
});
// Custom middleware for per-recipient email rate limiting
@@ -90,16 +189,23 @@ const representAPI = rateLimit({
},
standardHeaders: true,
legacyHeaders: false,
// Use a custom key generator that's safer with trust proxy
keyGenerator: (req) => {
// Fallback to connection remote address if req.ip is not available
return req.ip || req.connection?.remoteAddress || 'unknown';
},
keyGenerator: safeKeyGenerator
});
module.exports = {
// Legacy exports (backward compatibility)
general,
email,
representAPI,
// New granular rate limiters
emailSend,
representativeLookup,
login,
publicAPI,
campaignMutation,
perRecipientEmailLimiter,
representAPI
// Export config for testing/monitoring
rateLimitConfig
};

View File

@@ -1,7 +1,31 @@
// Validate Canadian postal code format
// Full Canadian postal code validation with proper FSA/LDU rules
function validatePostalCode(postalCode) {
const regex = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
return regex.test(postalCode);
// Remove whitespace and convert to uppercase
const cleaned = postalCode.replace(/\s/g, '').toUpperCase();
// Must be exactly 6 characters
if (cleaned.length !== 6) return false;
// Pattern: A1A 1A1 where A is letter and 1 is digit
const pattern = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
if (!pattern.test(cleaned)) return false;
// First character cannot be D, F, I, O, Q, U, W, or Z
const invalidFirstChars = ['D', 'F', 'I', 'O', 'Q', 'U', 'W', 'Z'];
if (invalidFirstChars.includes(cleaned[0])) return false;
// Second position (LDU) cannot be 0
if (cleaned[1] === '0') return false;
// Third character cannot be D, F, I, O, Q, or U
const invalidThirdChars = ['D', 'F', 'I', 'O', 'Q', 'U'];
if (invalidThirdChars.includes(cleaned[2])) return false;
// Fifth character cannot be D, F, I, O, Q, or U
if (invalidThirdChars.includes(cleaned[4])) return false;
return true;
}
// Validate Alberta postal code (starts with T)
@@ -10,10 +34,29 @@ function validateAlbertaPostalCode(postalCode) {
return formatted.startsWith('T') && validatePostalCode(postalCode);
}
// Validate email format
// Validate email format with stricter rules
function validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
// RFC 5322 simplified email validation
const regex = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!regex.test(email)) return false;
// Additional checks
const parts = email.split('@');
if (parts.length !== 2) return false;
const [localPart, domain] = parts;
// Local part max 64 characters
if (localPart.length > 64) return false;
// Domain must have at least one dot and valid TLD
if (!domain.includes('.')) return false;
// Check for consecutive dots
if (email.includes('..')) return false;
return true;
}
// Format postal code to standard format (A1A 1A1)
@@ -25,16 +68,57 @@ function formatPostalCode(postalCode) {
return cleaned;
}
// Sanitize string input to prevent XSS
// Sanitize string input to prevent XSS and injection attacks
function sanitizeString(str) {
if (typeof str !== 'string') return str;
return str
.replace(/[<>]/g, '') // Remove angle brackets
.replace(/javascript:/gi, '') // Remove javascript: protocol
.replace(/on\w+\s*=/gi, '') // Remove event handlers
.replace(/eval\s*\(/gi, '') // Remove eval calls
.trim()
.substring(0, 1000); // Limit length
}
// Sanitize HTML content for email templates
function sanitizeHtmlContent(html) {
if (typeof html !== 'string') return html;
// Remove dangerous tags and attributes
let sanitized = html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
.replace(/<embed[^>]*>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '');
return sanitized;
}
// Validate SQL/NoSQL injection attempts in where clauses
function validateWhereClause(whereClause) {
if (typeof whereClause !== 'string') return false;
// Check for SQL injection patterns
const suspiciousPatterns = [
/;\s*drop\s+/i,
/;\s*delete\s+/i,
/;\s*update\s+/i,
/;\s*insert\s+/i,
/union\s+select/i,
/exec\s*\(/i,
/execute\s*\(/i,
/--/,
/\/\*/,
/xp_/i,
/sp_/i
];
return !suspiciousPatterns.some(pattern => pattern.test(whereClause));
}
// Validate required fields in request body
function validateRequiredFields(body, requiredFields) {
const errors = [];
@@ -130,6 +214,8 @@ module.exports = {
validateEmail,
formatPostalCode,
sanitizeString,
sanitizeHtmlContent,
validateWhereClause,
validateRequiredFields,
containsSuspiciousContent,
generateSlug,