Influence, new frontpage, bunch of other small things

This commit is contained in:
admin 2026-01-14 18:41:24 -07:00
parent a04b7d5b43
commit e641360738
46 changed files with 10169 additions and 461 deletions

View File

@ -1,6 +1,16 @@
# Changemaker Lite
# Free Alberta
Changemaker Lite is a streamlined documentation and development platform featuring essential self-hosted services for creating, managing, and automating political campaign workflows.
Free Alberta is a comprehensive political campaign and advocacy platform built on [Changemaker Lite](https://github.com/changemaker-tools/changemaker.lite). This platform provides self-hosted services for creating, managing, and automating political campaign workflows specific to Alberta's political landscape.
## About This Project
This project leverages the Changemaker Lite framework to deliver:
- Campaign management and automation tools
- Interactive mapping for geographic organizing
- Constituent engagement and advocacy tools (Influence)
- Documentation and development infrastructure
Changemaker Lite provides the core infrastructure (documentation, code server, email campaigns, workflow automation), while Free Alberta extends it with Alberta-specific tools for political organizing and advocacy.
## Features
@ -21,8 +31,8 @@ The whole system can be set up in minutes using Docker Compose. It is recommende
```bash
# Clone the repository
git clone https://gitea.bnkops.com/admin/changemaker.lite
cd changemaker.lite
git clone https://git.freealberta.org/admin/freealberta.git
cd freealberta
# Configure environment (creates .env file)
./config.sh

View File

@ -12,7 +12,7 @@ ingress:
service: http://localhost:9001
- hostname: docs.freealberta.org
service: http://localhost:4002
- hostname: freealberta.org
- hostname: policy.freealberta.org
service: http://localhost:4003
- hostname: n8n.freealberta.org
service: http://localhost:5679
@ -26,4 +26,8 @@ ingress:
service: http://localhost:3333
- hostname: qr.freealberta.org
service: http://localhost:8091
- hostname: food.freealberta.org
service: http://localhost:3003
- hostname: freealberta.org
service: http://localhost:3020
- service: http_status:404

174
free-alberta-prompt.md Normal file
View File

@ -0,0 +1,174 @@
# Free Alberta Food Resources - Project Documentation
## Original Requirements
Build a webapp for freealberta.org to display free food resources in Alberta. Pull data from various sources, store in PostgreSQL, and serve via Cloudflare tunnel at food.freealberta.org.
### Data Sources
- InformAlberta (5 zones - North, Edmonton, Calgary, Central, South)
- Edmonton's Food Bank PDF (community meals)
- 211 Alberta (Cloudflare protected - requires manual data or API access)
## Implementation Status: COMPLETE
### Technology Stack
- **Backend**: Express.js (Node.js)
- **Frontend**: HTML, CSS, vanilla JavaScript
- **Database**: PostgreSQL 17 (dedicated container)
- **Maps**: Leaflet.js with OpenStreetMap (100% FOSS)
- **Routing**: OSRM (Open Source Routing Machine)
- **Geocoding**: Nominatim + Photon (OpenStreetMap-based)
- **Containerization**: Docker
### Project Structure
```
freealberta-food/
├── app/
│ ├── public/
│ │ ├── css/styles.css
│ │ ├── js/app.js
│ │ └── index.html
│ ├── routes/api.js
│ ├── controllers/
│ │ ├── resourceController.js
│ │ ├── scraperController.js
│ │ ├── geocodingController.js
│ │ └── routingController.js
│ ├── models/
│ │ ├── db.js
│ │ └── init-db.js
│ ├── services/
│ │ ├── geocoding.js
│ │ └── routing.js
│ ├── scrapers/
│ │ ├── informalberta.js
│ │ ├── ab211.js
│ │ ├── pdf-parser.js
│ │ └── run-all.js
│ ├── utils/logger.js
│ ├── server.js
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml
├── .env
└── .env.example
```
### Features Implemented
- Full-featured frontend with map view and list view
- Search functionality (by name, address, services)
- Filters by city and resource type
- Geolocation support (find nearby resources)
- **Turn-by-turn directions** from user location to any resource
- **Multiple travel modes**: Driving, Walking, Cycling
- **Printable directions** with step-by-step instructions
- **Automatic geocoding** of addresses during data scrape
- Resource detail modal
- Pagination
- Mobile responsive design
### Mapping Features (100% FOSS)
- **Base Map**: OpenStreetMap tiles via Leaflet.js
- **Geocoding**: Multi-provider with Nominatim and Photon fallback
- **Routing**: OSRM (Open Source Routing Machine) for directions
- **Directions**: Turn-by-turn navigation with distance/duration
- **Print**: Printable directions with full step list
### Resource Types
- Food Bank
- Community Meal
- Food Hamper
- Food Pantry
- Soup Kitchen
- Mobile Food
- Grocery Program
### API Endpoints
**Resources**
- `GET /api/resources` - List resources with filters
- `GET /api/resources/search` - Text search
- `GET /api/resources/nearby` - Location-based search
- `GET /api/resources/:id` - Single resource details
- `GET /api/cities` - Available cities
- `GET /api/types` - Resource types
- `GET /api/stats` - Statistics
**Geocoding**
- `GET /api/geocode?address=...` - Forward geocode address to coordinates
- `GET /api/geocode/reverse?lat=...&lng=...` - Reverse geocode coordinates
**Routing/Directions**
- `GET /api/directions?startLat=...&startLng=...&endLat=...&endLng=...&profile=driving` - Get turn-by-turn directions
**Admin**
- `POST /api/scrape` - Trigger manual scrape
- `GET /api/scrape/status` - Scrape status
- `GET /api/scrape/logs` - Scrape history
### Data Refresh
- Weekly automated scrape (Sundays at 2 AM)
- Manual trigger via `/api/scrape`
- Automatic geocoding of new addresses during scrape
## Deployment Instructions
### 1. Start the Application
```bash
cd freealberta-food
docker compose up -d
```
### 2. Initialize the Database
```bash
docker exec -it freealberta-food-app npm run db:init
```
### 3. Run Initial Data Scrape
```bash
docker exec -it freealberta-food-app npm run scrape
```
### 4. Configure Cloudflare Tunnel
Add to your Cloudflare tunnel configuration:
```yaml
- hostname: food.freealberta.org
service: http://freealberta-food-app:3003
```
## Port Configuration
- Application: 3003 (configured in main .env as FOOD_PORT)
- Database: Internal only (within Docker network)
## Known Limitations
1. **211 Alberta**: Cloudflare protection blocks automated scraping. Data must be entered manually or via API access request.
2. **PDF Parsing**: The Edmonton Food Bank PDF structure may vary; manual review recommended.
3. **Geocoding Rate Limits**: Nominatim has a 1 request/second limit. Scraping with geocoding takes longer.
4. **OSRM Public Server**: Using the public demo server which has rate limits. For production, consider self-hosting OSRM.
## Future Enhancements
- Request API access from 211 Alberta
- Self-host OSRM for faster routing
- Add admin panel for manual data management
- Connect to NocoDB for form-based data entry
- Add analytics tracking
- Cache route calculations
## Environment Variables
See `.env.example` for all configuration options.

View File

@ -0,0 +1,36 @@
# Free Alberta Food - Environment Configuration
# Copy this file to .env and update the values
# Application
NODE_ENV=production
PORT=3003
FOOD_PORT=3003
# Database Configuration
# Option 1: Use the dedicated food database
DB_HOST=food-db
DB_PORT=5432
DB_NAME=freealberta_food
DB_USER=foodadmin
DB_PASSWORD=changeme_secure_password
# Option 2: Use the existing listmonk database (uncomment and configure)
# DB_HOST=listmonk-db
# DB_PORT=5432
# DB_NAME=freealberta_food
# DB_USER=listmonk
# DB_PASSWORD=your_postgres_password
# Food Database credentials (for docker-compose)
FOOD_DB_USER=foodadmin
FOOD_DB_PASSWORD=changeme_secure_password
# Enable weekly cron job for data scraping
ENABLE_CRON=true
# Admin Panel
# Set a secure password for the admin panel
ADMIN_PASSWORD=changeme_admin_password
# Logging
LOG_LEVEL=info

28
freealberta-food/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Logs
logs/
*.log
# Environment files (keep .env.example)
.env
!.env.example
# OS files
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Build artifacts
dist/
build/
# Temporary files
tmp/
temp/

View File

@ -0,0 +1,28 @@
FROM node:20-alpine
WORKDIR /usr/src/app
# Install dependencies for pdf-parse and other native modules
RUN apk add --no-cache python3 make g++
# Copy package files
COPY package*.json ./
# Install production dependencies
RUN npm install --omit=dev
# Copy application code
COPY . .
# Create logs directory
RUN mkdir -p logs
# Expose port
EXPOSE 3003
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/api/health || exit 1
# Start the application
CMD ["node", "server.js"]

View File

@ -0,0 +1,160 @@
const geocoding = require('../services/geocoding');
const db = require('../models/db');
const logger = require('../utils/logger');
async function geocodeAddress(req, res) {
try {
const { address } = req.query;
if (!address) {
return res.status(400).json({ error: 'Address parameter required' });
}
const result = await geocoding.forwardGeocode(address);
res.json({
success: true,
result: {
latitude: result.latitude,
longitude: result.longitude,
formattedAddress: result.formattedAddress,
provider: result.provider,
confidence: result.confidence
}
});
} catch (error) {
logger.error('Geocoding failed', { error: error.message });
res.status(500).json({
success: false,
error: error.message
});
}
}
async function reverseGeocode(req, res) {
try {
const { lat, lng } = req.query;
if (!lat || !lng) {
return res.status(400).json({ error: 'lat and lng parameters required' });
}
const latitude = parseFloat(lat);
const longitude = parseFloat(lng);
if (isNaN(latitude) || isNaN(longitude)) {
return res.status(400).json({ error: 'Invalid coordinates' });
}
const result = await geocoding.reverseGeocode(latitude, longitude);
res.json({
success: true,
result: {
formattedAddress: result.formattedAddress,
components: result.components,
latitude: result.latitude,
longitude: result.longitude
}
});
} catch (error) {
logger.error('Reverse geocoding failed', { error: error.message });
res.status(500).json({
success: false,
error: error.message
});
}
}
async function regeocodeResource(req, res) {
try {
const { id } = req.params;
// Get the resource
const resourceResult = await db.query(
'SELECT id, name, address, city, postal_code FROM food_resources WHERE id = $1',
[id]
);
if (resourceResult.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Resource not found' });
}
const resource = resourceResult.rows[0];
// Build address string
let addressToGeocode;
let hasStreetAddress = false;
if (resource.address && !resource.address.startsWith('PO Box') && resource.address.trim() !== '') {
addressToGeocode = `${resource.address}, ${resource.city}, Alberta, Canada`;
hasStreetAddress = true;
} else if (resource.postal_code && resource.postal_code.trim() !== '') {
addressToGeocode = `${resource.city}, ${resource.postal_code}, Alberta, Canada`;
} else if (resource.city) {
addressToGeocode = `${resource.city}, Alberta, Canada`;
} else {
return res.status(400).json({ success: false, error: 'Resource has no address or city to geocode' });
}
logger.info(`Re-geocoding resource ${id}: ${resource.name}`, { address: addressToGeocode });
const geocodeResult = await geocoding.forwardGeocode(addressToGeocode);
if (!geocodeResult || !geocodeResult.latitude || !geocodeResult.longitude) {
return res.status(400).json({ success: false, error: 'Could not geocode address' });
}
const confidence = geocodeResult.combinedConfidence || geocodeResult.confidence || 50;
const provider = geocodeResult.provider || 'unknown';
const warnings = geocodeResult.validation?.warnings || [];
// Adjust confidence if no street address was provided
const adjustedConfidence = hasStreetAddress ? confidence : Math.min(confidence, 40);
// Update the database
await db.query(`
UPDATE food_resources
SET latitude = $1,
longitude = $2,
geocode_confidence = $3,
geocode_provider = $4,
updated_at = NOW()
WHERE id = $5
`, [geocodeResult.latitude, geocodeResult.longitude, adjustedConfidence, provider, id]);
logger.info(`Re-geocoded resource ${id} successfully`, {
latitude: geocodeResult.latitude,
longitude: geocodeResult.longitude,
confidence: adjustedConfidence,
provider
});
res.json({
success: true,
result: {
latitude: geocodeResult.latitude,
longitude: geocodeResult.longitude,
confidence: adjustedConfidence,
provider,
warnings,
formattedAddress: geocodeResult.formattedAddress
}
});
} catch (error) {
logger.error('Re-geocoding failed', { error: error.message, id: req.params.id });
res.status(500).json({
success: false,
error: error.message
});
}
}
module.exports = {
geocodeAddress,
reverseGeocode,
regeocodeResource
};

View File

@ -0,0 +1,385 @@
const db = require('../models/db');
const logger = require('../utils/logger');
// Get all resources with optional filters
async function getResources(req, res) {
try {
const {
cities, // Multi-select: comma-separated city names
types, // Multi-select: comma-separated resource types
contact, // Multi-select: comma-separated contact methods (phone, email, website)
page = 1,
limit = 50,
sort = 'name',
order = 'asc'
} = req.query;
const offset = (page - 1) * limit;
const params = [];
let whereClause = 'WHERE is_active = true';
// Multi-select city filter
if (cities) {
const cityList = cities.split(',').map(c => c.trim()).filter(c => c);
if (cityList.length > 0) {
const placeholders = cityList.map((_, i) => `LOWER($${params.length + i + 1})`).join(', ');
params.push(...cityList);
whereClause += ` AND LOWER(city) IN (${placeholders})`;
}
}
// Multi-select type filter
if (types) {
const typeList = types.split(',').map(t => t.trim()).filter(t => t);
if (typeList.length > 0) {
const placeholders = typeList.map((_, i) => `$${params.length + i + 1}`).join(', ');
params.push(...typeList);
whereClause += ` AND resource_type IN (${placeholders})`;
}
}
// Multi-select contact filter
if (contact) {
const contactList = contact.split(',').map(c => c.trim()).filter(c => c);
const contactConditions = [];
if (contactList.includes('phone')) contactConditions.push('phone IS NOT NULL AND phone != \'\'');
if (contactList.includes('email')) contactConditions.push('email IS NOT NULL AND email != \'\'');
if (contactList.includes('website')) contactConditions.push('website IS NOT NULL AND website != \'\'');
if (contactConditions.length > 0) {
whereClause += ` AND (${contactConditions.join(' OR ')})`;
}
}
// Validate sort column
const validSorts = ['name', 'city', 'resource_type', 'updated_at'];
const sortColumn = validSorts.includes(sort) ? sort : 'name';
const sortOrder = order.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
// Get total count
const countResult = await db.query(
`SELECT COUNT(*) FROM food_resources ${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count);
// Get resources
params.push(limit, offset);
const result = await db.query(`
SELECT
id, name, description, resource_type,
address, city, province, postal_code,
latitude, longitude, phone, email, website,
hours_of_operation, eligibility, services_offered,
source, source_url, updated_at, last_verified_at
FROM food_resources
${whereClause}
ORDER BY ${sortColumn} ${sortOrder}
LIMIT $${params.length - 1} OFFSET $${params.length}
`, params);
res.json({
resources: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
logger.error('Failed to get resources', { error: error.message });
res.status(500).json({ error: 'Failed to fetch resources' });
}
}
// Search resources by text
async function searchResources(req, res) {
try {
const { q, cities, types, contact, limit = 50 } = req.query;
if (!q || q.length < 2) {
return res.status(400).json({ error: 'Search query must be at least 2 characters' });
}
const params = [`%${q}%`];
let whereClause = `
WHERE is_active = true
AND (
LOWER(name) LIKE LOWER($1)
OR LOWER(description) LIKE LOWER($1)
OR LOWER(address) LIKE LOWER($1)
OR LOWER(services_offered) LIKE LOWER($1)
)
`;
// Multi-select city filter
if (cities) {
const cityList = cities.split(',').map(c => c.trim()).filter(c => c);
if (cityList.length > 0) {
const placeholders = cityList.map((_, i) => `LOWER($${params.length + i + 1})`).join(', ');
params.push(...cityList);
whereClause += ` AND LOWER(city) IN (${placeholders})`;
}
}
// Multi-select type filter
if (types) {
const typeList = types.split(',').map(t => t.trim()).filter(t => t);
if (typeList.length > 0) {
const placeholders = typeList.map((_, i) => `$${params.length + i + 1}`).join(', ');
params.push(...typeList);
whereClause += ` AND resource_type IN (${placeholders})`;
}
}
// Multi-select contact filter
if (contact) {
const contactList = contact.split(',').map(c => c.trim()).filter(c => c);
const contactConditions = [];
if (contactList.includes('phone')) contactConditions.push('phone IS NOT NULL AND phone != \'\'');
if (contactList.includes('email')) contactConditions.push('email IS NOT NULL AND email != \'\'');
if (contactList.includes('website')) contactConditions.push('website IS NOT NULL AND website != \'\'');
if (contactConditions.length > 0) {
whereClause += ` AND (${contactConditions.join(' OR ')})`;
}
}
params.push(limit);
const result = await db.query(`
SELECT
id, name, description, resource_type,
address, city, phone, website,
hours_of_operation, latitude, longitude
FROM food_resources
${whereClause}
ORDER BY
CASE WHEN LOWER(name) LIKE LOWER($1) THEN 0 ELSE 1 END,
name
LIMIT $${params.length}
`, params);
res.json({ resources: result.rows });
} catch (error) {
logger.error('Search failed', { error: error.message });
res.status(500).json({ error: 'Search failed' });
}
}
// Get nearby resources
async function getNearbyResources(req, res) {
try {
const { lat, lng, radius = 25, limit = 20 } = req.query;
if (!lat || !lng) {
return res.status(400).json({ error: 'Latitude and longitude required' });
}
const latitude = parseFloat(lat);
const longitude = parseFloat(lng);
const radiusKm = parseFloat(radius);
// Haversine formula for distance calculation
const result = await db.query(`
SELECT
id, name, description, resource_type,
address, city, phone, website,
hours_of_operation, latitude, longitude,
(6371 * acos(
cos(radians($1)) * cos(radians(latitude)) *
cos(radians(longitude) - radians($2)) +
sin(radians($1)) * sin(radians(latitude))
)) AS distance_km
FROM food_resources
WHERE is_active = true
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND (6371 * acos(
cos(radians($1)) * cos(radians(latitude)) *
cos(radians(longitude) - radians($2)) +
sin(radians($1)) * sin(radians(latitude))
)) < $3
ORDER BY distance_km
LIMIT $4
`, [latitude, longitude, radiusKm, limit]);
res.json({ resources: result.rows });
} catch (error) {
logger.error('Nearby search failed', { error: error.message });
res.status(500).json({ error: 'Nearby search failed' });
}
}
// Get single resource by ID
async function getResourceById(req, res) {
try {
const { id } = req.params;
const result = await db.query(`
SELECT *
FROM food_resources
WHERE id = $1 AND is_active = true
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Resource not found' });
}
res.json({ resource: result.rows[0] });
} catch (error) {
logger.error('Failed to get resource', { error: error.message, id: req.params.id });
res.status(500).json({ error: 'Failed to fetch resource' });
}
}
// Get list of cities
async function getCities(req, res) {
try {
const result = await db.query(`
SELECT DISTINCT city, COUNT(*) as count
FROM food_resources
WHERE is_active = true AND city IS NOT NULL
GROUP BY city
ORDER BY count DESC, city
`);
res.json({ cities: result.rows });
} catch (error) {
logger.error('Failed to get cities', { error: error.message });
res.status(500).json({ error: 'Failed to fetch cities' });
}
}
// Get list of resource types
async function getTypes(req, res) {
try {
const result = await db.query(`
SELECT resource_type, COUNT(*) as count
FROM food_resources
WHERE is_active = true
GROUP BY resource_type
ORDER BY count DESC
`);
// Map enum values to friendly names
const typeNames = {
'food_bank': 'Food Bank',
'community_meal': 'Community Meal',
'hamper': 'Food Hamper',
'pantry': 'Food Pantry',
'soup_kitchen': 'Soup Kitchen',
'mobile_food': 'Mobile Food',
'grocery_program': 'Grocery Program',
'other': 'Other'
};
const types = result.rows.map(row => ({
value: row.resource_type,
label: typeNames[row.resource_type] || row.resource_type,
count: parseInt(row.count)
}));
res.json({ types });
} catch (error) {
logger.error('Failed to get types', { error: error.message });
res.status(500).json({ error: 'Failed to fetch types' });
}
}
// Get all resources for map display (minimal data, no pagination)
async function getAllResourcesForMap(req, res) {
try {
const { cities, types, contact } = req.query;
const params = [];
let whereClause = 'WHERE is_active = true AND latitude IS NOT NULL AND longitude IS NOT NULL';
// Multi-select city filter
if (cities) {
const cityList = cities.split(',').map(c => c.trim()).filter(c => c);
if (cityList.length > 0) {
const placeholders = cityList.map((_, i) => `LOWER($${params.length + i + 1})`).join(', ');
params.push(...cityList);
whereClause += ` AND LOWER(city) IN (${placeholders})`;
}
}
// Multi-select type filter
if (types) {
const typeList = types.split(',').map(t => t.trim()).filter(t => t);
if (typeList.length > 0) {
const placeholders = typeList.map((_, i) => `$${params.length + i + 1}`).join(', ');
params.push(...typeList);
whereClause += ` AND resource_type IN (${placeholders})`;
}
}
// Multi-select contact filter
if (contact) {
const contactList = contact.split(',').map(c => c.trim()).filter(c => c);
const contactConditions = [];
if (contactList.includes('phone')) contactConditions.push('phone IS NOT NULL AND phone != \'\'');
if (contactList.includes('email')) contactConditions.push('email IS NOT NULL AND email != \'\'');
if (contactList.includes('website')) contactConditions.push('website IS NOT NULL AND website != \'\'');
if (contactConditions.length > 0) {
whereClause += ` AND (${contactConditions.join(' OR ')})`;
}
}
const result = await db.query(`
SELECT
id, name, resource_type,
address, city,
latitude, longitude,
geocode_confidence, geocode_provider
FROM food_resources
${whereClause}
ORDER BY name
`, params);
res.json({ resources: result.rows });
} catch (error) {
logger.error('Failed to get map resources', { error: error.message });
res.status(500).json({ error: 'Failed to fetch map resources' });
}
}
// Get statistics
async function getStats(req, res) {
try {
const result = await db.query(`
SELECT
COUNT(*) as total_resources,
COUNT(DISTINCT city) as total_cities,
COUNT(CASE WHEN resource_type = 'food_bank' THEN 1 END) as food_banks,
COUNT(CASE WHEN resource_type = 'community_meal' THEN 1 END) as community_meals,
MAX(updated_at) as last_updated
FROM food_resources
WHERE is_active = true
`);
res.json({ stats: result.rows[0] });
} catch (error) {
logger.error('Failed to get stats', { error: error.message });
res.status(500).json({ error: 'Failed to fetch stats' });
}
}
module.exports = {
getResources,
searchResources,
getNearbyResources,
getResourceById,
getCities,
getTypes,
getStats,
getAllResourcesForMap
};

View File

@ -0,0 +1,56 @@
const routing = require('../services/routing');
const logger = require('../utils/logger');
async function getDirections(req, res) {
try {
const { startLat, startLng, endLat, endLng, profile } = req.query;
// Validate required parameters
if (!startLat || !startLng || !endLat || !endLng) {
return res.status(400).json({
error: 'Missing required parameters: startLat, startLng, endLat, endLng'
});
}
const start = {
lat: parseFloat(startLat),
lng: parseFloat(startLng)
};
const end = {
lat: parseFloat(endLat),
lng: parseFloat(endLng)
};
// Validate coordinates
if (isNaN(start.lat) || isNaN(start.lng) || isNaN(end.lat) || isNaN(end.lng)) {
return res.status(400).json({ error: 'Invalid coordinates' });
}
// Validate profile
const validProfiles = ['driving', 'walking', 'cycling'];
const routeProfile = validProfiles.includes(profile) ? profile : 'driving';
const directions = await routing.getDirections(
start.lat, start.lng,
end.lat, end.lng,
routeProfile
);
res.json({
success: true,
profile: routeProfile,
route: directions
});
} catch (error) {
logger.error('Directions request failed', { error: error.message });
res.status(500).json({
success: false,
error: error.message
});
}
}
module.exports = {
getDirections
};

View File

@ -0,0 +1,95 @@
const db = require('../models/db');
const logger = require('../utils/logger');
const { runAllScrapers } = require('../scrapers/run-all');
let isScrapingInProgress = false;
async function triggerScrape(req, res) {
try {
if (isScrapingInProgress) {
return res.status(409).json({
error: 'Scrape already in progress',
message: 'Please wait for the current scrape to complete'
});
}
isScrapingInProgress = true;
// Run scrapers in background
runAllScrapers()
.then(results => {
logger.info('Manual scrape completed', results);
isScrapingInProgress = false;
})
.catch(error => {
logger.error('Manual scrape failed', { error: error.message });
isScrapingInProgress = false;
});
res.json({
message: 'Scrape started',
status: 'running'
});
} catch (error) {
isScrapingInProgress = false;
logger.error('Failed to trigger scrape', { error: error.message });
res.status(500).json({ error: 'Failed to start scrape' });
}
}
async function getScrapeStatus(req, res) {
try {
const result = await db.query(`
SELECT source, status, started_at, completed_at,
records_found, records_added, records_updated, error_message
FROM scrape_logs
WHERE started_at > NOW() - INTERVAL '24 hours'
ORDER BY started_at DESC
LIMIT 10
`);
res.json({
isRunning: isScrapingInProgress,
recentLogs: result.rows
});
} catch (error) {
logger.error('Failed to get scrape status', { error: error.message });
res.status(500).json({ error: 'Failed to get status' });
}
}
async function getScrapeLogs(req, res) {
try {
const { limit = 50, source } = req.query;
let query = `
SELECT *
FROM scrape_logs
`;
const params = [];
if (source) {
params.push(source);
query += ` WHERE source = $1`;
}
query += ` ORDER BY started_at DESC LIMIT $${params.length + 1}`;
params.push(limit);
const result = await db.query(query, params);
res.json({ logs: result.rows });
} catch (error) {
logger.error('Failed to get scrape logs', { error: error.message });
res.status(500).json({ error: 'Failed to get logs' });
}
}
module.exports = {
triggerScrape,
getScrapeStatus,
getScrapeLogs
};

View File

@ -0,0 +1,570 @@
const db = require('../models/db');
const logger = require('../utils/logger');
// Submit an update request for a resource
async function submitUpdateRequest(req, res) {
try {
const { id } = req.params;
const {
submitter_email,
submitter_name,
proposed_name,
proposed_description,
proposed_resource_type,
proposed_address,
proposed_city,
proposed_phone,
proposed_email,
proposed_website,
proposed_hours_of_operation,
proposed_eligibility,
proposed_services_offered,
additional_notes
} = req.body;
// Validate required fields
if (!submitter_email) {
return res.status(400).json({ error: 'Email is required' });
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(submitter_email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Check if resource exists
const resourceCheck = await db.query(
'SELECT id FROM food_resources WHERE id = $1 AND is_active = true',
[id]
);
if (resourceCheck.rows.length === 0) {
return res.status(404).json({ error: 'Resource not found' });
}
// Insert update request
const result = await db.query(`
INSERT INTO listing_update_requests (
resource_id, submitter_email, submitter_name,
proposed_name, proposed_description, proposed_resource_type,
proposed_address, proposed_city, proposed_phone,
proposed_email, proposed_website, proposed_hours_of_operation,
proposed_eligibility, proposed_services_offered, additional_notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, created_at
`, [
id,
submitter_email,
submitter_name || null,
proposed_name || null,
proposed_description || null,
proposed_resource_type || null,
proposed_address || null,
proposed_city || null,
proposed_phone || null,
proposed_email || null,
proposed_website || null,
proposed_hours_of_operation || null,
proposed_eligibility || null,
proposed_services_offered || null,
additional_notes || null
]);
logger.info('Update request submitted', {
requestId: result.rows[0].id,
resourceId: id,
submitterEmail: submitter_email
});
res.status(201).json({
success: true,
message: 'Update request submitted successfully',
requestId: result.rows[0].id
});
} catch (error) {
logger.error('Failed to submit update request', { error: error.message });
res.status(500).json({ error: 'Failed to submit update request' });
}
}
// Get all update requests (admin)
async function getUpdateRequests(req, res) {
try {
const { status = 'pending', limit = 50, offset = 0 } = req.query;
const result = await db.query(`
SELECT
ur.*,
fr.name as current_name,
fr.description as current_description,
fr.resource_type as current_resource_type,
fr.address as current_address,
fr.city as current_city,
fr.phone as current_phone,
fr.email as current_email,
fr.website as current_website,
fr.hours_of_operation as current_hours_of_operation,
fr.eligibility as current_eligibility,
fr.services_offered as current_services_offered
FROM listing_update_requests ur
JOIN food_resources fr ON ur.resource_id = fr.id
WHERE ur.status = $1
ORDER BY ur.created_at DESC
LIMIT $2 OFFSET $3
`, [status, limit, offset]);
const countResult = await db.query(
'SELECT COUNT(*) FROM listing_update_requests WHERE status = $1',
[status]
);
res.json({
requests: result.rows,
total: parseInt(countResult.rows[0].count),
pagination: {
limit: parseInt(limit),
offset: parseInt(offset)
}
});
} catch (error) {
logger.error('Failed to get update requests', { error: error.message });
res.status(500).json({ error: 'Failed to fetch update requests' });
}
}
// Get counts of requests by status (admin)
async function getRequestCounts(req, res) {
try {
const result = await db.query(`
SELECT status, COUNT(*) as count
FROM listing_update_requests
GROUP BY status
`);
const counts = {
pending: 0,
approved: 0,
rejected: 0
};
result.rows.forEach(row => {
counts[row.status] = parseInt(row.count);
});
res.json({ counts });
} catch (error) {
logger.error('Failed to get request counts', { error: error.message });
res.status(500).json({ error: 'Failed to fetch request counts' });
}
}
// Approve an update request (admin)
async function approveRequest(req, res) {
const client = await db.getClient();
try {
const { id } = req.params;
const { admin_notes } = req.body;
await client.query('BEGIN');
// Get the update request
const requestResult = await client.query(
'SELECT * FROM listing_update_requests WHERE id = $1 AND status = $2',
[id, 'pending']
);
if (requestResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Pending request not found' });
}
const request = requestResult.rows[0];
// Build the update query dynamically based on proposed changes
const updates = [];
const values = [];
let paramIndex = 1;
const fields = [
'name', 'description', 'resource_type', 'address', 'city',
'phone', 'email', 'website', 'hours_of_operation',
'eligibility', 'services_offered'
];
fields.forEach(field => {
const proposedValue = request[`proposed_${field}`];
if (proposedValue !== null) {
updates.push(`${field} = $${paramIndex}`);
values.push(proposedValue);
paramIndex++;
}
});
if (updates.length > 0) {
// Add updated_at
updates.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(request.resource_id);
await client.query(`
UPDATE food_resources
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
`, values);
}
// Mark request as approved
await client.query(`
UPDATE listing_update_requests
SET status = 'approved', admin_notes = $1, reviewed_at = CURRENT_TIMESTAMP, reviewed_by = 'admin'
WHERE id = $2
`, [admin_notes || null, id]);
await client.query('COMMIT');
logger.info('Update request approved', {
requestId: id,
resourceId: request.resource_id,
fieldsUpdated: updates.length
});
res.json({
success: true,
message: 'Update request approved and changes applied',
fieldsUpdated: updates.length - 1 // Exclude updated_at
});
} catch (error) {
await client.query('ROLLBACK');
logger.error('Failed to approve update request', { error: error.message });
res.status(500).json({ error: 'Failed to approve update request' });
} finally {
client.release();
}
}
// Reject an update request (admin)
async function rejectRequest(req, res) {
try {
const { id } = req.params;
const { admin_notes } = req.body;
const result = await db.query(`
UPDATE listing_update_requests
SET status = 'rejected', admin_notes = $1, reviewed_at = CURRENT_TIMESTAMP, reviewed_by = 'admin'
WHERE id = $2 AND status = 'pending'
RETURNING id
`, [admin_notes || null, id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Pending request not found' });
}
logger.info('Update request rejected', { requestId: id });
res.json({
success: true,
message: 'Update request rejected'
});
} catch (error) {
logger.error('Failed to reject update request', { error: error.message });
res.status(500).json({ error: 'Failed to reject update request' });
}
}
// Validate admin password
async function validateAuth(req, res) {
const { password } = req.body;
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword) {
logger.error('ADMIN_PASSWORD not configured');
return res.status(500).json({ error: 'Admin authentication not configured' });
}
if (password === adminPassword) {
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid password' });
}
}
// ==========================================
// Listing Submissions (new listings)
// ==========================================
// Submit a new listing for approval
async function submitListingSubmission(req, res) {
try {
const {
submitter_email,
submitter_name,
name,
description,
resource_type,
address,
city,
phone,
email,
website,
hours_of_operation,
eligibility,
services_offered,
additional_notes
} = req.body;
// Validate required fields
if (!submitter_email) {
return res.status(400).json({ error: 'Your email is required' });
}
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Listing name is required' });
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(submitter_email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Insert listing submission
const result = await db.query(`
INSERT INTO listing_submissions (
submitter_email, submitter_name,
name, description, resource_type,
address, city, phone, email, website,
hours_of_operation, eligibility, services_offered,
additional_notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at
`, [
submitter_email,
submitter_name || null,
name.trim(),
description || null,
resource_type || 'other',
address || null,
city || null,
phone || null,
email || null,
website || null,
hours_of_operation || null,
eligibility || null,
services_offered || null,
additional_notes || null
]);
logger.info('Listing submission created', {
submissionId: result.rows[0].id,
submitterEmail: submitter_email,
listingName: name
});
res.status(201).json({
success: true,
message: 'Listing submitted successfully. It will be reviewed by our team.',
submissionId: result.rows[0].id
});
} catch (error) {
logger.error('Failed to submit listing', { error: error.message });
res.status(500).json({ error: 'Failed to submit listing' });
}
}
// Get all listing submissions (admin)
async function getListingSubmissions(req, res) {
try {
const { status = 'pending', limit = 50, offset = 0 } = req.query;
const result = await db.query(`
SELECT *
FROM listing_submissions
WHERE status = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, [status, limit, offset]);
const countResult = await db.query(
'SELECT COUNT(*) FROM listing_submissions WHERE status = $1',
[status]
);
res.json({
submissions: result.rows,
total: parseInt(countResult.rows[0].count),
pagination: {
limit: parseInt(limit),
offset: parseInt(offset)
}
});
} catch (error) {
logger.error('Failed to get listing submissions', { error: error.message });
res.status(500).json({ error: 'Failed to fetch listing submissions' });
}
}
// Get counts of listing submissions by status (admin)
async function getListingSubmissionCounts(req, res) {
try {
const result = await db.query(`
SELECT status, COUNT(*) as count
FROM listing_submissions
GROUP BY status
`);
const counts = {
pending: 0,
approved: 0,
rejected: 0
};
result.rows.forEach(row => {
counts[row.status] = parseInt(row.count);
});
res.json({ counts });
} catch (error) {
logger.error('Failed to get listing submission counts', { error: error.message });
res.status(500).json({ error: 'Failed to fetch submission counts' });
}
}
// Approve a listing submission (admin) - creates the actual resource
async function approveListingSubmission(req, res) {
const client = await db.getClient();
try {
const { id } = req.params;
const { admin_notes } = req.body;
await client.query('BEGIN');
// Get the submission
const submissionResult = await client.query(
'SELECT * FROM listing_submissions WHERE id = $1 AND status = $2',
[id, 'pending']
);
if (submissionResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Pending submission not found' });
}
const submission = submissionResult.rows[0];
// Create the new resource in food_resources
const resourceResult = await client.query(`
INSERT INTO food_resources (
name, description, resource_type,
address, city, phone, email, website,
hours_of_operation, eligibility, services_offered,
source, source_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'manual', $12)
RETURNING id
`, [
submission.name,
submission.description,
submission.resource_type,
submission.address,
submission.city,
submission.phone,
submission.email,
submission.website,
submission.hours_of_operation,
submission.eligibility,
submission.services_offered,
`submission_${submission.id}`
]);
const newResourceId = resourceResult.rows[0].id;
// Mark submission as approved and link to created resource
await client.query(`
UPDATE listing_submissions
SET status = 'approved',
admin_notes = $1,
reviewed_at = CURRENT_TIMESTAMP,
reviewed_by = 'admin',
created_resource_id = $2
WHERE id = $3
`, [admin_notes || null, newResourceId, id]);
await client.query('COMMIT');
logger.info('Listing submission approved', {
submissionId: id,
createdResourceId: newResourceId,
listingName: submission.name
});
res.json({
success: true,
message: 'Listing approved and published',
resourceId: newResourceId
});
} catch (error) {
await client.query('ROLLBACK');
logger.error('Failed to approve listing submission', { error: error.message });
res.status(500).json({ error: 'Failed to approve submission' });
} finally {
client.release();
}
}
// Reject a listing submission (admin)
async function rejectListingSubmission(req, res) {
try {
const { id } = req.params;
const { admin_notes } = req.body;
const result = await db.query(`
UPDATE listing_submissions
SET status = 'rejected',
admin_notes = $1,
reviewed_at = CURRENT_TIMESTAMP,
reviewed_by = 'admin'
WHERE id = $2 AND status = 'pending'
RETURNING id
`, [admin_notes || null, id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Pending submission not found' });
}
logger.info('Listing submission rejected', { submissionId: id });
res.json({
success: true,
message: 'Listing submission rejected'
});
} catch (error) {
logger.error('Failed to reject listing submission', { error: error.message });
res.status(500).json({ error: 'Failed to reject submission' });
}
}
module.exports = {
submitUpdateRequest,
getUpdateRequests,
getRequestCounts,
approveRequest,
rejectRequest,
validateAuth,
// Listing submissions
submitListingSubmission,
getListingSubmissions,
getListingSubmissionCounts,
approveListingSubmission,
rejectListingSubmission
};

View File

@ -0,0 +1,34 @@
const logger = require('../utils/logger');
function adminAuth(req, res, next) {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword) {
logger.error('ADMIN_PASSWORD not configured');
return res.status(500).json({ error: 'Admin authentication not configured' });
}
// Check Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'Authorization required' });
}
// Expected format: "Bearer <password>"
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({ error: 'Invalid authorization format' });
}
const password = parts[1];
if (password !== adminPassword) {
logger.warn('Invalid admin password attempt');
return res.status(401).json({ error: 'Invalid password' });
}
next();
}
module.exports = adminAuth;

View File

@ -0,0 +1,27 @@
const { Pool } = require('pg');
const logger = require('../utils/logger');
const pool = new Pool({
host: process.env.DB_HOST || 'listmonk-db',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'freealberta_food',
user: process.env.DB_USER || process.env.POSTGRES_USER,
password: process.env.DB_PASSWORD || process.env.POSTGRES_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('connect', () => {
logger.info('Connected to PostgreSQL database');
});
pool.on('error', (err) => {
logger.error('Unexpected PostgreSQL error', { error: err.message });
});
module.exports = {
query: (text, params) => pool.query(text, params),
getClient: () => pool.connect(),
end: () => pool.end()
};

View File

@ -0,0 +1,206 @@
const db = require('./db');
const logger = require('../utils/logger');
const initDatabase = async () => {
const client = await db.getClient();
try {
await client.query('BEGIN');
// Create enum types
await client.query(`
DO $$ BEGIN
CREATE TYPE resource_type AS ENUM (
'food_bank',
'community_meal',
'hamper',
'pantry',
'soup_kitchen',
'mobile_food',
'grocery_program',
'other'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`);
await client.query(`
DO $$ BEGIN
CREATE TYPE data_source AS ENUM (
'informalberta',
'ab211',
'edmonton_foodbank_pdf',
'manual'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`);
// Create main resources table
await client.query(`
CREATE TABLE IF NOT EXISTS food_resources (
id SERIAL PRIMARY KEY,
name VARCHAR(500) NOT NULL,
description TEXT,
resource_type resource_type DEFAULT 'other',
-- Location info
address VARCHAR(500),
city VARCHAR(100),
province VARCHAR(50) DEFAULT 'Alberta',
postal_code VARCHAR(10),
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
-- Contact info
phone VARCHAR(50),
email VARCHAR(255),
website VARCHAR(500),
-- Operating info
hours_of_operation TEXT,
eligibility TEXT,
services_offered TEXT,
-- Meta info
source data_source NOT NULL,
source_url VARCHAR(500),
source_id VARCHAR(100),
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_verified_at TIMESTAMP WITH TIME ZONE,
-- Status
is_active BOOLEAN DEFAULT true,
-- Unique constraint on source + source_id
UNIQUE(source, source_id)
);
`);
// Create indexes
await client.query(`
CREATE INDEX IF NOT EXISTS idx_food_resources_city ON food_resources(city);
CREATE INDEX IF NOT EXISTS idx_food_resources_type ON food_resources(resource_type);
CREATE INDEX IF NOT EXISTS idx_food_resources_active ON food_resources(is_active);
CREATE INDEX IF NOT EXISTS idx_food_resources_location ON food_resources(latitude, longitude);
`);
// Create scrape logs table
await client.query(`
CREATE TABLE IF NOT EXISTS scrape_logs (
id SERIAL PRIMARY KEY,
source data_source NOT NULL,
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP WITH TIME ZONE,
status VARCHAR(50) DEFAULT 'running',
records_found INTEGER DEFAULT 0,
records_added INTEGER DEFAULT 0,
records_updated INTEGER DEFAULT 0,
error_message TEXT
);
`);
// Create listing update requests table
await client.query(`
CREATE TABLE IF NOT EXISTS listing_update_requests (
id SERIAL PRIMARY KEY,
resource_id INTEGER REFERENCES food_resources(id) ON DELETE CASCADE,
submitter_email VARCHAR(255) NOT NULL,
submitter_name VARCHAR(255),
-- Proposed changes (null = no change requested)
proposed_name VARCHAR(500),
proposed_description TEXT,
proposed_resource_type resource_type,
proposed_address VARCHAR(500),
proposed_city VARCHAR(100),
proposed_phone VARCHAR(50),
proposed_email VARCHAR(255),
proposed_website VARCHAR(500),
proposed_hours_of_operation TEXT,
proposed_eligibility TEXT,
proposed_services_offered TEXT,
additional_notes TEXT,
status VARCHAR(20) DEFAULT 'pending',
admin_notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP WITH TIME ZONE,
reviewed_by VARCHAR(100)
);
`);
// Create index for listing update requests
await client.query(`
CREATE INDEX IF NOT EXISTS idx_listing_update_requests_status ON listing_update_requests(status);
CREATE INDEX IF NOT EXISTS idx_listing_update_requests_resource ON listing_update_requests(resource_id);
`);
// Create listing submissions table (for new listings submitted by users)
await client.query(`
CREATE TABLE IF NOT EXISTS listing_submissions (
id SERIAL PRIMARY KEY,
submitter_email VARCHAR(255) NOT NULL,
submitter_name VARCHAR(255),
-- Proposed listing data
name VARCHAR(500) NOT NULL,
description TEXT,
resource_type resource_type DEFAULT 'other',
address VARCHAR(500),
city VARCHAR(100),
phone VARCHAR(50),
email VARCHAR(255),
website VARCHAR(500),
hours_of_operation TEXT,
eligibility TEXT,
services_offered TEXT,
additional_notes TEXT,
status VARCHAR(20) DEFAULT 'pending',
admin_notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP WITH TIME ZONE,
reviewed_by VARCHAR(100),
created_resource_id INTEGER REFERENCES food_resources(id)
);
`);
// Create indexes for listing submissions
await client.query(`
CREATE INDEX IF NOT EXISTS idx_listing_submissions_status ON listing_submissions(status);
`);
await client.query('COMMIT');
logger.info('Database initialized successfully');
} catch (error) {
await client.query('ROLLBACK');
logger.error('Database initialization failed', { error: error.message });
throw error;
} finally {
client.release();
}
};
// Run if called directly
if (require.main === module) {
initDatabase()
.then(() => {
logger.info('Database setup complete');
process.exit(0);
})
.catch((err) => {
logger.error('Database setup failed', { error: err.message });
process.exit(1);
});
}
module.exports = { initDatabase };

View File

@ -0,0 +1,34 @@
{
"name": "freealberta-food",
"version": "1.0.0",
"description": "Free food resources directory for Alberta",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"scrape": "node scrapers/run-all.js",
"scrape:informalberta": "node scrapers/informalberta.js",
"scrape:211": "node scrapers/ab211.js",
"scrape:pdf": "node scrapers/pdf-parser.js",
"db:init": "node models/init-db.js"
},
"dependencies": {
"express": "^4.18.2",
"pg": "^8.11.3",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"compression": "^1.7.4",
"dotenv": "^16.3.1",
"cheerio": "^1.0.0-rc.12",
"axios": "^1.6.2",
"pdf-parse": "^1.1.1",
"node-cron": "^3.0.3",
"winston": "^3.11.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -0,0 +1,231 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - Free Alberta Food</title>
<meta name="robots" content="noindex, nofollow">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-content">
<a href="/" class="logo">
<span class="logo-icon">&#127869;</span>
<span class="logo-text">Free Alberta Food</span>
</a>
<nav class="nav">
<span class="admin-badge">Admin Panel</span>
<button id="logoutBtn" class="nav-link logout-btn hidden">Logout</button>
</nav>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Login Section -->
<div id="loginSection" class="admin-login-section">
<div class="login-card">
<h1>Admin Login</h1>
<p>Enter the admin password to access the dashboard.</p>
<form id="loginForm">
<div class="form-group">
<label for="adminPassword">Password</label>
<input type="password" id="adminPassword" required autocomplete="current-password">
</div>
<div id="loginError" class="error-message hidden"></div>
<button type="submit" class="resource-action-btn primary full-width">Login</button>
</form>
</div>
</div>
<!-- Admin Dashboard -->
<div id="adminDashboard" class="hidden">
<!-- Section Selector -->
<div class="admin-section-tabs">
<button class="admin-section-tab active" data-section="updates">Update Requests</button>
<button class="admin-section-tab" data-section="listings">New Listings</button>
<button class="admin-section-tab" data-section="geocoding">Geocoding</button>
</div>
<!-- Update Requests Section -->
<div id="updatesSection" class="admin-section">
<h2 class="section-title">Update Requests</h2>
<!-- Stats Bar -->
<div class="admin-stats-bar">
<div class="stat-card">
<span class="stat-value" id="pendingCount">0</span>
<span class="stat-label">Pending</span>
</div>
<div class="stat-card">
<span class="stat-value" id="approvedCount">0</span>
<span class="stat-label">Approved</span>
</div>
<div class="stat-card">
<span class="stat-value" id="rejectedCount">0</span>
<span class="stat-label">Rejected</span>
</div>
</div>
<!-- Filter Tabs -->
<div class="admin-tabs">
<button class="admin-tab active" data-status="pending" data-section="updates">Pending</button>
<button class="admin-tab" data-status="approved" data-section="updates">Approved</button>
<button class="admin-tab" data-status="rejected" data-section="updates">Rejected</button>
</div>
<!-- Requests List -->
<div id="requestsList" class="requests-list">
<!-- Requests will be loaded here -->
</div>
<div id="noRequests" class="no-results hidden">
<p>No update requests found.</p>
</div>
<div id="loadingRequests" class="loading-indicator hidden">
<div class="spinner"></div>
<span>Loading requests...</span>
</div>
</div>
<!-- New Listings Section -->
<div id="listingsSection" class="admin-section hidden">
<h2 class="section-title">New Listing Submissions</h2>
<!-- Stats Bar -->
<div class="admin-stats-bar">
<div class="stat-card">
<span class="stat-value" id="listingPendingCount">0</span>
<span class="stat-label">Pending</span>
</div>
<div class="stat-card">
<span class="stat-value" id="listingApprovedCount">0</span>
<span class="stat-label">Approved</span>
</div>
<div class="stat-card">
<span class="stat-value" id="listingRejectedCount">0</span>
<span class="stat-label">Rejected</span>
</div>
</div>
<!-- Filter Tabs -->
<div class="admin-tabs">
<button class="admin-tab active" data-status="pending" data-section="listings">Pending</button>
<button class="admin-tab" data-status="approved" data-section="listings">Approved</button>
<button class="admin-tab" data-status="rejected" data-section="listings">Rejected</button>
</div>
<!-- Listings List -->
<div id="listingsList" class="requests-list">
<!-- Listings will be loaded here -->
</div>
<div id="noListings" class="no-results hidden">
<p>No listing submissions found.</p>
</div>
<div id="loadingListings" class="loading-indicator hidden">
<div class="spinner"></div>
<span>Loading submissions...</span>
</div>
</div>
<!-- Geocoding Section -->
<div id="geocodingSection" class="admin-section hidden">
<h2 class="section-title">Geocoding Management</h2>
<!-- Stats Bar -->
<div class="admin-stats-bar">
<div class="stat-card">
<span class="stat-value" id="geoTotalCount">0</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card" style="border-left-color:#10b981">
<span class="stat-value" id="geoHighCount">0</span>
<span class="stat-label">High (80%+)</span>
</div>
<div class="stat-card" style="border-left-color:#f59e0b">
<span class="stat-value" id="geoMediumCount">0</span>
<span class="stat-label">Medium (50-79%)</span>
</div>
<div class="stat-card" style="border-left-color:#ef4444">
<span class="stat-value" id="geoLowCount">0</span>
<span class="stat-label">Low (&lt;50%)</span>
</div>
</div>
<!-- Filter Tabs -->
<div class="admin-tabs">
<button class="admin-tab active" data-filter="all" data-section="geocoding">All</button>
<button class="admin-tab" data-filter="low" data-section="geocoding">Low Confidence</button>
<button class="admin-tab" data-filter="missing" data-section="geocoding">Missing Coords</button>
</div>
<!-- Resources List -->
<div id="geocodingList" class="requests-list">
<!-- Resources will be loaded here -->
</div>
<div id="noGeoResources" class="no-results hidden">
<p>No resources found.</p>
</div>
<div id="loadingGeoResources" class="loading-indicator hidden">
<div class="spinner"></div>
<span>Loading resources...</span>
</div>
</div>
</div>
</main>
<!-- Request Detail Modal -->
<div id="requestModal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content modal-large">
<button class="modal-close">&times;</button>
<div id="requestModalBody">
<!-- Request details will be loaded here -->
</div>
</div>
</div>
<!-- Listing Submission Detail Modal -->
<div id="listingModal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content modal-large">
<button class="modal-close">&times;</button>
<div id="listingModalBody">
<!-- Listing details will be loaded here -->
</div>
</div>
</div>
<!-- Geocoding Detail Modal -->
<div id="geocodingModal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content modal-large">
<button class="modal-close">&times;</button>
<div id="geocodingModalBody">
<!-- Resource geocoding details will be loaded here -->
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<p>&copy; 2025 <a href="https://freealberta.org">Free Alberta</a>. Admin Panel.</p>
</div>
</footer>
<!-- Custom JS -->
<script src="/js/admin.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,487 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Free Food Resources - Free Alberta</title>
<meta name="description" content="Find free food resources in Alberta including food banks, community meals, and food hampers.">
<!-- Leaflet CSS for maps -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-content">
<a href="/" class="logo">
<span class="logo-icon">&#127869;</span>
<span class="logo-text">Free Alberta Food</span>
</a>
<nav class="nav">
<a href="https://freealberta.org" class="nav-link">Free Alberta</a>
</nav>
</div>
</header>
<!-- Hero Section -->
<section class="hero">
<div class="hero-content">
<h1>Find Free Food Resources in Alberta</h1>
<p>Locate food banks, community meals, and food assistance programs near you</p>
<!-- Search Bar -->
<div class="search-container">
<input
type="text"
id="searchInput"
class="search-input"
placeholder="Search by name, address, or service..."
autocomplete="off"
>
<button id="searchBtn" class="search-btn">Search</button>
<button id="locationBtn" class="location-btn" title="Use my location">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 2v4m0 12v4m10-10h-4M6 12H2"></path>
</svg>
Find Me
</button>
<button id="closestBtn" class="location-btn" title="Find closest resource">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
Closest Resource
</button>
</div>
</div>
</section>
<!-- Main Content -->
<main class="main-content">
<!-- Filters -->
<div class="filters-bar">
<div class="filters-left">
<div class="multi-select" id="cityFilterContainer">
<button class="multi-select-btn" type="button">
<span class="multi-select-label">All Cities</span>
<svg class="multi-select-arrow" width="12" height="12" viewBox="0 0 12 12"><path fill="currentColor" d="M6 8L1 3h10z"/></svg>
</button>
<div class="multi-select-dropdown hidden">
<div class="multi-select-search">
<input type="text" placeholder="Search cities..." class="multi-select-search-input">
</div>
<div class="multi-select-options" id="cityFilterOptions">
<!-- Options loaded dynamically -->
</div>
<div class="multi-select-actions">
<button type="button" class="multi-select-clear">Clear</button>
<button type="button" class="multi-select-apply">Apply</button>
</div>
</div>
</div>
<div class="multi-select" id="typeFilterContainer">
<button class="multi-select-btn" type="button">
<span class="multi-select-label">All Types</span>
<svg class="multi-select-arrow" width="12" height="12" viewBox="0 0 12 12"><path fill="currentColor" d="M6 8L1 3h10z"/></svg>
</button>
<div class="multi-select-dropdown hidden">
<div class="multi-select-options" id="typeFilterOptions">
<!-- Options loaded dynamically -->
</div>
<div class="multi-select-actions">
<button type="button" class="multi-select-clear">Clear</button>
<button type="button" class="multi-select-apply">Apply</button>
</div>
</div>
</div>
<div class="multi-select" id="contactFilterContainer">
<button class="multi-select-btn" type="button">
<span class="multi-select-label">Any Contact</span>
<svg class="multi-select-arrow" width="12" height="12" viewBox="0 0 12 12"><path fill="currentColor" d="M6 8L1 3h10z"/></svg>
</button>
<div class="multi-select-dropdown hidden">
<div class="multi-select-options" id="contactFilterOptions">
<label class="multi-select-option">
<input type="checkbox" value="phone">
<span>Has Phone</span>
</label>
<label class="multi-select-option">
<input type="checkbox" value="email">
<span>Has Email</span>
</label>
<label class="multi-select-option">
<input type="checkbox" value="website">
<span>Has Website</span>
</label>
</div>
<div class="multi-select-actions">
<button type="button" class="multi-select-clear">Clear</button>
<button type="button" class="multi-select-apply">Apply</button>
</div>
</div>
</div>
</div>
<div class="filters-right">
<span id="resultCount" class="result-count">0 resources</span>
</div>
</div>
<!-- Content Area - Split Layout -->
<div class="content-area split-layout">
<!-- Map View (Always Visible) -->
<div id="mapContainer" class="map-container">
<div id="map"></div>
</div>
<!-- List View (Always Visible) -->
<div id="listContainer" class="list-container">
<div id="resourceList" class="resource-list">
<!-- Resources will be loaded here -->
</div>
<div id="loadingIndicator" class="loading-indicator hidden">
<div class="spinner"></div>
<span>Loading resources...</span>
</div>
<div id="noResults" class="no-results hidden">
<p>No resources found matching your criteria.</p>
</div>
</div>
</div>
</main>
<!-- Resource Detail Modal -->
<div id="resourceModal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content">
<button class="modal-close">&times;</button>
<div id="modalBody">
<!-- Resource details will be loaded here -->
</div>
</div>
</div>
<!-- Directions Modal -->
<div id="directionsModal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content modal-large">
<button class="modal-close">&times;</button>
<div class="directions-container">
<div class="directions-header">
<h2>Directions</h2>
<div class="directions-controls">
<select id="travelMode" class="filter-select">
<option value="driving">Driving</option>
<option value="walking">Walking</option>
<option value="cycling">Cycling</option>
</select>
<button id="printDirectionsBtn" class="resource-action-btn" title="Print directions">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
Print
</button>
</div>
</div>
<div class="directions-summary" id="directionsSummary">
<!-- Summary will be inserted here -->
</div>
<div class="directions-layout">
<div class="directions-map-container">
<div id="directionsMap"></div>
</div>
<div class="directions-steps-container">
<div id="directionsSteps" class="directions-steps">
<!-- Turn-by-turn steps will be inserted here -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Update Request Modal -->
<div id="updateModal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content modal-large">
<button class="modal-close">&times;</button>
<div class="update-modal-container">
<h2>Update This Listing</h2>
<p class="update-modal-subtitle">Suggest corrections or updates to this resource listing. Your submission will be reviewed by our team.</p>
<form id="updateForm" class="update-form">
<input type="hidden" id="updateResourceId" value="">
<div class="form-section">
<h3>Your Information</h3>
<div class="form-row">
<div class="form-group">
<label for="submitterEmail">Email <span class="required">*</span></label>
<input type="email" id="submitterEmail" required placeholder="your@email.com">
</div>
<div class="form-group">
<label for="submitterName">Name (optional)</label>
<input type="text" id="submitterName" placeholder="Your name">
</div>
</div>
</div>
<div class="form-section">
<h3>Proposed Changes</h3>
<p class="form-hint">Leave fields blank if no change is needed</p>
<div class="form-group">
<label for="proposedName">Name</label>
<input type="text" id="proposedName" placeholder="Resource name">
</div>
<div class="form-row">
<div class="form-group">
<label for="proposedResourceType">Type</label>
<select id="proposedResourceType">
<option value="">-- No change --</option>
<option value="food_bank">Food Bank</option>
<option value="community_meal">Community Meal</option>
<option value="hamper">Food Hamper</option>
<option value="pantry">Food Pantry</option>
<option value="soup_kitchen">Soup Kitchen</option>
<option value="mobile_food">Mobile Food</option>
<option value="grocery_program">Grocery Program</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label for="proposedCity">City</label>
<input type="text" id="proposedCity" placeholder="City">
</div>
</div>
<div class="form-group">
<label for="proposedAddress">Address</label>
<input type="text" id="proposedAddress" placeholder="Street address">
</div>
<div class="form-row">
<div class="form-group">
<label for="proposedPhone">Phone</label>
<input type="tel" id="proposedPhone" placeholder="Phone number">
</div>
<div class="form-group">
<label for="proposedEmail">Contact Email</label>
<input type="email" id="proposedEmail" placeholder="Contact email">
</div>
</div>
<div class="form-group">
<label for="proposedWebsite">Website</label>
<input type="url" id="proposedWebsite" placeholder="https://...">
</div>
<div class="form-group">
<label for="proposedHours">Hours of Operation</label>
<textarea id="proposedHours" rows="2" placeholder="e.g., Monday-Friday 9am-5pm"></textarea>
</div>
<div class="form-group">
<label for="proposedDescription">Description</label>
<textarea id="proposedDescription" rows="3" placeholder="Description of services"></textarea>
</div>
<div class="form-group">
<label for="proposedEligibility">Eligibility Requirements</label>
<textarea id="proposedEligibility" rows="2" placeholder="Who can access this service"></textarea>
</div>
<div class="form-group">
<label for="proposedServices">Services Offered</label>
<textarea id="proposedServices" rows="2" placeholder="What services are available"></textarea>
</div>
</div>
<div class="form-section">
<h3>Additional Notes</h3>
<div class="form-group">
<label for="additionalNotes">Any other information</label>
<textarea id="additionalNotes" rows="3" placeholder="Additional context or notes about your suggested changes"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="resource-action-btn" onclick="app.closeModal('updateModal')">Cancel</button>
<button type="submit" class="resource-action-btn primary">Submit Update Request</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Listing Modal -->
<div id="addListingModal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content modal-large">
<button class="modal-close">&times;</button>
<div class="update-modal-container">
<h2>Add a New Listing</h2>
<p class="update-modal-subtitle">Submit a new food resource to be added to our directory. Your submission will be reviewed by our team before being published.</p>
<form id="addListingForm" class="update-form">
<div class="form-section">
<h3>Your Information</h3>
<div class="form-row">
<div class="form-group">
<label for="newListingSubmitterEmail">Email <span class="required">*</span></label>
<input type="email" id="newListingSubmitterEmail" required placeholder="your@email.com">
</div>
<div class="form-group">
<label for="newListingSubmitterName">Name (optional)</label>
<input type="text" id="newListingSubmitterName" placeholder="Your name">
</div>
</div>
</div>
<div class="form-section">
<h3>Listing Details</h3>
<div class="form-group">
<label for="newListingName">Name <span class="required">*</span></label>
<input type="text" id="newListingName" required placeholder="Name of the food resource">
</div>
<div class="form-row">
<div class="form-group">
<label for="newListingResourceType">Type</label>
<select id="newListingResourceType">
<option value="food_bank">Food Bank</option>
<option value="community_meal">Community Meal</option>
<option value="hamper">Food Hamper</option>
<option value="pantry">Food Pantry</option>
<option value="soup_kitchen">Soup Kitchen</option>
<option value="mobile_food">Mobile Food</option>
<option value="grocery_program">Grocery Program</option>
<option value="other" selected>Other</option>
</select>
</div>
<div class="form-group">
<label for="newListingCity">City</label>
<input type="text" id="newListingCity" placeholder="City">
</div>
</div>
<div class="form-group">
<label for="newListingAddress">Address</label>
<input type="text" id="newListingAddress" placeholder="Street address">
</div>
<div class="form-row">
<div class="form-group">
<label for="newListingPhone">Phone</label>
<input type="tel" id="newListingPhone" placeholder="Phone number">
</div>
<div class="form-group">
<label for="newListingEmail">Contact Email</label>
<input type="email" id="newListingEmail" placeholder="Contact email for the resource">
</div>
</div>
<div class="form-group">
<label for="newListingWebsite">Website</label>
<input type="url" id="newListingWebsite" placeholder="https://...">
</div>
<div class="form-group">
<label for="newListingHours">Hours of Operation</label>
<textarea id="newListingHours" rows="2" placeholder="e.g., Monday-Friday 9am-5pm"></textarea>
</div>
<div class="form-group">
<label for="newListingDescription">Description</label>
<textarea id="newListingDescription" rows="3" placeholder="Description of services provided"></textarea>
</div>
<div class="form-group">
<label for="newListingEligibility">Eligibility Requirements</label>
<textarea id="newListingEligibility" rows="2" placeholder="Who can access this service"></textarea>
</div>
<div class="form-group">
<label for="newListingServices">Services Offered</label>
<textarea id="newListingServices" rows="2" placeholder="What services are available"></textarea>
</div>
</div>
<div class="form-section">
<h3>Additional Notes</h3>
<div class="form-group">
<label for="newListingNotes">Any other information</label>
<textarea id="newListingNotes" rows="3" placeholder="Additional context or notes about this listing"></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="resource-action-btn" onclick="app.closeModal('addListingModal')">Cancel</button>
<button type="submit" class="resource-action-btn primary">Submit Listing</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Listing Call-to-Action -->
<section class="add-listing-cta">
<div class="add-listing-cta-content">
<h2>Know of a food resource not listed here?</h2>
<p>Help your community by adding it to our directory.</p>
<button id="addListingBtn" class="resource-action-btn primary large">Add a Listing</button>
</div>
</section>
<!-- Print-only content -->
<div id="printContent" class="print-only">
<div class="print-header">
<div class="print-logo">
<span class="print-logo-icon">&#127869;</span>
<span class="print-logo-text">Free Alberta Food</span>
</div>
<div class="print-tagline">Helping Albertans access free food resources</div>
</div>
<div class="print-title">
<h1>Directions</h1>
<p class="print-date" id="printDate"></p>
</div>
<div id="printDirections"></div>
<div class="print-footer">
<div class="print-footer-brand">
<span>&#127869;</span> Free Alberta Food
</div>
<div class="print-footer-url">food.freealberta.org</div>
<div class="print-footer-note">Data sourced from InformAlberta, 211 Alberta, and Edmonton's Food Bank</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-content">
<p>&copy; 2025 <a href="https://freealberta.org">Free Alberta</a>. Helping Albertans access free food resources.</p>
<p class="footer-note">Data sourced from InformAlberta, 211 Alberta, and Edmonton's Food Bank.</p>
<p class="footer-admin"><a href="/admin.html">Admin Login</a></p>
</div>
</footer>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Custom JS -->
<script src="/js/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,56 @@
const express = require('express');
const router = express.Router();
const resourceController = require('../controllers/resourceController');
const scraperController = require('../controllers/scraperController');
const geocodingController = require('../controllers/geocodingController');
const routingController = require('../controllers/routingController');
const updateRequestController = require('../controllers/updateRequestController');
const adminAuth = require('../middleware/adminAuth');
// Resource endpoints
router.get('/resources', resourceController.getResources);
router.get('/resources/search', resourceController.searchResources);
router.get('/resources/nearby', resourceController.getNearbyResources);
router.get('/resources/map', resourceController.getAllResourcesForMap);
router.get('/resources/:id', resourceController.getResourceById);
// Filter options
router.get('/cities', resourceController.getCities);
router.get('/types', resourceController.getTypes);
// Stats
router.get('/stats', resourceController.getStats);
// Admin/scraper endpoints
router.post('/scrape', scraperController.triggerScrape);
router.get('/scrape/status', scraperController.getScrapeStatus);
router.get('/scrape/logs', scraperController.getScrapeLogs);
// Geocoding endpoints
router.get('/geocode', geocodingController.geocodeAddress);
router.get('/geocode/reverse', geocodingController.reverseGeocode);
router.post('/admin/resources/:id/regeocode', adminAuth, geocodingController.regeocodeResource);
// Routing/Directions endpoints
router.get('/directions', routingController.getDirections);
// Update request endpoints (public)
router.post('/resources/:id/update-request', updateRequestController.submitUpdateRequest);
// Listing submission endpoint (public)
router.post('/listings/submit', updateRequestController.submitListingSubmission);
// Admin endpoints (protected)
router.post('/admin/auth', updateRequestController.validateAuth);
router.get('/admin/update-requests', adminAuth, updateRequestController.getUpdateRequests);
router.get('/admin/update-requests/counts', adminAuth, updateRequestController.getRequestCounts);
router.post('/admin/update-requests/:id/approve', adminAuth, updateRequestController.approveRequest);
router.post('/admin/update-requests/:id/reject', adminAuth, updateRequestController.rejectRequest);
// Listing submissions admin endpoints (protected)
router.get('/admin/listing-submissions', adminAuth, updateRequestController.getListingSubmissions);
router.get('/admin/listing-submissions/counts', adminAuth, updateRequestController.getListingSubmissionCounts);
router.post('/admin/listing-submissions/:id/approve', adminAuth, updateRequestController.approveListingSubmission);
router.post('/admin/listing-submissions/:id/reject', adminAuth, updateRequestController.rejectListingSubmission);
module.exports = router;

View File

@ -0,0 +1,206 @@
const axios = require('axios');
const db = require('../models/db');
const logger = require('../utils/logger');
/*
* 211 Alberta Scraper
*
* NOTE: ab.211.ca uses Cloudflare protection which blocks automated scraping.
* This scraper is designed to work with their API if access is granted,
* or can be used with manual data entry.
*
* Options for getting 211 data:
* 1. Contact 211 Alberta to request API access
* 2. Use their data export/sharing programs
* 3. Manual data entry from their website
*
* For now, this provides a framework for importing 211 data.
*/
const SEARCH_LOCATIONS = [
{ name: 'Calgary', lat: 51.0447, lng: -114.0719 },
{ name: 'Edmonton', lat: 53.5461, lng: -113.4938 },
{ name: 'Red Deer', lat: 52.2681, lng: -113.8112 },
{ name: 'Lethbridge', lat: 49.6956, lng: -112.8451 },
{ name: 'Medicine Hat', lat: 50.0405, lng: -110.6764 }
];
// Topic ID 58 = Food/Meals in 211's taxonomy
const FOOD_TOPIC_ID = 58;
async function attempt211Fetch(location) {
const url = `https://ab.211.ca/api/v1/search`;
try {
const response = await axios.get(url, {
params: {
latitude: location.lat,
longitude: location.lng,
topicPath: FOOD_TOPIC_ID,
distance: 50
},
headers: {
'User-Agent': 'FreeAlbertaFoodBot/1.0',
'Accept': 'application/json'
},
timeout: 30000
});
return response.data;
} catch (error) {
if (error.response?.status === 403) {
logger.warn('211 Alberta blocked request (Cloudflare protection)', {
location: location.name
});
} else {
logger.error('211 fetch failed', {
location: location.name,
error: error.message
});
}
return null;
}
}
async function importManualData(resources) {
/*
* Use this function to import manually collected 211 data.
* Expected format:
* [{
* name: 'Service Name',
* description: 'Description',
* address: 'Full address',
* city: 'City',
* phone: 'Phone number',
* website: 'URL',
* hours: 'Hours of operation',
* type: 'food_bank|community_meal|hamper|pantry|etc'
* }]
*/
let added = 0;
let updated = 0;
for (const resource of resources) {
try {
const sourceId = resource.id ||
`211-${resource.name}-${resource.city}`.replace(/\s+/g, '-').toLowerCase();
const result = await db.query(`
INSERT INTO food_resources (
name, description, resource_type,
address, city, phone, website,
hours_of_operation, source, source_id,
updated_at, last_verified_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ab211', $9, NOW(), NOW())
ON CONFLICT (source, source_id)
DO UPDATE SET
name = EXCLUDED.name,
description = COALESCE(EXCLUDED.description, food_resources.description),
address = COALESCE(EXCLUDED.address, food_resources.address),
phone = COALESCE(EXCLUDED.phone, food_resources.phone),
website = COALESCE(EXCLUDED.website, food_resources.website),
hours_of_operation = COALESCE(EXCLUDED.hours_of_operation, food_resources.hours_of_operation),
updated_at = NOW()
RETURNING (xmax = 0) AS inserted
`, [
resource.name,
resource.description || null,
resource.type || 'other',
resource.address || null,
resource.city || null,
resource.phone || null,
resource.website || null,
resource.hours || null,
sourceId
]);
if (result.rows[0].inserted) {
added++;
} else {
updated++;
}
} catch (error) {
logger.error('Failed to import 211 resource', {
name: resource.name,
error: error.message
});
}
}
return { added, updated };
}
async function scrape211Alberta() {
logger.info('Starting 211 Alberta scrape');
logger.warn('Note: 211 Alberta has Cloudflare protection. API access may be required.');
const logResult = await db.query(`
INSERT INTO scrape_logs (source, status)
VALUES ('ab211', 'running')
RETURNING id
`);
const logId = logResult.rows[0].id;
let totalFound = 0;
let totalAdded = 0;
let totalUpdated = 0;
try {
for (const location of SEARCH_LOCATIONS) {
const data = await attempt211Fetch(location);
if (data && data.results) {
totalFound += data.results.length;
// Process results if we get any
logger.info(`Found ${data.results.length} results for ${location.name}`);
}
// Rate limiting
await new Promise(resolve => setTimeout(resolve, 2000));
}
await db.query(`
UPDATE scrape_logs
SET completed_at = NOW(),
status = 'completed',
records_found = $1,
records_added = $2,
records_updated = $3
WHERE id = $4
`, [totalFound, totalAdded, totalUpdated, logId]);
logger.info('211 Alberta scrape completed', {
note: 'API access likely blocked by Cloudflare',
found: totalFound
});
return { found: totalFound, added: totalAdded, updated: totalUpdated };
} catch (error) {
await db.query(`
UPDATE scrape_logs
SET completed_at = NOW(),
status = 'failed',
error_message = $1
WHERE id = $2
`, [error.message, logId]);
throw error;
}
}
// Run if called directly
if (require.main === module) {
scrape211Alberta()
.then(result => {
console.log('211 scrape attempted:', result);
process.exit(0);
})
.catch(err => {
console.error('211 scrape failed:', err);
process.exit(1);
});
}
module.exports = { scrape211Alberta, importManualData };

View File

@ -0,0 +1,397 @@
const axios = require('axios');
const cheerio = require('cheerio');
const db = require('../models/db');
const logger = require('../utils/logger');
const geocoding = require('../services/geocoding');
const BASE_URL = 'https://informalberta.ca/public/common';
const COMBO_LISTS = [
{ id: '1004954', name: 'North Zone' },
{ id: '1004953', name: 'Edmonton Zone' },
{ id: '1004951', name: 'Calgary Zone' },
{ id: '1004952', name: 'Central Zone' },
{ id: '1004903', name: 'South Zone' }
];
// Rate limiting - be respectful to the server
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function fetchPage(url) {
try {
const response = await axios.get(url, {
headers: {
'User-Agent': 'FreeAlbertaFoodBot/1.0 (contact@freealberta.org)',
'Accept': 'text/html,application/xhtml+xml'
},
timeout: 30000
});
return response.data;
} catch (error) {
logger.error(`Failed to fetch ${url}`, { error: error.message });
return null;
}
}
async function parseServicePage(serviceUrl) {
const html = await fetchPage(serviceUrl);
if (!html) return null;
const $ = cheerio.load(html);
const resource = {};
// Parse service profile page - structure may vary
resource.name = $('h1').first().text().trim() ||
$('.service-name').text().trim() ||
$('title').text().split('|')[0].trim();
// Try multiple selectors for different page structures
const addressBlock = $('.address, .location-address, [class*="address"]').first();
if (addressBlock.length) {
resource.address = addressBlock.text().replace(/\s+/g, ' ').trim();
}
// Look for phone numbers
const phoneMatch = html.match(/(\d{3}[-.\s]?\d{3}[-.\s]?\d{4})/);
if (phoneMatch) {
resource.phone = phoneMatch[1];
}
// Look for description/service info
const descriptionSelectors = [
'.service-description',
'.description',
'#description',
'.program-description',
'[class*="description"]'
];
for (const selector of descriptionSelectors) {
const desc = $(selector).first().text().trim();
if (desc && desc.length > 20) {
resource.description = desc;
break;
}
}
// Hours of operation
const hoursMatch = html.match(/hours?[:\s]*([\w\s\d:;,.-]+)/i);
if (hoursMatch) {
resource.hours_of_operation = hoursMatch[1].trim();
}
// Email
const emailMatch = html.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/);
if (emailMatch) {
resource.email = emailMatch[0];
}
// Website
const websiteMatch = html.match(/https?:\/\/[^\s"'<>]+/g);
if (websiteMatch) {
const externalSite = websiteMatch.find(url =>
!url.includes('informalberta.ca') &&
!url.includes('facebook.com') &&
url.length < 200
);
if (externalSite) {
resource.website = externalSite;
}
}
return resource;
}
async function parseSublist(sublistUrl, zone) {
const html = await fetchPage(sublistUrl);
if (!html) return [];
const $ = cheerio.load(html);
const resources = [];
// Find all service links
$('a[href*="serviceProfileStyled.do"], a[href*="serviceQueryId"]').each((_, el) => {
const href = $(el).attr('href');
const name = $(el).text().trim();
if (href && name) {
resources.push({
name,
url: href.startsWith('http') ? href : `${BASE_URL}/${href.replace('../', '')}`,
zone
});
}
});
// Also look for direct resource info in list format
$('.service-item, .resource-item, [class*="service"]').each((_, el) => {
const name = $(el).find('.name, .title, h3, h4').first().text().trim();
const address = $(el).find('.address, .location').first().text().trim();
const phone = $(el).find('.phone, .tel').first().text().trim();
if (name) {
resources.push({
name,
address,
phone,
zone,
fromList: true
});
}
});
return resources;
}
async function parseComboList(comboListId, zoneName) {
const url = `${BASE_URL}/viewComboList.do?comboListId=${comboListId}`;
const html = await fetchPage(url);
if (!html) return [];
const $ = cheerio.load(html);
const sublists = [];
// Find all sublist links
$('a[href*="viewSublist.do"], a[href*="cartId"]').each((_, el) => {
const href = $(el).attr('href');
const areaName = $(el).text().trim();
if (href && areaName) {
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}/${href.replace('../', '')}`;
sublists.push({
url: fullUrl,
area: areaName,
zone: zoneName
});
}
});
return sublists;
}
function determineResourceType(name, description = '') {
const text = `${name} ${description}`.toLowerCase();
if (text.includes('food bank')) return 'food_bank';
if (text.includes('hamper')) return 'hamper';
if (text.includes('meal') || text.includes('soup') || text.includes('kitchen')) return 'community_meal';
if (text.includes('pantry')) return 'pantry';
if (text.includes('mobile')) return 'mobile_food';
if (text.includes('grocery')) return 'grocery_program';
return 'other';
}
function extractCity(address, areaName) {
// Common Alberta cities
const cities = [
'Edmonton', 'Calgary', 'Red Deer', 'Lethbridge', 'Medicine Hat',
'Grande Prairie', 'Fort McMurray', 'Airdrie', 'Spruce Grove',
'St. Albert', 'Sherwood Park', 'Leduc', 'Camrose', 'Lloydminster',
'Cold Lake', 'Wetaskiwin', 'Okotoks', 'Cochrane', 'Brooks',
'Banff', 'Canmore', 'High River', 'Stony Plain', 'Hinton',
'Slave Lake', 'Peace River', 'Drumheller', 'Barrhead', 'Edson',
'Whitecourt', 'Taber', 'Jasper', 'Athabasca', 'Bonnyville'
];
const text = `${address} ${areaName}`;
for (const city of cities) {
if (text.toLowerCase().includes(city.toLowerCase())) {
return city;
}
}
return areaName.split(',')[0].trim() || null;
}
async function geocodeAddress(address, city) {
if (!address && !city) return null;
const fullAddress = [address, city, 'Alberta', 'Canada']
.filter(Boolean)
.join(', ');
try {
const result = await geocoding.forwardGeocode(fullAddress);
if (result && result.latitude && result.longitude) {
logger.info(`Geocoded "${fullAddress}" to ${result.latitude}, ${result.longitude}`);
return {
latitude: result.latitude,
longitude: result.longitude
};
}
} catch (error) {
logger.warn(`Failed to geocode "${fullAddress}": ${error.message}`);
}
return null;
}
async function saveResource(resource, sourceUrl) {
const sourceId = sourceUrl.match(/serviceQueryId=(\d+)/) ||
sourceUrl.match(/cartId=(\d+)/) ||
[null, `${resource.name}-${resource.city || 'unknown'}`.replace(/\s+/g, '-')];
try {
const result = await db.query(`
INSERT INTO food_resources (
name, description, resource_type,
address, city, latitude, longitude,
phone, email, website,
hours_of_operation, source, source_url, source_id,
updated_at, last_verified_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
ON CONFLICT (source, source_id)
DO UPDATE SET
name = EXCLUDED.name,
description = COALESCE(EXCLUDED.description, food_resources.description),
address = COALESCE(EXCLUDED.address, food_resources.address),
city = COALESCE(EXCLUDED.city, food_resources.city),
latitude = COALESCE(EXCLUDED.latitude, food_resources.latitude),
longitude = COALESCE(EXCLUDED.longitude, food_resources.longitude),
phone = COALESCE(EXCLUDED.phone, food_resources.phone),
email = COALESCE(EXCLUDED.email, food_resources.email),
website = COALESCE(EXCLUDED.website, food_resources.website),
hours_of_operation = COALESCE(EXCLUDED.hours_of_operation, food_resources.hours_of_operation),
updated_at = NOW(),
last_verified_at = NOW()
RETURNING id, (xmax = 0) AS inserted
`, [
resource.name,
resource.description || null,
resource.resource_type || 'other',
resource.address || null,
resource.city || null,
resource.latitude || null,
resource.longitude || null,
resource.phone || null,
resource.email || null,
resource.website || null,
resource.hours_of_operation || null,
'informalberta',
sourceUrl,
sourceId[1]
]);
return result.rows[0];
} catch (error) {
logger.error('Failed to save resource', { name: resource.name, error: error.message });
return null;
}
}
async function scrapeInformAlberta() {
logger.info('Starting InformAlberta scrape');
// Log scrape start
const logResult = await db.query(`
INSERT INTO scrape_logs (source, status)
VALUES ('informalberta', 'running')
RETURNING id
`);
const logId = logResult.rows[0].id;
let totalFound = 0;
let totalAdded = 0;
let totalUpdated = 0;
try {
for (const zone of COMBO_LISTS) {
logger.info(`Processing zone: ${zone.name}`);
const sublists = await parseComboList(zone.id, zone.name);
logger.info(`Found ${sublists.length} areas in ${zone.name}`);
for (const sublist of sublists) {
await delay(1000); // Rate limiting
const resources = await parseSublist(sublist.url, zone.name);
logger.info(`Found ${resources.length} resources in ${sublist.area}`);
for (const res of resources) {
totalFound++;
// Fetch full details if we have a URL
let fullResource = { ...res };
if (res.url && !res.fromList) {
await delay(500);
const details = await parseServicePage(res.url);
if (details) {
fullResource = { ...fullResource, ...details };
}
}
// Determine resource type and city
fullResource.resource_type = determineResourceType(
fullResource.name,
fullResource.description
);
fullResource.city = extractCity(
fullResource.address || '',
sublist.area
);
// Geocode address to get coordinates
if (fullResource.address || fullResource.city) {
await delay(1500); // Rate limit geocoding
const coords = await geocodeAddress(fullResource.address, fullResource.city);
if (coords) {
fullResource.latitude = coords.latitude;
fullResource.longitude = coords.longitude;
}
}
const saved = await saveResource(fullResource, res.url || sublist.url);
if (saved) {
if (saved.inserted) {
totalAdded++;
} else {
totalUpdated++;
}
}
}
}
}
// Update scrape log
await db.query(`
UPDATE scrape_logs
SET completed_at = NOW(),
status = 'completed',
records_found = $1,
records_added = $2,
records_updated = $3
WHERE id = $4
`, [totalFound, totalAdded, totalUpdated, logId]);
logger.info('InformAlberta scrape completed', {
found: totalFound,
added: totalAdded,
updated: totalUpdated
});
return { found: totalFound, added: totalAdded, updated: totalUpdated };
} catch (error) {
await db.query(`
UPDATE scrape_logs
SET completed_at = NOW(),
status = 'failed',
error_message = $1
WHERE id = $2
`, [error.message, logId]);
logger.error('InformAlberta scrape failed', { error: error.message });
throw error;
}
}
// Run if called directly
if (require.main === module) {
scrapeInformAlberta()
.then(result => {
console.log('Scrape completed:', result);
process.exit(0);
})
.catch(err => {
console.error('Scrape failed:', err);
process.exit(1);
});
}
module.exports = { scrapeInformAlberta };

View File

@ -0,0 +1,233 @@
const axios = require('axios');
const pdfParse = require('pdf-parse');
const db = require('../models/db');
const logger = require('../utils/logger');
const PDF_SOURCES = [
{
url: 'https://www.edmontonsfoodbank.com/documents/293/2025_April_Free_Community_Meals_4cRPMU5.pdf',
name: 'Edmonton Food Bank - Community Meals',
city: 'Edmonton'
}
];
async function downloadPDF(url) {
try {
const response = await axios.get(url, {
responseType: 'arraybuffer',
headers: {
'User-Agent': 'FreeAlbertaFoodBot/1.0'
},
timeout: 60000
});
return Buffer.from(response.data);
} catch (error) {
logger.error('Failed to download PDF', { url, error: error.message });
return null;
}
}
function parseEdmontonMealsPDF(text) {
/*
* Parse the Edmonton Food Bank community meals PDF.
* The format typically lists:
* - Location name
* - Address
* - Days/Times
* - Meal type
*
* This parser attempts to extract structured data from the text.
*/
const resources = [];
const lines = text.split('\n').map(l => l.trim()).filter(l => l);
// Common patterns in the PDF
const dayPatterns = /monday|tuesday|wednesday|thursday|friday|saturday|sunday/i;
const timePattern = /\d{1,2}:\d{2}\s*(am|pm|AM|PM)?/;
const addressPattern = /\d+\s+[\w\s]+(?:Street|St|Avenue|Ave|Road|Rd|Drive|Dr|Boulevard|Blvd)/i;
let currentResource = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip header/footer lines
if (line.includes('Free Community Meals') ||
line.includes('Edmonton') && line.includes('Food Bank') ||
line.match(/page\s+\d+/i)) {
continue;
}
// Try to identify a new resource entry
// Usually starts with the location name (no numbers at start)
if (!line.match(/^\d/) &&
!line.match(dayPatterns) &&
!line.match(timePattern) &&
line.length > 5 &&
line.length < 100) {
// Save previous resource if exists
if (currentResource && currentResource.name) {
resources.push(currentResource);
}
currentResource = {
name: line,
city: 'Edmonton',
source: 'edmonton_foodbank_pdf'
};
continue;
}
// Try to extract address
if (currentResource && line.match(addressPattern)) {
currentResource.address = line;
continue;
}
// Try to extract days/times
if (currentResource && (line.match(dayPatterns) || line.match(timePattern))) {
currentResource.hours_of_operation = currentResource.hours_of_operation
? `${currentResource.hours_of_operation}; ${line}`
: line;
}
}
// Don't forget the last resource
if (currentResource && currentResource.name) {
resources.push(currentResource);
}
return resources;
}
async function saveResource(resource, sourceUrl) {
const sourceId = `pdf-${resource.name}-${resource.city}`.replace(/\s+/g, '-').toLowerCase();
try {
const result = await db.query(`
INSERT INTO food_resources (
name, description, resource_type,
address, city, phone, hours_of_operation,
source, source_url, source_id,
updated_at, last_verified_at
) VALUES ($1, $2, 'community_meal', $3, $4, $5, $6, 'edmonton_foodbank_pdf', $7, $8, NOW(), NOW())
ON CONFLICT (source, source_id)
DO UPDATE SET
name = EXCLUDED.name,
address = COALESCE(EXCLUDED.address, food_resources.address),
hours_of_operation = COALESCE(EXCLUDED.hours_of_operation, food_resources.hours_of_operation),
updated_at = NOW()
RETURNING (xmax = 0) AS inserted
`, [
resource.name,
resource.description || 'Free community meal',
resource.address || null,
resource.city,
resource.phone || null,
resource.hours_of_operation || null,
sourceUrl,
sourceId
]);
return result.rows[0];
} catch (error) {
logger.error('Failed to save PDF resource', { name: resource.name, error: error.message });
return null;
}
}
async function parsePDFs() {
logger.info('Starting PDF parsing');
const logResult = await db.query(`
INSERT INTO scrape_logs (source, status)
VALUES ('edmonton_foodbank_pdf', 'running')
RETURNING id
`);
const logId = logResult.rows[0].id;
let totalFound = 0;
let totalAdded = 0;
let totalUpdated = 0;
try {
for (const pdfSource of PDF_SOURCES) {
logger.info(`Processing PDF: ${pdfSource.name}`);
const pdfBuffer = await downloadPDF(pdfSource.url);
if (!pdfBuffer) {
logger.warn(`Skipping ${pdfSource.name} - download failed`);
continue;
}
try {
const data = await pdfParse(pdfBuffer);
logger.info(`Extracted ${data.text.length} characters from PDF`);
const resources = parseEdmontonMealsPDF(data.text);
logger.info(`Parsed ${resources.length} resources from PDF`);
totalFound += resources.length;
for (const resource of resources) {
const saved = await saveResource(resource, pdfSource.url);
if (saved) {
if (saved.inserted) {
totalAdded++;
} else {
totalUpdated++;
}
}
}
} catch (parseError) {
logger.error('PDF parsing failed', { error: parseError.message });
}
}
await db.query(`
UPDATE scrape_logs
SET completed_at = NOW(),
status = 'completed',
records_found = $1,
records_added = $2,
records_updated = $3
WHERE id = $4
`, [totalFound, totalAdded, totalUpdated, logId]);
logger.info('PDF parsing completed', {
found: totalFound,
added: totalAdded,
updated: totalUpdated
});
return { found: totalFound, added: totalAdded, updated: totalUpdated };
} catch (error) {
await db.query(`
UPDATE scrape_logs
SET completed_at = NOW(),
status = 'failed',
error_message = $1
WHERE id = $2
`, [error.message, logId]);
throw error;
}
}
// Run if called directly
if (require.main === module) {
parsePDFs()
.then(result => {
console.log('PDF parsing completed:', result);
process.exit(0);
})
.catch(err => {
console.error('PDF parsing failed:', err);
process.exit(1);
});
}
module.exports = { parsePDFs };

View File

@ -0,0 +1,59 @@
const logger = require('../utils/logger');
const { scrapeInformAlberta } = require('./informalberta');
const { scrape211Alberta } = require('./ab211');
const { parsePDFs } = require('./pdf-parser');
async function runAllScrapers() {
logger.info('Starting all scrapers');
const results = {
informalberta: null,
ab211: null,
pdf: null
};
// Run InformAlberta scraper
try {
results.informalberta = await scrapeInformAlberta();
logger.info('InformAlberta scrape completed', results.informalberta);
} catch (error) {
logger.error('InformAlberta scrape failed', { error: error.message });
results.informalberta = { error: error.message };
}
// Run 211 Alberta scraper (may be blocked by Cloudflare)
try {
results.ab211 = await scrape211Alberta();
logger.info('211 Alberta scrape completed', results.ab211);
} catch (error) {
logger.error('211 Alberta scrape failed', { error: error.message });
results.ab211 = { error: error.message };
}
// Run PDF parser
try {
results.pdf = await parsePDFs();
logger.info('PDF parsing completed', results.pdf);
} catch (error) {
logger.error('PDF parsing failed', { error: error.message });
results.pdf = { error: error.message };
}
logger.info('All scrapers finished', results);
return results;
}
// Run if called directly
if (require.main === module) {
runAllScrapers()
.then(results => {
console.log('All scrapers completed:', JSON.stringify(results, null, 2));
process.exit(0);
})
.catch(err => {
console.error('Scraper run failed:', err);
process.exit(1);
});
}
module.exports = { runAllScrapers };

View File

@ -0,0 +1,265 @@
const db = require('../models/db');
const { forwardGeocode, clearCache } = require('../services/geocoding');
const logger = require('../utils/logger');
/**
* Batch geocode resources that need coordinates
* Can target: all missing, low confidence, or force re-geocode all
*/
async function batchGeocode(options = {}) {
const {
forceAll = false, // Re-geocode everything
minConfidence = 60, // Re-geocode if below this confidence
onlyMissing = false, // Only geocode records without coordinates
limit = null // Limit number of records to process
} = options;
logger.info('Starting batch geocoding', { forceAll, minConfidence, onlyMissing, limit });
// Ensure geocode_confidence column exists
await ensureConfidenceColumn();
// Build query based on options
let query;
if (forceAll) {
query = `
SELECT id, name, address, city, postal_code, latitude, longitude, geocode_confidence
FROM food_resources
ORDER BY geocode_confidence ASC NULLS FIRST, city, name
`;
} else if (onlyMissing) {
query = `
SELECT id, name, address, city, postal_code, latitude, longitude, geocode_confidence
FROM food_resources
WHERE latitude IS NULL OR longitude IS NULL
ORDER BY city, name
`;
} else {
query = `
SELECT id, name, address, city, postal_code, latitude, longitude, geocode_confidence
FROM food_resources
WHERE latitude IS NULL OR longitude IS NULL
OR geocode_confidence IS NULL
OR geocode_confidence < $1
ORDER BY geocode_confidence ASC NULLS FIRST, city, name
`;
}
const params = (!forceAll && !onlyMissing) ? [minConfidence] : [];
const result = await db.query(query, params);
let resources = result.rows;
if (limit) {
resources = resources.slice(0, limit);
}
logger.info(`Found ${resources.length} resources to geocode`);
let successCount = 0;
let failCount = 0;
let skippedCount = 0;
const results = [];
for (const resource of resources) {
// Build address string - prioritize full address if available
let addressToGeocode;
let hasStreetAddress = false;
if (resource.address && !resource.address.startsWith('PO Box') && resource.address.trim() !== '') {
addressToGeocode = `${resource.address}, ${resource.city}, Alberta, Canada`;
hasStreetAddress = true;
} else if (resource.postal_code && resource.postal_code.trim() !== '') {
addressToGeocode = `${resource.city}, ${resource.postal_code}, Alberta, Canada`;
} else {
addressToGeocode = `${resource.city}, Alberta, Canada`;
}
logger.info(`[${resource.id}] Geocoding "${resource.name}" in ${resource.city}`);
logger.info(` Address: "${addressToGeocode}"`);
try {
const geocodeResult = await forwardGeocode(addressToGeocode);
if (geocodeResult && geocodeResult.latitude && geocodeResult.longitude) {
const confidence = geocodeResult.combinedConfidence || geocodeResult.confidence || 50;
const provider = geocodeResult.provider || 'unknown';
const warnings = geocodeResult.validation?.warnings || [];
// Adjust confidence if no street address was provided
const adjustedConfidence = hasStreetAddress ? confidence : Math.min(confidence, 40);
await db.query(`
UPDATE food_resources
SET latitude = $1,
longitude = $2,
geocode_confidence = $3,
geocode_provider = $4,
updated_at = NOW()
WHERE id = $5
`, [geocodeResult.latitude, geocodeResult.longitude, adjustedConfidence, provider, resource.id]);
const resultInfo = {
id: resource.id,
name: resource.name,
city: resource.city,
address: resource.address,
latitude: geocodeResult.latitude,
longitude: geocodeResult.longitude,
confidence: adjustedConfidence,
provider,
warnings,
status: 'success'
};
results.push(resultInfo);
if (warnings.length > 0) {
logger.warn(` Success with warnings: ${geocodeResult.latitude}, ${geocodeResult.longitude} (${provider}, ${adjustedConfidence}%)`);
logger.warn(` Warnings: ${warnings.join(', ')}`);
} else {
logger.info(` Success: ${geocodeResult.latitude}, ${geocodeResult.longitude} (${provider}, ${adjustedConfidence}%)`);
}
successCount++;
} else {
logger.warn(` No coordinates found`);
results.push({
id: resource.id,
name: resource.name,
city: resource.city,
address: resource.address,
status: 'no_result'
});
failCount++;
}
} catch (error) {
logger.error(` Error: ${error.message}`);
results.push({
id: resource.id,
name: resource.name,
city: resource.city,
address: resource.address,
status: 'error',
error: error.message
});
failCount++;
}
// Rate limiting - wait between requests
await new Promise(resolve => setTimeout(resolve, 1500));
}
logger.info(`Batch geocoding complete: ${successCount} success, ${failCount} failed, ${skippedCount} skipped`);
return {
success: successCount,
failed: failCount,
skipped: skippedCount,
total: resources.length,
results
};
}
/**
* Ensure the geocode_confidence column exists
*/
async function ensureConfidenceColumn() {
try {
await db.query(`
ALTER TABLE food_resources
ADD COLUMN IF NOT EXISTS geocode_confidence INTEGER,
ADD COLUMN IF NOT EXISTS geocode_provider VARCHAR(50)
`);
} catch (error) {
// Column might already exist, that's fine
logger.debug('Confidence column check:', error.message);
}
}
/**
* Get geocoding statistics
*/
async function getGeocodingStats() {
const result = await db.query(`
SELECT
COUNT(*) as total,
COUNT(latitude) as geocoded,
COUNT(CASE WHEN address IS NOT NULL AND address != '' THEN 1 END) as has_address,
COUNT(CASE WHEN geocode_confidence >= 80 THEN 1 END) as high_confidence,
COUNT(CASE WHEN geocode_confidence >= 50 AND geocode_confidence < 80 THEN 1 END) as medium_confidence,
COUNT(CASE WHEN geocode_confidence < 50 OR geocode_confidence IS NULL THEN 1 END) as low_confidence,
ROUND(AVG(geocode_confidence)) as avg_confidence
FROM food_resources
`);
return result.rows[0];
}
if (require.main === module) {
// Parse CLI arguments
const args = process.argv.slice(2);
const options = {
forceAll: args.includes('--force-all'),
onlyMissing: args.includes('--only-missing'),
minConfidence: 60,
limit: null
};
// Parse --min-confidence=N
const minConfArg = args.find(a => a.startsWith('--min-confidence='));
if (minConfArg) {
options.minConfidence = parseInt(minConfArg.split('=')[1], 10);
}
// Parse --limit=N
const limitArg = args.find(a => a.startsWith('--limit='));
if (limitArg) {
options.limit = parseInt(limitArg.split('=')[1], 10);
}
// Show stats first if requested
if (args.includes('--stats')) {
getGeocodingStats()
.then(stats => {
console.log('\nGeocoding Statistics:');
console.log('=====================');
console.log(`Total records: ${stats.total}`);
console.log(`Geocoded: ${stats.geocoded}`);
console.log(`With street address: ${stats.has_address}`);
console.log(`High confidence: ${stats.high_confidence} (>=80%)`);
console.log(`Medium confidence: ${stats.medium_confidence} (50-79%)`);
console.log(`Low confidence: ${stats.low_confidence} (<50%)`);
console.log(`Average confidence: ${stats.avg_confidence || 'N/A'}%`);
process.exit(0);
})
.catch(err => {
console.error('Failed to get stats:', err);
process.exit(1);
});
} else {
console.log('Batch geocoding with options:', options);
console.log('Use --stats to see current geocoding statistics');
console.log('Use --force-all to re-geocode everything');
console.log('Use --only-missing to only geocode records without coordinates');
console.log('Use --min-confidence=N to re-geocode records below N% confidence');
console.log('Use --limit=N to limit the number of records processed');
console.log('');
// Clear cache before batch run
clearCache();
batchGeocode(options)
.then(result => {
console.log('\nGeocoding complete:');
console.log(` Success: ${result.success}`);
console.log(` Failed: ${result.failed}`);
console.log(` Total: ${result.total}`);
process.exit(0);
})
.catch(err => {
console.error('Geocoding failed:', err);
process.exit(1);
});
}
}
module.exports = { batchGeocode, getGeocodingStats, ensureConfidenceColumn };

View File

@ -0,0 +1,146 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const path = require('path');
const cron = require('node-cron');
require('dotenv').config();
const logger = require('./utils/logger');
const db = require('./models/db');
const apiRoutes = require('./routes/api');
const app = express();
const PORT = process.env.PORT || 3003;
// Trust proxy for Docker/reverse proxy environments
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
app.use(compression());
// Security middleware - disable upgrade-insecure-requests and HSTS for HTTP-only access
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://fonts.googleapis.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
connectSrc: ["'self'", "https://unpkg.com", "https://tile.openstreetmap.org", "https://*.tile.openstreetmap.org", "https://router.project-osrm.org"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://unpkg.com"],
upgradeInsecureRequests: null, // Disable for HTTP-only access
},
},
hsts: false, // Disable HSTS for HTTP-only access (will be enabled by Cloudflare tunnel)
crossOriginOpenerPolicy: false, // Disable for HTTP access
originAgentCluster: false, // Disable for consistent behavior across HTTP/Tailscale access
}));
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.path}`, {
status: res.statusCode,
duration: `${duration}ms`,
ip: req.ip
});
});
next();
});
// Static files
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0,
etag: true
}));
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
await db.query('SELECT 1');
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
} catch (error) {
logger.error('Health check failed', { error: error.message });
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// API routes
app.use('/api', apiRoutes);
// Serve main page
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Resource detail page
app.get('/resource/:id', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Weekly scraper cron job (runs every Sunday at 2 AM)
if (process.env.ENABLE_CRON === 'true') {
cron.schedule('0 2 * * 0', async () => {
logger.info('Starting weekly data scrape');
try {
const { runAllScrapers } = require('./scrapers/run-all');
await runAllScrapers();
logger.info('Weekly data scrape completed');
} catch (error) {
logger.error('Weekly data scrape failed', { error: error.message });
}
});
logger.info('Weekly cron job scheduled for Sundays at 2 AM');
}
// Error handling middleware
app.use((err, req, res, next) => {
logger.error('Application error', {
error: err.message,
stack: err.stack,
path: req.path
});
res.status(err.status || 500).json({
error: 'Something went wrong',
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down');
server.close(() => {
db.end();
process.exit(0);
});
});
const server = app.listen(PORT, () => {
logger.info('Server started', {
port: PORT,
environment: process.env.NODE_ENV
});
});
module.exports = app;

View File

@ -0,0 +1,602 @@
const axios = require('axios');
const logger = require('../utils/logger');
// Cache for geocoding results
const geocodeCache = new Map();
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
// Clean up old cache entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, value] of geocodeCache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
geocodeCache.delete(key);
}
}
}, 60 * 60 * 1000);
/**
* Alberta bounding box for validation
* Ensures geocoded points are actually in Alberta
*/
const ALBERTA_BOUNDS = {
north: 60.0,
south: 49.0,
east: -110.0,
west: -120.0
};
/**
* Multi-provider geocoding - tries providers in order until success
* Premium providers first (when API key available), then free fallbacks
*/
const GEOCODING_PROVIDERS = [
{
name: 'Mapbox',
func: geocodeWithMapbox,
enabled: () => !!process.env.MAPBOX_ACCESS_TOKEN,
options: { timeout: 10000, delay: 0 }
},
{
name: 'Nominatim',
func: geocodeWithNominatim,
enabled: () => true,
options: { timeout: 10000, delay: 1000 }
},
{
name: 'Photon',
func: geocodeWithPhoton,
enabled: () => true,
options: { timeout: 10000, delay: 500 }
},
{
name: 'ArcGIS',
func: geocodeWithArcGIS,
enabled: () => true,
options: { timeout: 10000, delay: 500 }
}
];
/**
* Geocode with Mapbox (premium provider)
*/
async function geocodeWithMapbox(address, options = {}) {
const { timeout = 10000 } = options;
const apiKey = process.env.MAPBOX_ACCESS_TOKEN;
if (!apiKey) {
throw new Error('Mapbox API key not configured');
}
logger.info(`Geocoding with Mapbox: ${address}`);
try {
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json`;
const response = await axios.get(url, {
params: {
access_token: apiKey,
limit: 1,
country: 'ca',
types: 'address,poi,place'
},
timeout
});
const data = response.data;
if (!data.features || data.features.length === 0) {
return null;
}
const result = data.features[0];
const [longitude, latitude] = result.center;
// Extract address components from context
const components = extractMapboxComponents(result);
return {
latitude,
longitude,
formattedAddress: result.place_name,
provider: 'Mapbox',
confidence: Math.round((result.relevance || 0.5) * 100),
components,
raw: result
};
} catch (error) {
logger.error('Mapbox geocoding error:', error.message);
throw error;
}
}
/**
* Geocode with Nominatim (OpenStreetMap)
*/
async function geocodeWithNominatim(address, options = {}) {
const { timeout = 10000, delay = 1000 } = options;
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
const url = `https://nominatim.openstreetmap.org/search`;
logger.info(`Geocoding with Nominatim: ${address}`);
try {
const response = await axios.get(url, {
params: {
format: 'json',
q: address,
limit: 1,
addressdetails: 1,
countrycodes: 'ca'
},
headers: {
'User-Agent': 'FreeAlbertaFood/1.0 (https://freealberta.org)'
},
timeout
});
const data = response.data;
if (!data || data.length === 0) {
return null;
}
const result = data[0];
return {
latitude: parseFloat(result.lat),
longitude: parseFloat(result.lon),
formattedAddress: result.display_name,
provider: 'Nominatim',
confidence: calculateNominatimConfidence(result),
components: extractAddressComponents(result.address || {}),
raw: result
};
} catch (error) {
logger.error('Nominatim geocoding error:', error.message);
throw error;
}
}
/**
* Geocode with Photon (OpenStreetMap-based)
*/
async function geocodeWithPhoton(address, options = {}) {
const { timeout = 10000, delay = 500 } = options;
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
logger.info(`Geocoding with Photon: ${address}`);
try {
const response = await axios.get('https://photon.komoot.io/api/', {
params: {
q: address,
limit: 1,
lang: 'en'
},
timeout
});
if (!response.data?.features || response.data.features.length === 0) {
return null;
}
const feature = response.data.features[0];
const coords = feature.geometry.coordinates;
const props = feature.properties;
return {
latitude: coords[1],
longitude: coords[0],
formattedAddress: buildFormattedAddress(props),
provider: 'Photon',
confidence: calculatePhotonConfidence(feature),
components: extractPhotonComponents(props),
raw: feature
};
} catch (error) {
logger.error('Photon geocoding error:', error.message);
throw error;
}
}
/**
* Geocode with ArcGIS World Geocoding Service (free tier)
*/
async function geocodeWithArcGIS(address, options = {}) {
const { timeout = 10000, delay = 500 } = options;
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
logger.info(`Geocoding with ArcGIS: ${address}`);
try {
const response = await axios.get('https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates', {
params: {
SingleLine: address,
f: 'json',
outFields: '*',
maxLocations: 1,
countryCode: 'CA'
},
timeout
});
if (!response.data?.candidates || response.data.candidates.length === 0) {
return null;
}
const candidate = response.data.candidates[0];
const location = candidate.location;
const attributes = candidate.attributes;
return {
latitude: location.y,
longitude: location.x,
formattedAddress: attributes.LongLabel || candidate.address,
provider: 'ArcGIS',
confidence: candidate.score || 50,
components: extractArcGISComponents(attributes),
raw: candidate
};
} catch (error) {
logger.error('ArcGIS geocoding error:', error.message);
throw error;
}
}
/**
* Validate that coordinates are within Alberta
*/
function isInAlberta(lat, lng) {
return lat >= ALBERTA_BOUNDS.south &&
lat <= ALBERTA_BOUNDS.north &&
lng >= ALBERTA_BOUNDS.west &&
lng <= ALBERTA_BOUNDS.east;
}
/**
* Validate geocoding result against original address
*/
function validateGeocodeResult(originalAddress, result) {
const validation = {
isValid: true,
confidence: result.confidence || 50,
warnings: []
};
if (!result || !result.latitude || !result.longitude) {
validation.isValid = false;
validation.confidence = 0;
return validation;
}
// Check if result is in Alberta
if (!isInAlberta(result.latitude, result.longitude)) {
validation.warnings.push('Result is outside Alberta');
validation.confidence -= 50;
validation.isValid = false;
}
// Check for street number match if original has one
const originalNumber = originalAddress.match(/^(\d+)/);
if (originalNumber && result.components) {
if (!result.components.house_number) {
validation.warnings.push('Street number not found in result');
validation.confidence -= 25;
} else if (result.components.house_number !== originalNumber[1]) {
validation.warnings.push('Street number mismatch');
validation.confidence -= 30;
}
}
// Penalize results that are just city-level (no street)
if (result.components && !result.components.road && !result.components.house_number) {
validation.warnings.push('Result is city-level only, not street address');
validation.confidence -= 20;
}
validation.confidence = Math.max(validation.confidence, 0);
validation.isValid = validation.confidence >= 30;
return validation;
}
/**
* Forward geocode address to coordinates
*/
async function forwardGeocode(address) {
if (!address || typeof address !== 'string' || address.trim().length === 0) {
throw new Error('Invalid address');
}
address = address.trim();
const cacheKey = `addr:${address.toLowerCase()}`;
// Check cache
const cached = geocodeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
logger.debug(`Geocoding cache hit for ${address}`);
return cached.data;
}
// Build address variations for Alberta addresses
const variations = buildAddressVariations(address);
let bestResult = null;
let bestValidation = null;
let bestScore = 0;
for (const provider of GEOCODING_PROVIDERS) {
if (!provider.enabled()) continue;
logger.info(`Trying provider: ${provider.name}`);
for (const variation of variations) {
try {
const result = await provider.func(variation, provider.options);
if (!result) continue;
// Validate the result
const validation = validateGeocodeResult(address, result);
const score = (result.confidence + validation.confidence) / 2;
logger.debug(`${provider.name} result: confidence=${result.confidence}, validation=${validation.confidence}, score=${score}`);
if (score > bestScore) {
bestResult = result;
bestValidation = validation;
bestScore = score;
// If we have a high-confidence match, return immediately
if (score >= 85 && validation.isValid) {
bestResult.validation = validation;
bestResult.combinedConfidence = score;
geocodeCache.set(cacheKey, { data: bestResult, timestamp: Date.now() });
logger.info(`High-confidence result from ${provider.name}: ${score}`);
return bestResult;
}
}
} catch (error) {
logger.warn(`${provider.name} failed for "${variation}": ${error.message}`);
}
}
// If we have a good result from this provider, stop trying more
if (bestScore >= 70) {
logger.info(`Good result from ${provider.name}, stopping search`);
break;
}
}
if (bestResult) {
bestResult.validation = bestValidation;
bestResult.combinedConfidence = bestScore;
geocodeCache.set(cacheKey, { data: bestResult, timestamp: Date.now() });
logger.info(`Best result: ${bestResult.provider} with score ${bestScore}`);
return bestResult;
}
throw new Error('Could not geocode address');
}
/**
* Build address variations for geocoding attempts
*/
function buildAddressVariations(address) {
const variations = new Set();
// Original address
variations.add(address);
// Add Alberta/Canada if not present
if (!address.toLowerCase().includes('alberta') && !address.toLowerCase().includes(', ab')) {
variations.add(`${address}, Alberta, Canada`);
variations.add(`${address}, AB, Canada`);
}
// Expand quadrant abbreviations (common in Calgary/Edmonton)
const quadrantExpansions = {
' NW': ' Northwest',
' NE': ' Northeast',
' SW': ' Southwest',
' SE': ' Southeast'
};
for (const [abbrev, full] of Object.entries(quadrantExpansions)) {
if (address.toUpperCase().includes(abbrev)) {
variations.add(address.replace(new RegExp(abbrev, 'gi'), full));
}
if (address.includes(full)) {
variations.add(address.replace(new RegExp(full, 'gi'), abbrev.trim()));
}
}
// Expand/contract street type abbreviations
const streetTypes = {
' St ': ' Street ',
' St.': ' Street',
' Ave ': ' Avenue ',
' Ave.': ' Avenue',
' Rd ': ' Road ',
' Rd.': ' Road',
' Dr ': ' Drive ',
' Dr.': ' Drive',
' Cres ': ' Crescent ',
' Cres.': ' Crescent',
' Blvd ': ' Boulevard ',
' Blvd.': ' Boulevard'
};
for (const [abbrev, full] of Object.entries(streetTypes)) {
if (address.includes(abbrev)) {
variations.add(address.replace(abbrev, full));
}
if (address.includes(full)) {
variations.add(address.replace(full, abbrev.replace('.', '')));
}
}
return Array.from(variations).slice(0, 6); // Limit to 6 variations
}
/**
* Reverse geocode coordinates to address
*/
async function reverseGeocode(lat, lng) {
const cacheKey = `rev:${lat.toFixed(6)},${lng.toFixed(6)}`;
const cached = geocodeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const response = await axios.get('https://nominatim.openstreetmap.org/reverse', {
params: {
format: 'json',
lat,
lon: lng,
zoom: 18,
addressdetails: 1
},
headers: {
'User-Agent': 'FreeAlbertaFood/1.0 (https://freealberta.org)'
},
timeout: 10000
});
const result = {
formattedAddress: response.data.display_name,
components: extractAddressComponents(response.data.address || {}),
latitude: parseFloat(response.data.lat),
longitude: parseFloat(response.data.lon)
};
geocodeCache.set(cacheKey, { data: result, timestamp: Date.now() });
return result;
} catch (error) {
logger.error('Reverse geocoding error:', error.message);
throw error;
}
}
// Helper functions
function extractAddressComponents(address) {
return {
house_number: address.house_number || '',
road: address.road || '',
suburb: address.suburb || address.neighbourhood || '',
city: address.city || address.town || address.village || '',
state: address.state || address.province || '',
postcode: address.postcode || '',
country: address.country || ''
};
}
function extractPhotonComponents(props) {
return {
house_number: props.housenumber || '',
road: props.street || '',
suburb: props.district || '',
city: props.city || '',
state: props.state || '',
postcode: props.postcode || '',
country: props.country || ''
};
}
function extractMapboxComponents(result) {
const components = {
house_number: '',
road: '',
suburb: '',
city: '',
state: '',
postcode: '',
country: ''
};
if (result.context && Array.isArray(result.context)) {
result.context.forEach(item => {
const id = item.id || '';
if (id.startsWith('postcode.')) components.postcode = item.text;
else if (id.startsWith('place.')) components.city = item.text;
else if (id.startsWith('region.')) components.state = item.text;
else if (id.startsWith('country.')) components.country = item.text;
else if (id.startsWith('neighborhood.')) components.suburb = item.text;
});
}
// Extract house number and street from place_name
if (result.place_name) {
const match = result.place_name.match(/^(\d+[A-Za-z]?)\s+(.+?),/);
if (match) {
components.house_number = match[1];
components.road = match[2];
}
}
return components;
}
function extractArcGISComponents(attributes) {
return {
house_number: attributes.AddNum || '',
road: attributes.StName || '',
suburb: attributes.District || '',
city: attributes.City || '',
state: attributes.Region || '',
postcode: attributes.Postal || '',
country: attributes.Country || ''
};
}
function buildFormattedAddress(props) {
const parts = [];
if (props.housenumber) parts.push(props.housenumber);
if (props.street) parts.push(props.street);
if (props.city) parts.push(props.city);
if (props.state) parts.push(props.state);
if (props.postcode) parts.push(props.postcode);
return parts.join(', ');
}
function calculateNominatimConfidence(data) {
let confidence = 100;
if (!data.address?.house_number) confidence -= 20;
if (!data.address?.road) confidence -= 30;
if (data.type === 'administrative') confidence -= 25;
return Math.max(confidence, 10);
}
function calculatePhotonConfidence(feature) {
let confidence = 100;
const props = feature.properties;
if (!props.housenumber) confidence -= 20;
if (!props.street) confidence -= 30;
return Math.max(confidence, 10);
}
function getCacheStats() {
return { size: geocodeCache.size, ttl: CACHE_TTL };
}
function clearCache() {
geocodeCache.clear();
}
module.exports = {
forwardGeocode,
reverseGeocode,
getCacheStats,
clearCache
};

View File

@ -0,0 +1,262 @@
const axios = require('axios');
const logger = require('../utils/logger');
// OSRM public demo server (free, rate-limited)
// For production, consider self-hosting OSRM or using a paid service
const OSRM_BASE_URL = 'https://router.project-osrm.org';
// Cache for routing results
const routeCache = new Map();
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
/**
* Get driving directions between two points using OSRM
* @param {number} startLat - Starting latitude
* @param {number} startLng - Starting longitude
* @param {number} endLat - Destination latitude
* @param {number} endLng - Destination longitude
* @param {string} profile - Routing profile: 'driving', 'walking', 'cycling'
* @returns {Promise<Object>} Route with geometry and turn-by-turn instructions
*/
async function getDirections(startLat, startLng, endLat, endLng, profile = 'driving') {
const cacheKey = `${startLat.toFixed(5)},${startLng.toFixed(5)}-${endLat.toFixed(5)},${endLng.toFixed(5)}-${profile}`;
// Check cache
const cached = routeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
logger.debug('Route cache hit');
return cached.data;
}
try {
// OSRM expects coordinates as lng,lat (GeoJSON order)
const coordinates = `${startLng},${startLat};${endLng},${endLat}`;
const response = await axios.get(
`${OSRM_BASE_URL}/route/v1/${profile}/${coordinates}`,
{
params: {
overview: 'full', // Get full route geometry
geometries: 'geojson', // Return as GeoJSON
steps: true, // Include turn-by-turn steps
annotations: 'duration,distance' // Include duration and distance
},
timeout: 15000,
headers: {
'User-Agent': 'FreeAlbertaFood/1.0'
}
}
);
if (response.data.code !== 'Ok') {
throw new Error(`OSRM error: ${response.data.code}`);
}
const route = response.data.routes[0];
const result = {
distance: route.distance, // meters
duration: route.duration, // seconds
distanceText: formatDistance(route.distance),
durationText: formatDuration(route.duration),
geometry: route.geometry, // GeoJSON LineString
steps: formatSteps(route.legs[0].steps),
bounds: calculateBounds(route.geometry.coordinates),
waypoints: response.data.waypoints.map(wp => ({
name: wp.name || 'Unknown',
location: [wp.location[1], wp.location[0]] // Convert to [lat, lng]
}))
};
// Cache result
routeCache.set(cacheKey, { data: result, timestamp: Date.now() });
return result;
} catch (error) {
logger.error('Routing error:', error.message);
if (error.code === 'ECONNABORTED') {
throw new Error('Routing service timeout');
}
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.');
}
throw new Error('Could not calculate route');
}
}
/**
* Format turn-by-turn steps with icons and instructions
*/
function formatSteps(steps) {
return steps.map((step, index) => {
const maneuver = step.maneuver;
return {
number: index + 1,
instruction: formatInstruction(step),
distance: step.distance,
duration: step.duration,
distanceText: formatDistance(step.distance),
durationText: formatDuration(step.duration),
name: step.name || 'unnamed road',
mode: step.mode,
maneuver: {
type: maneuver.type,
modifier: maneuver.modifier || null,
location: [maneuver.location[1], maneuver.location[0]], // [lat, lng]
bearingBefore: maneuver.bearing_before,
bearingAfter: maneuver.bearing_after,
icon: getManeuverIcon(maneuver.type, maneuver.modifier)
},
geometry: step.geometry
};
});
}
/**
* Generate human-readable instruction from step
*/
function formatInstruction(step) {
const maneuver = step.maneuver;
const name = step.name || 'the road';
const distance = formatDistance(step.distance);
switch (maneuver.type) {
case 'depart':
return `Start on ${name} heading ${getCardinalDirection(maneuver.bearing_after)}`;
case 'arrive':
return 'Arrive at your destination';
case 'turn':
return `Turn ${maneuver.modifier || ''} onto ${name}`;
case 'continue':
return `Continue on ${name} for ${distance}`;
case 'merge':
return `Merge ${maneuver.modifier || ''} onto ${name}`;
case 'on ramp':
case 'off ramp':
return `Take the ramp onto ${name}`;
case 'fork':
return `Keep ${maneuver.modifier || 'straight'} at the fork onto ${name}`;
case 'end of road':
return `At the end of the road, turn ${maneuver.modifier || ''} onto ${name}`;
case 'new name':
return `Continue onto ${name}`;
case 'roundabout':
const exit = step.maneuver.exit || '';
return `Enter the roundabout and take exit ${exit} onto ${name}`;
case 'rotary':
return `Enter the rotary and take the exit onto ${name}`;
case 'roundabout turn':
return `At the roundabout, take the ${maneuver.modifier || ''} exit onto ${name}`;
case 'notification':
return step.name || 'Continue';
default:
if (maneuver.modifier) {
return `Go ${maneuver.modifier} on ${name}`;
}
return `Continue on ${name}`;
}
}
/**
* Get icon class for maneuver type
*/
function getManeuverIcon(type, modifier) {
const icons = {
'depart': 'start',
'arrive': 'destination',
'turn': modifier?.includes('left') ? 'turn-left' : modifier?.includes('right') ? 'turn-right' : 'straight',
'continue': 'straight',
'merge': modifier?.includes('left') ? 'merge-left' : 'merge-right',
'on ramp': 'ramp',
'off ramp': 'ramp',
'fork': modifier?.includes('left') ? 'fork-left' : 'fork-right',
'end of road': modifier?.includes('left') ? 'turn-left' : 'turn-right',
'roundabout': 'roundabout',
'rotary': 'roundabout'
};
return icons[type] || 'straight';
}
/**
* Get cardinal direction from bearing
*/
function getCardinalDirection(bearing) {
const directions = ['north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest'];
const index = Math.round(bearing / 45) % 8;
return directions[index];
}
/**
* Format distance in metric/imperial
*/
function formatDistance(meters) {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(1)} km`;
}
/**
* Format duration in human readable format
*/
function formatDuration(seconds) {
if (seconds < 60) {
return `${Math.round(seconds)} sec`;
}
if (seconds < 3600) {
const mins = Math.round(seconds / 60);
return `${mins} min`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
return `${hours} hr ${mins} min`;
}
/**
* Calculate bounding box for route
*/
function calculateBounds(coordinates) {
let minLat = Infinity, maxLat = -Infinity;
let minLng = Infinity, maxLng = -Infinity;
coordinates.forEach(coord => {
const [lng, lat] = coord;
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLng = Math.min(minLng, lng);
maxLng = Math.max(maxLng, lng);
});
return [[minLat, minLng], [maxLat, maxLng]];
}
/**
* Clear routing cache
*/
function clearCache() {
routeCache.clear();
}
module.exports = {
getDirections,
formatDistance,
formatDuration,
clearCache
};

View File

@ -0,0 +1,31 @@
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'freealberta-food' },
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
})
]
});
if (process.env.NODE_ENV === 'production') {
logger.add(new winston.transports.File({
filename: 'logs/error.log',
level: 'error'
}));
logger.add(new winston.transports.File({
filename: 'logs/combined.log'
}));
}
module.exports = logger;

View File

@ -0,0 +1,80 @@
services:
app:
build:
context: ./app
dockerfile: Dockerfile
container_name: freealberta-food-app
ports:
- "${FOOD_PORT:-3003}:3003"
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=3003
- DB_HOST=food-db
- DB_PORT=5432
- DB_NAME=freealberta_food
- ENABLE_CRON=true
volumes:
- ./app:/usr/src/app
- /usr/src/app/node_modules
- food-logs:/usr/src/app/logs
restart: always
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3003/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
networks:
- changemaker-lite-freealberta
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
food-db:
condition: service_healthy
food-db:
image: postgres:17-alpine
container_name: freealberta-food-db
restart: unless-stopped
environment:
POSTGRES_USER: ${FOOD_DB_USER:-foodadmin}
POSTGRES_PASSWORD: ${FOOD_DB_PASSWORD:-changeme}
POSTGRES_DB: freealberta_food
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${FOOD_DB_USER:-foodadmin} -d freealberta_food"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- food-db-data:/var/lib/postgresql/data
networks:
- changemaker-lite-freealberta
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
volumes:
food-logs:
driver: local
food-db-data:
driver: local
networks:
changemaker-lite-freealberta:
name: changemaker-lite-freealberta_changemaker-lite-freealberta
external: true

View File

@ -0,0 +1,28 @@
services:
lander:
image: nginx:alpine
container_name: freealberta-lander
ports:
- "${LANDER_PORT:-3020}:80"
volumes:
- ./public:/usr/share/nginx/html:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- changemaker-lite-freealberta
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
networks:
changemaker-lite-freealberta:
name: changemaker-lite-freealberta_changemaker-lite-freealberta
external: true

View File

@ -0,0 +1,36 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
# Main location
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

View File

@ -0,0 +1,358 @@
/* Free Alberta Landing Page Styles - Matching MkDocs Theme */
:root {
/* Light theme - matching MkDocs Material */
--md-primary-fg-color: #2196f3;
--md-primary-fg-color--light: #42a5f5;
--md-primary-fg-color--dark: #1976d2;
--md-primary-bg-color: #ffffff;
--md-accent-fg-color: #526cfe;
--md-accent-bg-color: #ffffff;
--md-typeset-color: rgba(0, 0, 0, 0.87);
--md-code-bg-color: #f5f5f5;
/* Custom colors */
--shadow-color: rgba(0, 0, 0, 0.15);
--shadow-color-heavy: rgba(0, 0, 0, 0.25);
}
[data-theme="dark"] {
--md-primary-fg-color: #2196f3;
--md-primary-fg-color--light: #42a5f5;
--md-primary-fg-color--dark: #1976d2;
--md-primary-bg-color: #1e1e1e;
--md-accent-fg-color: #526cfe;
--md-accent-bg-color: #1e1e1e;
--md-typeset-color: rgba(255, 255, 255, 0.87);
--md-code-bg-color: #2d2d2d;
--shadow-color: rgba(0, 0, 0, 0.25);
--shadow-color-heavy: rgba(0, 0, 0, 0.4);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--md-primary-bg-color);
color: var(--md-typeset-color);
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Hero Section - Matching MkDocs welcome-message */
.hero {
text-align: center;
padding: 4rem 2rem;
perspective: 1000px;
margin-bottom: 2rem;
}
.logo-container {
margin-bottom: 2rem;
}
.logo {
max-width: 500px;
width: 100%;
height: auto;
}
/* Tagline with MkDocs gradient animation */
.tagline {
font-size: clamp(1.8rem, 5vw, 3.5rem);
background: linear-gradient(
45deg,
#2196f3 10%,
#64b5f6 20%,
#90caf9 30%,
#bbdefb 40%,
#90caf9 50%,
#64b5f6 60%,
#2196f3 70%
);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-transform: uppercase;
font-weight: 900;
letter-spacing: 4px;
margin-bottom: 1.5rem;
position: relative;
animation: shine 8s linear infinite;
}
@keyframes shine {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
.intro {
font-size: 1.5rem;
color: var(--md-typeset-color);
max-width: 800px;
margin: 0 auto;
line-height: 1.6;
}
/* Action Cards - Matching MkDocs grid-item styling */
.actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
padding: 1.5rem;
max-width: 1200px;
margin: 2rem auto;
flex-grow: 1;
align-content: center;
}
.action-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
min-height: 200px;
background: var(--md-primary-fg-color--light);
color: var(--md-primary-bg-color);
text-decoration: none;
border-radius: 10px;
text-align: center;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease, background-color 0.3s ease;
box-shadow:
0 8px 15px var(--shadow-color),
0 12px 25px rgba(0,0,0,0.1),
inset 0 -4px 0px #1565C0,
inset 0 -8px 0px #0D47A1;
}
.action-card:hover {
transform: translateY(-5px);
background-color: #2196f3;
box-shadow:
0 16px 30px rgba(0,0,0,0.2),
0 20px 40px rgba(0,0,0,0.15),
inset 0 -4px 0px #1565C0,
inset 0 -8px 0px #0D47A1;
}
/* Dark mode card styling */
[data-theme="dark"] .action-card {
box-shadow:
0 8px 15px var(--shadow-color-heavy),
0 12px 25px rgba(0,0,0,0.2),
inset 0 -4px 0px #0D47A1,
inset 0 -8px 0px #052555;
}
[data-theme="dark"] .action-card:hover {
box-shadow:
0 16px 30px rgba(0,0,0,0.3),
0 20px 40px rgba(0,0,0,0.25),
inset 0 -4px 0px #0D47A1,
inset 0 -8px 0px #052555;
}
.card-icon {
width: 60px;
height: 60px;
margin-bottom: 1rem;
}
.card-icon svg {
width: 100%;
height: 100%;
}
.action-card h2 {
color: var(--md-primary-bg-color);
margin-top: 0;
font-size: 1.6rem;
font-weight: 700;
}
.action-card p {
color: var(--md-primary-bg-color);
opacity: 0.9;
flex-grow: 1;
margin: 0.75rem 0;
font-size: 0.95rem;
line-height: 1.4;
}
/* Footer */
.footer {
text-align: center;
padding: 2rem 1rem;
flex-shrink: 0;
position: relative;
}
.footer-links {
margin-bottom: 1rem;
}
/* CTA Button - Matching MkDocs cta-button */
.docs-link {
display: inline-block;
padding: 0.6rem 1.2rem;
margin-top: 0.75rem;
background-color: var(--md-primary-bg-color);
color: var(--md-primary-fg-color) !important;
border-radius: 4px;
text-decoration: none;
font-weight: bold;
transition: background-color 0.2s ease, transform 0.2s ease, color 0.2s ease;
border: 2px solid var(--md-primary-fg-color);
}
.docs-link:hover {
background-color: var(--md-accent-fg-color);
color: var(--md-accent-bg-color) !important;
border-color: var(--md-accent-fg-color);
transform: translateY(-2px);
}
[data-theme="dark"] .docs-link {
background-color: var(--md-code-bg-color);
}
[data-theme="dark"] .docs-link:hover {
background-color: var(--md-accent-fg-color);
color: white !important;
}
.footer-text {
font-style: italic;
color: var(--md-typeset-color);
opacity: 0.7;
font-size: 0.95rem;
margin-top: 1rem;
}
/* Theme Toggle */
.theme-toggle {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
background: var(--md-code-bg-color);
color: var(--md-typeset-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px var(--shadow-color);
transition: transform 0.2s ease, background-color 0.3s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
}
.theme-toggle svg {
width: 24px;
height: 24px;
}
.sun-icon {
display: none;
}
.moon-icon {
display: block;
}
[data-theme="dark"] .sun-icon {
display: block;
}
[data-theme="dark"] .moon-icon {
display: none;
}
/* Responsive */
@media (max-width: 1023px) {
.actions {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.hero {
padding: 2rem 0.5rem;
}
.logo {
max-width: 300px;
}
.intro {
font-size: 1rem;
padding: 0 0.5rem;
margin: 0.5rem 0;
}
.actions {
gap: 0.5rem;
padding: 0;
width: 100%;
margin: 0.5rem 0;
}
.action-card {
min-height: 180px;
padding: 1rem;
border-radius: 4px;
}
.card-icon {
width: 48px;
height: 48px;
}
.action-card h2 {
font-size: 1.4rem;
}
.theme-toggle {
bottom: 1rem;
right: 1rem;
width: 44px;
height: 44px;
}
}
@media (max-width: 480px) {
.tagline {
font-size: 1.4rem;
letter-spacing: 1px;
}
.logo {
max-width: 250px;
}
}

View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Free Alberta - Redefining Freedom in Alberta. Access food resources, policy information, and influence mapping.">
<title>Free Alberta - Redefining Freedom</title>
<link rel="stylesheet" href="css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700;900&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<header class="hero">
<div class="logo-container">
<img src="assets/freealberta-logo-long-opaque.gif" alt="Free Alberta Logo" class="logo">
</div>
<h1 class="tagline">Redefining Freedom in Alberta</h1>
<p class="intro">
Because real freedom means everyone has access to what they need.
Not just the wealthy. Not just the connected. Everyone.
</p>
</header>
<main class="actions">
<a href="https://food.freealberta.org" class="action-card food">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
</div>
<h2>Find Food</h2>
<p>Because no one should go hungry in Canada's richest province</p>
</a>
<a href="https://policy.freealberta.org" class="action-card policy">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
</svg>
</div>
<h2>Policy & Action</h2>
<p>Real change requires real demands. See what we're fighting for.</p>
</a>
<a href="https://influence.freealberta.org" class="action-card influence">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93s3.05-7.44 7-7.93v15.86zm2-15.86c1.03.13 2 .45 2.87.93H13v-.93zM13 7h5.24c.25.31.48.65.68 1H13V7zm0 3h6.74c.08.33.15.66.19 1H13v-1zm0 9.93V19h2.87c-.87.48-1.84.8-2.87.93zM18.24 17H13v-1h5.92c-.2.35-.43.69-.68 1zm1.5-3H13v-1h6.93c-.04.34-.11.67-.19 1z"/>
</svg>
</div>
<h2>Influence</h2>
<p>Write to those pulling the strings in Alberta politics</p>
</a>
</main>
<footer class="footer">
<div class="footer-links">
<a href="https://policy.freealberta.org" class="docs-link">
Learn More About Our Mission
</a>
</div>
<p class="footer-text">
Man is only as free as his stomach is hungry.
</p>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg>
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/>
</svg>
</button>
</footer>
</div>
<script>
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
// Load saved theme preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
</script>
</body>
</html>

View File

@ -35,7 +35,7 @@ services:
cpus: '0.5'
memory: 512M
networks:
- changemakerlite_changemaker-lite
- changemaker-lite-freealberta_changemaker-lite-freealberta
logging:
driver: "json-file"
options:
@ -49,5 +49,5 @@ volumes:
driver: local
networks:
changemakerlite_changemaker-lite:
changemaker-lite-freealberta_changemaker-lite-freealberta:
external: true

View File

@ -1,148 +0,0 @@
# BNKops Influence Campaign Tool - Environment Configuration Example
# Copy this file to .env and update with your actual values
# NocoDB Configuration
# Your NocoDB instance URL and API configuration
NOCODB_URL=https://your-nocodb-instance.com
NOCODB_API_URL=https://your-nocodb-instance.com/api/v1
NOCODB_API_TOKEN=your_nocodb_api_token_here
NOCODB_PROJECT_ID=your_project_id
# SMTP Configuration
# Configure your email service provider settings. See below for development mode smtp
# SMTP_HOST=smtp.your-provider.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@domain.com
# SMTP_PASS=your_email_password_or_app_password
# SMTP_FROM_EMAIL=your-sender@domain.com
# SMTP_FROM_NAME="Your Campaign Name"
# Listmonk Configuration (Email List Management)
# Enable to sync campaign participants to Listmonk email lists
LISTMONK_API_URL=http://listmonk_app:9000/api
LISTMONK_USERNAME=API
LISTMONK_PASSWORD=your_listmonk_api_password
LISTMONK_SYNC_ENABLED=false
LISTMONK_INITIAL_SYNC=false
# Admin Configuration
# Set a strong password for admin access
ADMIN_PASSWORD=change_this_to_a_strong_password
# Represent API Configuration
# Canadian electoral data API (usually no changes needed)
REPRESENT_API_BASE=https://represent.opennorth.ca
REPRESENT_API_RATE_LIMIT=60
# App Configuration
# Your application URL and basic settings
APP_NAME="BNKops Influence"
APP_URL=http://localhost:3333
BASE_URL=http://localhost:3333
PORT=3333
SESSION_SECRET=generate_a_long_random_string_here_at_least_64_characters_long
NODE_ENV=development
# Email Testing Configuration
# IMPORTANT: Set to true for development/testing, false for production
EMAIL_TEST_MODE=true
TEST_EMAIL_RECIPIENT=your-test-email@domain.com
# Email Verification Configuration
# For email-to-campaign conversion feature
EMAIL_VERIFICATION_ENABLED=true
EMAIL_VERIFICATION_EXPIRY=24
# NocoDB Table IDs
# These will be auto-generated when you run build-nocodb.sh
# DO NOT modify these manually - they are set by the setup script
NOCODB_TABLE_REPRESENTATIVES=
NOCODB_TABLE_EMAILS=
NOCODB_TABLE_POSTAL_CODES=
NOCODB_TABLE_CAMPAIGN_EMAILS=
NOCODB_TABLE_CAMPAIGNS=
NOCODB_TABLE_USERS=
NOCODB_TABLE_CALLS=
NOCODB_TABLE_REPRESENTATIVE_RESPONSES=
NOCODB_TABLE_RESPONSE_UPVOTES=
NOCODB_TABLE_EMAIL_VERIFICATIONS=
# Redis Configuration (for email queue and caching)
# Uses centralized Redis from root docker-compose.yml
REDIS_HOST=redis-changemaker
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Backup Configuration
BACKUP_RETENTION_DAYS=30
BACKUP_ENCRYPTION_KEY=generate_a_strong_encryption_key_here
BACKUP_BASE_DIR=/path/to/backups
USE_S3_BACKUP=false
S3_BACKUP_BUCKET=
S3_BACKUP_PREFIX=influence-backups
REMOVE_LOCAL_AFTER_S3=false
# Monitoring Configuration (optional)
GRAFANA_ADMIN_PASSWORD=change_this_for_production
# Optional: Development Mode Settings
# Uncomment and modify these for local development with centralized MailHog
# MailHog runs from root docker-compose.yml as a shared service
SMTP_HOST=mailhog-changemaker
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM_EMAIL=dev@albertainfluence.local
SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
# Security Notes:
# - Keep your .env file secure and never commit it to version control
# - Use strong, unique passwords for ADMIN_PASSWORD
# - Generate a secure random string for SESSION_SECRET (64+ characters)
# - For production, ensure EMAIL_TEST_MODE=false and HTTPS=true
# - Use app passwords or API keys for SMTP_PASS, not your main email password
# - Rotate all secrets regularly (every 90 days recommended)
# Generate Secure Secrets:
# SESSION_SECRET: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# BACKUP_ENCRYPTION_KEY: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Common SMTP Provider Examples:
#
# Gmail:
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your_app_password
#
# ProtonMail:
# SMTP_HOST=smtp.protonmail.ch
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@protonmail.com
# SMTP_PASS=your_app_password
#
# Outlook/Hotmail:
# SMTP_HOST=smtp-mail.outlook.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@outlook.com
# SMTP_PASS=your_app_password
#
# SendGrid:
# SMTP_HOST=smtp.sendgrid.net
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=apikey
# SMTP_PASS=your_sendgrid_api_key
#
# AWS SES:
# SMTP_HOST=email-smtp.us-east-1.amazonaws.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your_aws_smtp_username
# SMTP_PASS=your_aws_smtp_password

View File

@ -3,6 +3,9 @@ site_description: Free Alberta is a repository of information and tools for the
site_url: https://freealberta.org
site_author: Free Alberta
site_dir: site
repo_url: https://git.freealberta.org/admin/freealberta
repo_name: freealberta
edit_uri: src/branch/main/mkdocs/docs
theme:
name: material
@ -31,6 +34,8 @@ theme:
- navigation.indexes
- toc.integrate
- content.code.copy
- content.action.edit
- content.action.view
- navigation.path
- navigation.top
- navigation.footer
@ -69,7 +74,7 @@ extra:
generator: false
social:
- icon: material/web
link: https://changemaker.bnkops.com
link: https://freealberta.org
plugins:
- social

View File

@ -269,6 +269,18 @@
<div class="md-header__source">
<a href="https://git.freealberta.org/admin/freealberta" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
freealberta
</div>
</a>
</div>
</nav>
</header>
@ -564,6 +576,18 @@
Free Alberta
</label>
<div class="md-nav__source">
<a href="https://git.freealberta.org/admin/freealberta" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
freealberta
</div>
</a>
</div>
<ul class="md-nav__list" data-md-scrollfix>
@ -2682,7 +2706,7 @@
<a href="https://changemaker.bnkops.com" target="_blank" rel="noopener" title="changemaker.bnkops.com" class="md-social__link">
<a href="https://freealberta.org" target="_blank" rel="noopener" title="freealberta.org" class="md-social__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2"/></svg>
</a>
@ -2703,7 +2727,7 @@
<script id="__config" type="application/json">{"annotate": null, "base": "/", "features": ["navigation.instant", "navigation.instant.progress", "navigation.instant.preview", "navigation.tracking", "navigation.indexes", "toc.integrate", "content.code.copy", "navigation.path", "navigation.top", "navigation.footer", "header.autohide", "navigation.collaspe", "navigation.tabs", "search.highlight", "search.share"], "search": "/assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
<script id="__config" type="application/json">{"annotate": null, "base": "/", "features": ["navigation.instant", "navigation.instant.progress", "navigation.instant.preview", "navigation.tracking", "navigation.indexes", "toc.integrate", "content.code.copy", "content.action.edit", "content.action.view", "navigation.path", "navigation.top", "navigation.footer", "header.autohide", "navigation.collaspe", "navigation.tabs", "search.highlight", "search.share"], "search": "/assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>

View File

@ -289,6 +289,18 @@
<div class="md-header__source">
<a href="https://git.freealberta.org/admin/freealberta" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
freealberta
</div>
</a>
</div>
</nav>
</header>
@ -584,6 +596,18 @@
Free Alberta
</label>
<div class="md-nav__source">
<a href="https://git.freealberta.org/admin/freealberta" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
freealberta
</div>
</a>
</div>
<ul class="md-nav__list" data-md-scrollfix>
@ -2800,7 +2824,7 @@
<a href="https://changemaker.bnkops.com" target="_blank" rel="noopener" title="changemaker.bnkops.com" class="md-social__link">
<a href="https://freealberta.org" target="_blank" rel="noopener" title="freealberta.org" class="md-social__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2"/></svg>
</a>
@ -2821,7 +2845,7 @@
<script id="__config" type="application/json">{"annotate": null, "base": "../../..", "features": ["navigation.instant", "navigation.instant.progress", "navigation.instant.preview", "navigation.tracking", "navigation.indexes", "toc.integrate", "content.code.copy", "navigation.path", "navigation.top", "navigation.footer", "header.autohide", "navigation.collaspe", "navigation.tabs", "search.highlight", "search.share"], "search": "../../../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
<script id="__config" type="application/json">{"annotate": null, "base": "../../..", "features": ["navigation.instant", "navigation.instant.progress", "navigation.instant.preview", "navigation.tracking", "navigation.indexes", "toc.integrate", "content.code.copy", "content.action.edit", "content.action.view", "navigation.path", "navigation.top", "navigation.footer", "header.autohide", "navigation.collaspe", "navigation.tabs", "search.highlight", "search.share"], "search": "../../../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>

View File

@ -291,6 +291,18 @@
<div class="md-header__source">
<a href="https://git.freealberta.org/admin/freealberta" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
freealberta
</div>
</a>
</div>
</nav>
</header>
@ -588,6 +600,18 @@
Free Alberta
</label>
<div class="md-nav__source">
<a href="https://git.freealberta.org/admin/freealberta" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
freealberta
</div>
</a>
</div>
<ul class="md-nav__list" data-md-scrollfix>
@ -2838,7 +2862,7 @@
<a href="https://changemaker.bnkops.com" target="_blank" rel="noopener" title="changemaker.bnkops.com" class="md-social__link">
<a href="https://freealberta.org" target="_blank" rel="noopener" title="freealberta.org" class="md-social__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2"/></svg>
</a>
@ -2859,7 +2883,7 @@
<script id="__config" type="application/json">{"annotate": null, "base": "..", "features": ["navigation.instant", "navigation.instant.progress", "navigation.instant.preview", "navigation.tracking", "navigation.indexes", "toc.integrate", "content.code.copy", "navigation.path", "navigation.top", "navigation.footer", "header.autohide", "navigation.collaspe", "navigation.tabs", "search.highlight", "search.share"], "search": "../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
<script id="__config" type="application/json">{"annotate": null, "base": "..", "features": ["navigation.instant", "navigation.instant.progress", "navigation.instant.preview", "navigation.tracking", "navigation.indexes", "toc.integrate", "content.code.copy", "content.action.edit", "content.action.view", "navigation.path", "navigation.top", "navigation.footer", "header.autohide", "navigation.collaspe", "navigation.tabs", "search.highlight", "search.share"], "search": "../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>

View File

@ -286,6 +286,18 @@
<div class="md-header__source">
<a href="https://git.freealberta.org/admin/freealberta" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
freealberta
</div>
</a>
</div>
</nav>
</header>
@ -587,6 +599,18 @@
Free Alberta
</label>
<div class="md-nav__source">
<a href="https://git.freealberta.org/admin/freealberta" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
freealberta
</div>
</a>
</div>
<ul class="md-nav__list" data-md-scrollfix>
@ -2826,7 +2850,7 @@
<a href="https://changemaker.bnkops.com" target="_blank" rel="noopener" title="changemaker.bnkops.com" class="md-social__link">
<a href="https://freealberta.org" target="_blank" rel="noopener" title="freealberta.org" class="md-social__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2"/></svg>
</a>
@ -2847,7 +2871,7 @@
<script id="__config" type="application/json">{"annotate": null, "base": ".", "features": ["navigation.instant", "navigation.instant.progress", "navigation.instant.preview", "navigation.tracking", "navigation.indexes", "toc.integrate", "content.code.copy", "navigation.path", "navigation.top", "navigation.footer", "header.autohide", "navigation.collaspe", "navigation.tabs", "search.highlight", "search.share"], "search": "assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
<script id="__config" type="application/json">{"annotate": null, "base": ".", "features": ["navigation.instant", "navigation.instant.progress", "navigation.instant.preview", "navigation.tracking", "navigation.indexes", "toc.integrate", "content.code.copy", "content.action.edit", "content.action.view", "navigation.path", "navigation.top", "navigation.footer", "header.autohide", "navigation.collaspe", "navigation.tabs", "search.highlight", "search.share"], "search": "assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>

File diff suppressed because it is too large Load Diff

Binary file not shown.