Influence, new frontpage, bunch of other small things
This commit is contained in:
parent
a04b7d5b43
commit
e641360738
18
README.md
18
README.md
@ -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
|
||||
|
||||
@ -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
174
free-alberta-prompt.md
Normal 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.
|
||||
36
freealberta-food/.env.example
Normal file
36
freealberta-food/.env.example
Normal 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
28
freealberta-food/.gitignore
vendored
Normal 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/
|
||||
28
freealberta-food/app/Dockerfile
Normal file
28
freealberta-food/app/Dockerfile
Normal 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"]
|
||||
160
freealberta-food/app/controllers/geocodingController.js
Normal file
160
freealberta-food/app/controllers/geocodingController.js
Normal 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
|
||||
};
|
||||
385
freealberta-food/app/controllers/resourceController.js
Normal file
385
freealberta-food/app/controllers/resourceController.js
Normal 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
|
||||
};
|
||||
56
freealberta-food/app/controllers/routingController.js
Normal file
56
freealberta-food/app/controllers/routingController.js
Normal 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
|
||||
};
|
||||
95
freealberta-food/app/controllers/scraperController.js
Normal file
95
freealberta-food/app/controllers/scraperController.js
Normal 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
|
||||
};
|
||||
570
freealberta-food/app/controllers/updateRequestController.js
Normal file
570
freealberta-food/app/controllers/updateRequestController.js
Normal 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
|
||||
};
|
||||
34
freealberta-food/app/middleware/adminAuth.js
Normal file
34
freealberta-food/app/middleware/adminAuth.js
Normal 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;
|
||||
27
freealberta-food/app/models/db.js
Normal file
27
freealberta-food/app/models/db.js
Normal 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()
|
||||
};
|
||||
206
freealberta-food/app/models/init-db.js
Normal file
206
freealberta-food/app/models/init-db.js
Normal 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 };
|
||||
34
freealberta-food/app/package.json
Normal file
34
freealberta-food/app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
231
freealberta-food/app/public/admin.html
Normal file
231
freealberta-food/app/public/admin.html
Normal 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">🍽</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 (<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">×</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">×</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">×</button>
|
||||
<div id="geocodingModalBody">
|
||||
<!-- Resource geocoding details will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<p>© 2025 <a href="https://freealberta.org">Free Alberta</a>. Admin Panel.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1829
freealberta-food/app/public/css/styles.css
Normal file
1829
freealberta-food/app/public/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
487
freealberta-food/app/public/index.html
Normal file
487
freealberta-food/app/public/index.html
Normal 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">🍽</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">×</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">×</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">×</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">×</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">🍽</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>🍽</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>© 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>
|
||||
1085
freealberta-food/app/public/js/admin.js
Normal file
1085
freealberta-food/app/public/js/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
1421
freealberta-food/app/public/js/app.js
Normal file
1421
freealberta-food/app/public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
56
freealberta-food/app/routes/api.js
Normal file
56
freealberta-food/app/routes/api.js
Normal 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;
|
||||
206
freealberta-food/app/scrapers/ab211.js
Normal file
206
freealberta-food/app/scrapers/ab211.js
Normal 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 };
|
||||
397
freealberta-food/app/scrapers/informalberta.js
Normal file
397
freealberta-food/app/scrapers/informalberta.js
Normal 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 };
|
||||
233
freealberta-food/app/scrapers/pdf-parser.js
Normal file
233
freealberta-food/app/scrapers/pdf-parser.js
Normal 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 };
|
||||
59
freealberta-food/app/scrapers/run-all.js
Normal file
59
freealberta-food/app/scrapers/run-all.js
Normal 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 };
|
||||
265
freealberta-food/app/scripts/batch-geocode.js
Normal file
265
freealberta-food/app/scripts/batch-geocode.js
Normal 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 };
|
||||
146
freealberta-food/app/server.js
Normal file
146
freealberta-food/app/server.js
Normal 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;
|
||||
602
freealberta-food/app/services/geocoding.js
Normal file
602
freealberta-food/app/services/geocoding.js
Normal 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
|
||||
};
|
||||
262
freealberta-food/app/services/routing.js
Normal file
262
freealberta-food/app/services/routing.js
Normal 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
|
||||
};
|
||||
31
freealberta-food/app/utils/logger.js
Normal file
31
freealberta-food/app/utils/logger.js
Normal 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;
|
||||
80
freealberta-food/docker-compose.yml
Normal file
80
freealberta-food/docker-compose.yml
Normal 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
|
||||
28
freealberta-lander/docker-compose.yml
Normal file
28
freealberta-lander/docker-compose.yml
Normal 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
|
||||
36
freealberta-lander/nginx/default.conf
Normal file
36
freealberta-lander/nginx/default.conf
Normal 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 |
BIN
freealberta-lander/public/assets/freealberta-logo.gif
Normal file
BIN
freealberta-lander/public/assets/freealberta-logo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
358
freealberta-lander/public/css/style.css
Normal file
358
freealberta-lander/public/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
96
freealberta-lander/public/index.html
Normal file
96
freealberta-lander/public/index.html
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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.
Loading…
x
Reference in New Issue
Block a user