diff --git a/README.md b/README.md index 2502110..0bcbaa4 100644 --- a/README.md +++ b/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 diff --git a/configs/cloudflare/tunnel-config.yml b/configs/cloudflare/tunnel-config.yml index f1a53de..1f1deed 100644 --- a/configs/cloudflare/tunnel-config.yml +++ b/configs/cloudflare/tunnel-config.yml @@ -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 diff --git a/free-alberta-prompt.md b/free-alberta-prompt.md new file mode 100644 index 0000000..e5d3bc3 --- /dev/null +++ b/free-alberta-prompt.md @@ -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. diff --git a/freealberta-food/.env.example b/freealberta-food/.env.example new file mode 100644 index 0000000..1a8c36c --- /dev/null +++ b/freealberta-food/.env.example @@ -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 diff --git a/freealberta-food/.gitignore b/freealberta-food/.gitignore new file mode 100644 index 0000000..082af1c --- /dev/null +++ b/freealberta-food/.gitignore @@ -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/ diff --git a/freealberta-food/app/Dockerfile b/freealberta-food/app/Dockerfile new file mode 100644 index 0000000..943a092 --- /dev/null +++ b/freealberta-food/app/Dockerfile @@ -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"] diff --git a/freealberta-food/app/controllers/geocodingController.js b/freealberta-food/app/controllers/geocodingController.js new file mode 100644 index 0000000..4d112df --- /dev/null +++ b/freealberta-food/app/controllers/geocodingController.js @@ -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 +}; diff --git a/freealberta-food/app/controllers/resourceController.js b/freealberta-food/app/controllers/resourceController.js new file mode 100644 index 0000000..6d39368 --- /dev/null +++ b/freealberta-food/app/controllers/resourceController.js @@ -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 +}; diff --git a/freealberta-food/app/controllers/routingController.js b/freealberta-food/app/controllers/routingController.js new file mode 100644 index 0000000..677221c --- /dev/null +++ b/freealberta-food/app/controllers/routingController.js @@ -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 +}; diff --git a/freealberta-food/app/controllers/scraperController.js b/freealberta-food/app/controllers/scraperController.js new file mode 100644 index 0000000..28917b8 --- /dev/null +++ b/freealberta-food/app/controllers/scraperController.js @@ -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 +}; diff --git a/freealberta-food/app/controllers/updateRequestController.js b/freealberta-food/app/controllers/updateRequestController.js new file mode 100644 index 0000000..3fb5b3a --- /dev/null +++ b/freealberta-food/app/controllers/updateRequestController.js @@ -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 +}; diff --git a/freealberta-food/app/middleware/adminAuth.js b/freealberta-food/app/middleware/adminAuth.js new file mode 100644 index 0000000..f1ee102 --- /dev/null +++ b/freealberta-food/app/middleware/adminAuth.js @@ -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 " + 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; diff --git a/freealberta-food/app/models/db.js b/freealberta-food/app/models/db.js new file mode 100644 index 0000000..8e7ea4f --- /dev/null +++ b/freealberta-food/app/models/db.js @@ -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() +}; diff --git a/freealberta-food/app/models/init-db.js b/freealberta-food/app/models/init-db.js new file mode 100644 index 0000000..f7f54ad --- /dev/null +++ b/freealberta-food/app/models/init-db.js @@ -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 }; diff --git a/freealberta-food/app/package.json b/freealberta-food/app/package.json new file mode 100644 index 0000000..1e60066 --- /dev/null +++ b/freealberta-food/app/package.json @@ -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" + } +} diff --git a/freealberta-food/app/public/admin.html b/freealberta-food/app/public/admin.html new file mode 100644 index 0000000..6970f8f --- /dev/null +++ b/freealberta-food/app/public/admin.html @@ -0,0 +1,231 @@ + + + + + + Admin - Free Alberta Food + + + + + + + + + + + + +
+
+ + +
+
+ + +
+ + + + + +
+ + + + + + + + + + + + + + + + + diff --git a/freealberta-food/app/public/css/styles.css b/freealberta-food/app/public/css/styles.css new file mode 100644 index 0000000..35a8ca5 --- /dev/null +++ b/freealberta-food/app/public/css/styles.css @@ -0,0 +1,1829 @@ +/* Reset and Base Styles */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --primary: #2563eb; + --primary-dark: #1d4ed8; + --primary-light: #3b82f6; + --secondary: #10b981; + --background: #f8fafc; + --surface: #ffffff; + --text: #1e293b; + --text-light: #64748b; + --border: #e2e8f0; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --radius: 8px; + --radius-lg: 12px; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: var(--background); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ +.header { + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + display: flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + color: var(--text); + font-weight: 700; + font-size: 1.25rem; +} + +.logo-icon { + font-size: 1.5rem; +} + +.nav-link { + color: var(--text-light); + text-decoration: none; + font-weight: 500; + transition: color 0.2s; +} + +.nav-link:hover { + color: var(--primary); +} + +/* Hero Section */ +.hero { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + color: white; + padding: 3rem 1.5rem; +} + +.hero-content { + max-width: 800px; + margin: 0 auto; + text-align: center; +} + +.hero h1 { + font-size: 2.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.hero p { + font-size: 1.125rem; + opacity: 0.9; + margin-bottom: 1.5rem; +} + +/* Search Container */ +.search-container { + display: flex; + gap: 0.5rem; + max-width: 600px; + margin: 0 auto; +} + +.search-input { + flex: 1; + padding: 0.875rem 1rem; + font-size: 1rem; + border: none; + border-radius: var(--radius); + outline: none; +} + +.search-input:focus { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.3); +} + +.search-btn { + padding: 0.875rem 1.5rem; + font-size: 1rem; + font-weight: 600; + background: var(--secondary); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s; +} + +.search-btn:hover { + background: #059669; +} + +.location-btn { + padding: 0.875rem; + background: rgba(255, 255, 255, 0.2); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.location-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Main Content */ +.main-content { + flex: 1; + max-width: 1400px; + margin: 0 auto; + padding: 1.5rem; + width: 100%; +} + +/* Filters Bar */ +.filters-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.filters-left { + display: flex; + gap: 0.75rem; +} + +.filters-right { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.filter-select { + padding: 0.5rem 2rem 0.5rem 0.75rem; + font-size: 0.875rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; +} + +.filter-select:focus { + outline: none; + border-color: var(--primary); +} + +/* Multi-Select Dropdown */ +.multi-select { + position: relative; + display: inline-block; +} + +.multi-select-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + cursor: pointer; + min-width: 140px; + justify-content: space-between; + transition: border-color 0.2s; +} + +.multi-select-btn:hover, +.multi-select.open .multi-select-btn { + border-color: var(--primary); +} + +.multi-select-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; +} + +.multi-select-arrow { + flex-shrink: 0; + color: var(--text-light); + transition: transform 0.2s; +} + +.multi-select.open .multi-select-arrow { + transform: rotate(180deg); +} + +.multi-select-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 220px; + max-width: 300px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + z-index: 100; +} + +.multi-select-search { + padding: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.multi-select-search-input { + width: 100%; + padding: 0.5rem; + font-size: 0.875rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--background); +} + +.multi-select-search-input:focus { + outline: none; + border-color: var(--primary); +} + +.multi-select-options { + max-height: 250px; + overflow-y: auto; + padding: 0.5rem 0; +} + +.multi-select-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + font-size: 0.875rem; + transition: background 0.15s; +} + +.multi-select-option:hover { + background: var(--background); +} + +.multi-select-option input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--primary); +} + +.multi-select-option span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.multi-select-option .count { + color: var(--text-light); + font-size: 0.75rem; +} + +.multi-select-actions { + display: flex; + gap: 0.5rem; + padding: 0.5rem; + border-top: 1px solid var(--border); + background: var(--background); +} + +.multi-select-clear, +.multi-select-apply { + flex: 1; + padding: 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s; +} + +.multi-select-clear { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); +} + +.multi-select-clear:hover { + background: var(--background); +} + +.multi-select-apply { + background: var(--primary); + border: 1px solid var(--primary); + color: white; +} + +.multi-select-apply:hover { + background: var(--primary-dark); +} + +.multi-select-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + font-size: 0.7rem; + font-weight: 600; + background: var(--primary); + color: white; + border-radius: 9px; + margin-left: 0.25rem; +} + +.view-btn { + padding: 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + color: var(--text-light); + transition: all 0.2s; +} + +.view-btn:hover, +.view-btn.active { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +.result-count { + color: var(--text-light); + font-size: 0.875rem; + margin-left: 0.5rem; +} + +/* Content Area */ +.content-area { + min-height: 400px; +} + +/* Split Layout - Map and List side by side */ +.content-area.split-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + min-height: 600px; +} + +.split-layout .map-container { + position: sticky; + top: 80px; + height: calc(100vh - 100px); + max-height: 700px; +} + +.split-layout .list-container { + max-height: calc(100vh - 100px); + overflow-y: auto; +} + +/* Map Container */ +.map-container { + height: 500px; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow); +} + +#map { + height: 100%; + width: 100%; +} + +/* List Container */ +.resource-list { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); +} + +/* Resource Card */ +.resource-card { + background: var(--surface); + border-radius: var(--radius-lg); + padding: 1.25rem; + box-shadow: var(--shadow); + transition: box-shadow 0.2s, transform 0.2s; + cursor: pointer; +} + +.resource-card:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +.resource-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.resource-click-hint { + font-size: 0.7rem; + color: var(--primary); + margin-bottom: 0.5rem; + opacity: 0.7; + transition: opacity 0.2s; +} + +.resource-card:hover .resource-click-hint { + opacity: 1; +} + +.resource-name { + font-size: 1.125rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.resource-type { + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + background: var(--primary); + color: white; + white-space: nowrap; +} + +.resource-type.food_bank { background: #dc2626; } +.resource-type.community_meal { background: #ea580c; } +.resource-type.hamper { background: #ca8a04; } +.resource-type.pantry { background: #16a34a; } +.resource-type.soup_kitchen { background: #9333ea; } +.resource-type.mobile_food { background: #0891b2; } +.resource-type.grocery_program { background: #4f46e5; } + +.resource-info { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-light); +} + +.resource-info-row { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.resource-info-row svg { + flex-shrink: 0; + margin-top: 0.125rem; +} + +.resource-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.resource-action-btn { + flex: 1; + padding: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + cursor: pointer; + text-decoration: none; + text-align: center; + transition: all 0.2s; +} + +.resource-action-btn:hover { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +.resource-action-btn.primary { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +.resource-action-btn.primary:hover { + background: var(--primary-dark); +} + +.resource-action-btn.maps-btn { + background: #10b981; + border-color: #10b981; + color: white; +} + +.resource-action-btn.maps-btn:hover { + background: #059669; + border-color: #059669; +} + +.resource-action-btn.update-btn { + flex: 0; + padding: 0.375rem 0.625rem; + font-size: 0.75rem; + background: transparent; + border-color: var(--text-light); + color: var(--text-light); +} + +.resource-action-btn.update-btn:hover { + background: var(--text-light); + color: white; +} + +/* Loading & No Results */ +.loading-indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 3rem; + color: var(--text-light); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-more-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 1.5rem; + color: var(--text-light); + font-size: 0.875rem; +} + +.loading-more-indicator .spinner { + width: 24px; + height: 24px; + border-width: 2px; +} + +.no-results { + text-align: center; + padding: 3rem; + color: var(--text-light); +} + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 2rem; +} + +.page-btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + cursor: pointer; + transition: all 0.2s; +} + +.page-btn:hover:not(:disabled) { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +.page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.page-info { + color: var(--text-light); + font-size: 0.875rem; +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.modal-content { + position: relative; + background: var(--surface); + border-radius: var(--radius-lg); + max-width: 600px; + max-height: 90vh; + width: 90%; + overflow-y: auto; + box-shadow: var(--shadow-lg); +} + +.modal-close { + position: absolute; + top: 1rem; + right: 1rem; + width: 32px; + height: 32px; + font-size: 1.5rem; + border: none; + background: none; + cursor: pointer; + color: var(--text-light); + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background 0.2s; +} + +.modal-close:hover { + background: var(--border); +} + +#modalBody { + padding: 1.5rem; +} + +.modal-header { + margin-bottom: 1.5rem; +} + +.modal-title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.modal-section { + margin-bottom: 1.25rem; +} + +.modal-section-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-light); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.5rem; +} + +.modal-section-content { + color: var(--text); +} + +.modal-actions { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +/* Add Listing Call-to-Action */ +.add-listing-cta { + background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); + border-top: 1px solid #bbf7d0; + padding: 3rem 1.5rem; +} + +.add-listing-cta-content { + max-width: 600px; + margin: 0 auto; + text-align: center; +} + +.add-listing-cta h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); + margin-bottom: 0.5rem; +} + +.add-listing-cta p { + color: var(--text-light); + margin-bottom: 1.5rem; +} + +.add-listing-cta .resource-action-btn.large { + padding: 0.875rem 2rem; + font-size: 1rem; +} + +/* Footer */ +.footer { + background: var(--surface); + border-top: 1px solid var(--border); + padding: 2rem 1.5rem; + margin-top: auto; +} + +.footer-content { + max-width: 1400px; + margin: 0 auto; + text-align: center; + color: var(--text-light); + font-size: 0.875rem; +} + +.footer a { + color: var(--primary); + text-decoration: none; +} + +.footer a:hover { + text-decoration: underline; +} + +.footer-note { + margin-top: 0.5rem; + font-size: 0.75rem; +} + +.footer-admin { + margin-top: 1rem; + font-size: 0.7rem; + opacity: 0.6; +} + +.footer-admin:hover { + opacity: 1; +} + +/* Utility Classes */ +.hidden { + display: none !important; +} + +/* Responsive */ +@media (max-width: 1024px) { + .content-area.split-layout { + grid-template-columns: 1fr; + } + + .split-layout .map-container { + position: relative; + top: 0; + height: 400px; + max-height: none; + } + + .split-layout .list-container { + max-height: calc(100vh - 250px); + overflow-y: auto; + } +} + +@media (max-width: 768px) { + .hero h1 { + font-size: 1.75rem; + } + + .search-container { + flex-wrap: wrap; + } + + .search-input { + width: 100%; + } + + .search-btn { + flex: 1; + } + + .filters-bar { + flex-direction: column; + align-items: stretch; + } + + .filters-left, + .filters-right { + justify-content: space-between; + } + + .resource-list { + grid-template-columns: 1fr; + } + + .split-layout .list-container { + max-height: calc(100vh - 350px); + min-height: 300px; + } + + .map-container { + height: 350px; + } + + .split-layout .map-container { + height: 300px; + } +} + +/* Leaflet Popup Customization */ +.leaflet-popup-content-wrapper { + border-radius: var(--radius); +} + +.leaflet-popup-content { + margin: 0.75rem; +} + +.popup-title { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.popup-address { + font-size: 0.875rem; + color: var(--text-light); +} + +/* Directions Modal */ +.modal-large { + max-width: 1000px; + max-height: 95vh; +} + +.directions-container { + padding: 1.5rem; +} + +.directions-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; + gap: 1rem; +} + +.directions-header h2 { + margin: 0; + font-size: 1.5rem; +} + +.directions-controls { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.directions-summary { + display: flex; + gap: 2rem; + padding: 1rem; + background: var(--background); + border-radius: var(--radius); + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.directions-summary-item { + display: flex; + flex-direction: column; +} + +.directions-summary-label { + font-size: 0.75rem; + color: var(--text-light); + text-transform: uppercase; +} + +.directions-summary-value { + font-size: 1.25rem; + font-weight: 600; + color: var(--text); +} + +.directions-layout { + display: grid; + grid-template-columns: 1fr 350px; + gap: 1rem; + height: 450px; +} + +.directions-map-container { + border-radius: var(--radius); + overflow: hidden; + background: var(--border); +} + +#directionsMap { + height: 100%; + width: 100%; +} + +.directions-steps-container { + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.directions-steps { + padding: 0.5rem; +} + +.direction-step { + display: flex; + gap: 0.75rem; + padding: 0.75rem; + border-bottom: 1px solid var(--border); + transition: background 0.2s; + cursor: pointer; +} + +.direction-step:last-child { + border-bottom: none; +} + +.direction-step:hover { + background: var(--background); +} + +.direction-step-number { + width: 28px; + height: 28px; + background: var(--primary); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.direction-step-number.start { + background: var(--secondary); +} + +.direction-step-number.end { + background: #dc2626; +} + +.direction-step-content { + flex: 1; + min-width: 0; +} + +.direction-step-instruction { + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.direction-step-meta { + font-size: 0.75rem; + color: var(--text-light); +} + +/* Maneuver icons */ +.direction-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-light); +} + +/* Route line styling */ +.route-line { + stroke: var(--primary); + stroke-width: 5; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; +} + +/* Print Styles */ +.print-only { + display: none; +} + +@media print { + /* Hide everything except print content */ + body * { + visibility: hidden; + } + + .print-only, + .print-only * { + visibility: visible; + } + + .print-only { + position: absolute; + left: 0; + top: 0; + width: 100%; + padding: 1.5rem 2rem; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: #1e293b; + } + + /* Header with branding */ + .print-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 1rem; + border-bottom: 3px solid #2563eb; + margin-bottom: 1.5rem; + } + + .print-logo { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .print-logo-icon { + font-size: 2rem; + } + + .print-logo-text { + font-size: 1.5rem; + font-weight: 700; + color: #2563eb; + } + + .print-tagline { + font-size: 0.875rem; + color: #64748b; + } + + /* Title section */ + .print-title { + margin-bottom: 1.5rem; + } + + .print-title h1 { + font-size: 1.75rem; + font-weight: 700; + margin: 0 0 0.25rem 0; + color: #1e293b; + } + + .print-date { + font-size: 0.875rem; + color: #64748b; + margin: 0; + } + + /* Summary box */ + .print-summary { + display: flex; + gap: 2rem; + margin-bottom: 1.5rem; + padding: 1rem 1.25rem; + background: #f1f5f9; + border-radius: 8px; + border-left: 4px solid #2563eb; + } + + .print-summary-item { + display: flex; + flex-direction: column; + } + + .print-summary-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #64748b; + font-weight: 600; + } + + .print-summary-value { + font-size: 1.125rem; + font-weight: 600; + color: #1e293b; + } + + /* Destination card */ + .print-destination { + margin-bottom: 1.5rem; + padding: 1rem; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + } + + .print-destination-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #64748b; + font-weight: 600; + margin-bottom: 0.25rem; + } + + .print-destination h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: #1e293b; + } + + .print-destination p { + margin: 0.25rem 0; + font-size: 0.875rem; + color: #475569; + } + + .print-destination-contact { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e2e8f0; + } + + /* Steps section */ + .print-steps-header { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: #1e293b; + } + + .print-steps { + margin-bottom: 1.5rem; + } + + .print-step { + display: flex; + gap: 0.75rem; + padding: 0.625rem 0; + border-bottom: 1px solid #e2e8f0; + align-items: flex-start; + } + + .print-step:last-child { + border-bottom: none; + } + + .print-step-number { + width: 24px; + height: 24px; + min-width: 24px; + background: #2563eb; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 700; + margin-top: 2px; + } + + .print-step-number.start { + background: #10b981; + } + + .print-step-number.end { + background: #dc2626; + } + + .print-step-content { + flex: 1; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + } + + .print-step-instruction { + flex: 1; + font-size: 0.875rem; + line-height: 1.4; + } + + .print-step-distance { + font-size: 0.75rem; + color: #64748b; + white-space: nowrap; + } + + /* Footer */ + .print-footer { + margin-top: 2rem; + padding-top: 1rem; + border-top: 2px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; + } + + .print-footer-brand { + font-weight: 600; + color: #2563eb; + } + + .print-footer-url { + font-size: 0.875rem; + color: #64748b; + } + + .print-footer-note { + font-size: 0.7rem; + color: #94a3b8; + } + + /* Hide interactive elements */ + .modal, + .header, + .footer, + button, + select, + input { + display: none !important; + } +} + +/* Responsive directions */ +@media (max-width: 768px) { + .directions-layout { + grid-template-columns: 1fr; + height: auto; + } + + .directions-map-container { + height: 250px; + } + + .directions-steps-container { + max-height: 300px; + } + + .directions-summary { + gap: 1rem; + } +} + +/* Update Modal Styles */ +.update-modal-container { + padding: 1.5rem; +} + +.update-modal-container h2 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; +} + +.update-modal-subtitle { + color: var(--text-light); + margin-bottom: 1.5rem; +} + +.update-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-section { + border-bottom: 1px solid var(--border); + padding-bottom: 1.5rem; +} + +.form-section:last-of-type { + border-bottom: none; +} + +.form-section h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text); +} + +.form-hint { + font-size: 0.875rem; + color: var(--text-light); + margin-bottom: 1rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text); +} + +.form-group .required { + color: #dc2626; +} + +.form-group input, +.form-group select, +.form-group textarea { + padding: 0.625rem 0.75rem; + font-size: 0.875rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + font-family: inherit; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); +} + +.form-group textarea { + resize: vertical; +} + +.form-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + padding-top: 1rem; +} + +.modal-update-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.modal-update-btn { + width: 100%; + background: transparent; + border: 1px dashed var(--border); + color: var(--text-light); +} + +.modal-update-btn:hover { + background: var(--background); + border-color: var(--primary); + color: var(--primary); +} + +/* Admin Panel Styles */ +.admin-badge { + background: var(--primary); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; +} + +.logout-btn { + cursor: pointer; + background: none; + border: none; + font-size: inherit; +} + +.admin-login-section { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +} + +.login-card { + background: var(--surface); + padding: 2rem; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + max-width: 400px; + width: 100%; +} + +.login-card h1 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; +} + +.login-card p { + color: var(--text-light); + margin-bottom: 1.5rem; +} + +.full-width { + width: 100%; +} + +.error-message { + background: #fef2f2; + color: #dc2626; + padding: 0.75rem; + border-radius: var(--radius); + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.admin-stats-bar { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--surface); + padding: 1.25rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + text-align: center; +} + +.stat-value { + display: block; + font-size: 2rem; + font-weight: 700; + color: var(--primary); +} + +.stat-label { + font-size: 0.875rem; + color: var(--text-light); +} + +/* Admin Section Tabs */ +.admin-section-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + background: var(--surface); + padding: 0.5rem; + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +.admin-section-tab { + flex: 1; + padding: 0.75rem 1rem; + background: transparent; + border: none; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-light); + cursor: pointer; + border-radius: var(--radius); + transition: all 0.2s; +} + +.admin-section-tab:hover { + background: var(--background); + color: var(--text); +} + +.admin-section-tab.active { + background: var(--primary); + color: white; +} + +.admin-section { + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.section-title { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text); +} + +.admin-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.5rem; +} + +.admin-tab { + padding: 0.5rem 1rem; + background: none; + border: none; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-light); + cursor: pointer; + border-radius: var(--radius); + transition: all 0.2s; +} + +.admin-tab:hover { + background: var(--background); + color: var(--text); +} + +.admin-tab.active { + background: var(--primary); + color: white; +} + +.requests-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.request-card { + background: var(--surface); + padding: 1.25rem; + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +.request-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.request-resource-name { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 0.25rem 0; +} + +.request-date { + font-size: 0.75rem; + color: var(--text-light); +} + +.status-badge { + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + text-transform: capitalize; +} + +.status-badge.pending { + background: #fef3c7; + color: #92400e; +} + +.status-badge.approved { + background: #d1fae5; + color: #065f46; +} + +.status-badge.rejected { + background: #fee2e2; + color: #991b1b; +} + +.request-card-info { + font-size: 0.875rem; + color: var(--text-light); + margin-bottom: 1rem; +} + +.request-submitter { + margin-bottom: 0.5rem; +} + +.request-card-actions { + display: flex; + gap: 0.5rem; +} + +/* Request Detail Modal */ +.request-detail { + padding: 1.5rem; +} + +.request-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.request-detail-header h2 { + margin: 0; + font-size: 1.25rem; +} + +.diff-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.diff-row { + background: var(--background); + padding: 1rem; + border-radius: var(--radius); +} + +.diff-label { + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 0.5rem; + color: var(--text); +} + +.diff-values { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 0.75rem; + align-items: center; +} + +.diff-current, +.diff-proposed { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.diff-tag { + font-size: 0.625rem; + text-transform: uppercase; + font-weight: 600; + color: var(--text-light); +} + +.diff-value { + font-size: 0.875rem; + color: var(--text); + word-break: break-word; +} + +.diff-current .diff-value { + color: var(--text-light); + text-decoration: line-through; +} + +.diff-proposed .diff-value { + color: #059669; + font-weight: 500; +} + +.diff-arrow { + color: var(--text-light); + font-size: 1.25rem; +} + +.admin-notes-input { + width: 100%; + padding: 0.75rem; + font-size: 0.875rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: inherit; + resize: vertical; +} + +.admin-notes-input:focus { + outline: none; + border-color: var(--primary); +} + +.reject-btn { + background: #fee2e2; + border-color: #fecaca; + color: #991b1b; +} + +.reject-btn:hover { + background: #dc2626; + border-color: #dc2626; + color: white; +} + +/* Listing Fields Display */ +.listing-fields { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.listing-field { + background: var(--background); + padding: 0.75rem 1rem; + border-radius: var(--radius); +} + +.listing-field-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-light); + text-transform: uppercase; + margin-bottom: 0.25rem; +} + +.listing-field-value { + font-size: 0.875rem; + color: var(--text); + word-break: break-word; +} + +/* Responsive Admin */ +@media (max-width: 768px) { + .form-row { + grid-template-columns: 1fr; + } + + .admin-stats-bar { + grid-template-columns: 1fr; + } + + .diff-values { + grid-template-columns: 1fr; + } + + .diff-arrow { + transform: rotate(90deg); + text-align: center; + } +} diff --git a/freealberta-food/app/public/index.html b/freealberta-food/app/public/index.html new file mode 100644 index 0000000..a1b36f4 --- /dev/null +++ b/freealberta-food/app/public/index.html @@ -0,0 +1,487 @@ + + + + + + Free Food Resources - Free Alberta + + + + + + + + + + + + + + + +
+ +
+ + +
+
+

Find Free Food Resources in Alberta

+

Locate food banks, community meals, and food assistance programs near you

+ + +
+ + + + +
+
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ 0 resources +
+
+ + +
+ +
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + + + + + + + + + + + + + +
+
+

Know of a food resource not listed here?

+

Help your community by adding it to our directory.

+ +
+
+ + + + + + + + + + + + + + diff --git a/freealberta-food/app/public/js/admin.js b/freealberta-food/app/public/js/admin.js new file mode 100644 index 0000000..d3f32f9 --- /dev/null +++ b/freealberta-food/app/public/js/admin.js @@ -0,0 +1,1085 @@ +// Free Alberta Food - Admin Panel + +class AdminPanel { + constructor() { + this.password = null; + this.currentSection = 'updates'; + this.currentStatus = 'pending'; + this.listingStatus = 'pending'; + this.geocodingFilter = 'all'; + this.requests = []; + this.listings = []; + this.geoResources = []; + + this.init(); + } + + init() { + this.bindEvents(); + this.checkAuth(); + } + + bindEvents() { + // Login form + document.getElementById('loginForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.login(); + }); + + // Logout button + document.getElementById('logoutBtn').addEventListener('click', () => this.logout()); + + // Section tabs (Update Requests vs New Listings) + document.querySelectorAll('.admin-section-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + this.switchSection(e.target.dataset.section); + }); + }); + + // Status filter tabs + document.querySelectorAll('.admin-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + const section = e.target.dataset.section; + const status = e.target.dataset.status; + const filter = e.target.dataset.filter; + if (section === 'updates') { + this.switchTab(status); + } else if (section === 'listings') { + this.switchListingTab(status); + } else if (section === 'geocoding') { + this.switchGeocodingTab(filter); + } + }); + }); + + // Request modal + document.querySelectorAll('#requestModal .modal-overlay, #requestModal .modal-close').forEach(el => { + el.addEventListener('click', () => this.closeModal('requestModal')); + }); + + // Listing modal + document.querySelectorAll('#listingModal .modal-overlay, #listingModal .modal-close').forEach(el => { + el.addEventListener('click', () => this.closeModal('listingModal')); + }); + + // Geocoding modal + document.querySelectorAll('#geocodingModal .modal-overlay, #geocodingModal .modal-close').forEach(el => { + el.addEventListener('click', () => this.closeModal('geocodingModal')); + }); + + // Escape key to close modals + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.closeModal('requestModal'); + this.closeModal('listingModal'); + this.closeModal('geocodingModal'); + } + }); + + // Event delegation for requests list + document.getElementById('requestsList').addEventListener('click', (e) => { + const viewBtn = e.target.closest('.view-request-btn'); + if (viewBtn) { + const requestId = parseInt(viewBtn.dataset.requestId); + this.openRequestModal(requestId); + } + }); + + // Event delegation for listings list + document.getElementById('listingsList').addEventListener('click', (e) => { + const viewBtn = e.target.closest('.view-listing-btn'); + if (viewBtn) { + const listingId = parseInt(viewBtn.dataset.listingId); + this.openListingModal(listingId); + } + }); + + // Event delegation for request modal actions + document.getElementById('requestModalBody').addEventListener('click', (e) => { + const approveBtn = e.target.closest('.approve-btn'); + if (approveBtn) { + const requestId = parseInt(approveBtn.dataset.requestId); + this.approveRequest(requestId); + return; + } + + const rejectBtn = e.target.closest('.reject-btn'); + if (rejectBtn) { + const requestId = parseInt(rejectBtn.dataset.requestId); + this.rejectRequest(requestId); + } + }); + + // Event delegation for listing modal actions + document.getElementById('listingModalBody').addEventListener('click', (e) => { + const approveBtn = e.target.closest('.approve-listing-btn'); + if (approveBtn) { + const listingId = parseInt(approveBtn.dataset.listingId); + this.approveListingSubmission(listingId); + return; + } + + const rejectBtn = e.target.closest('.reject-listing-btn'); + if (rejectBtn) { + const listingId = parseInt(rejectBtn.dataset.listingId); + this.rejectListingSubmission(listingId); + } + }); + + // Event delegation for geocoding list + document.getElementById('geocodingList').addEventListener('click', (e) => { + const viewBtn = e.target.closest('.view-geocoding-btn'); + if (viewBtn) { + const resourceId = parseInt(viewBtn.dataset.resourceId); + this.openGeocodingModal(resourceId); + } + }); + + // Event delegation for geocoding modal actions + document.getElementById('geocodingModalBody').addEventListener('click', (e) => { + const regeocodeBtn = e.target.closest('.regeocode-btn'); + if (regeocodeBtn) { + const resourceId = parseInt(regeocodeBtn.dataset.resourceId); + this.regeocodeResource(resourceId, regeocodeBtn); + } + }); + } + + switchSection(section) { + this.currentSection = section; + + // Update section tab active states + document.querySelectorAll('.admin-section-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.section === section); + }); + + // Show/hide sections + document.getElementById('updatesSection').classList.toggle('hidden', section !== 'updates'); + document.getElementById('listingsSection').classList.toggle('hidden', section !== 'listings'); + document.getElementById('geocodingSection').classList.toggle('hidden', section !== 'geocoding'); + + // Load data for the active section + if (section === 'updates') { + this.loadCounts(); + this.loadRequests(); + } else if (section === 'listings') { + this.loadListingCounts(); + this.loadListings(); + } else if (section === 'geocoding') { + this.loadGeocodingStats(); + this.loadGeoResources(); + } + } + + checkAuth() { + const savedPassword = sessionStorage.getItem('adminPassword'); + if (savedPassword) { + this.password = savedPassword; + this.showDashboard(); + } + } + + async login() { + const passwordInput = document.getElementById('adminPassword'); + const password = passwordInput.value; + const errorDiv = document.getElementById('loginError'); + + try { + const response = await fetch('/api/admin/auth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ password }) + }); + + if (response.ok) { + this.password = password; + sessionStorage.setItem('adminPassword', password); + errorDiv.classList.add('hidden'); + this.showDashboard(); + } else { + const data = await response.json(); + errorDiv.textContent = data.error || 'Invalid password'; + errorDiv.classList.remove('hidden'); + } + } catch (error) { + console.error('Login failed:', error); + errorDiv.textContent = 'Login failed. Please try again.'; + errorDiv.classList.remove('hidden'); + } + } + + logout() { + this.password = null; + sessionStorage.removeItem('adminPassword'); + document.getElementById('loginSection').classList.remove('hidden'); + document.getElementById('adminDashboard').classList.add('hidden'); + document.getElementById('logoutBtn').classList.add('hidden'); + document.getElementById('adminPassword').value = ''; + } + + showDashboard() { + document.getElementById('loginSection').classList.add('hidden'); + document.getElementById('adminDashboard').classList.remove('hidden'); + document.getElementById('logoutBtn').classList.remove('hidden'); + // Load data for the current section + if (this.currentSection === 'updates') { + this.loadCounts(); + this.loadRequests(); + } else if (this.currentSection === 'listings') { + this.loadListingCounts(); + this.loadListings(); + } else if (this.currentSection === 'geocoding') { + this.loadGeocodingStats(); + this.loadGeoResources(); + } + } + + async loadCounts() { + try { + const response = await fetch('/api/admin/update-requests/counts', { + headers: { + 'Authorization': `Bearer ${this.password}` + } + }); + + if (response.ok) { + const data = await response.json(); + document.getElementById('pendingCount').textContent = data.counts.pending; + document.getElementById('approvedCount').textContent = data.counts.approved; + document.getElementById('rejectedCount').textContent = data.counts.rejected; + } + } catch (error) { + console.error('Failed to load counts:', error); + } + } + + async loadRequests() { + const container = document.getElementById('requestsList'); + const noResults = document.getElementById('noRequests'); + const loading = document.getElementById('loadingRequests'); + + container.innerHTML = ''; + noResults.classList.add('hidden'); + loading.classList.remove('hidden'); + + try { + const response = await fetch(`/api/admin/update-requests?status=${this.currentStatus}`, { + headers: { + 'Authorization': `Bearer ${this.password}` + } + }); + + if (response.status === 401) { + this.logout(); + return; + } + + const data = await response.json(); + this.requests = data.requests; + + loading.classList.add('hidden'); + + if (this.requests.length === 0) { + noResults.classList.remove('hidden'); + return; + } + + container.innerHTML = this.requests.map(request => ` +
+
+
+

${this.escapeHtml(request.current_name)}

+ ${this.formatDate(request.created_at)} +
+ ${request.status} +
+
+
+ From: ${this.escapeHtml(request.submitter_email)} + ${request.submitter_name ? ` (${this.escapeHtml(request.submitter_name)})` : ''} +
+
+ Changes: ${this.getChangesSummary(request)} +
+
+
+ +
+
+ `).join(''); + + } catch (error) { + console.error('Failed to load requests:', error); + loading.classList.add('hidden'); + container.innerHTML = '
Failed to load requests. Please try again.
'; + } + } + + switchTab(status) { + this.currentStatus = status; + + // Update active tab (only for updates section) + document.querySelectorAll('.admin-tab[data-section="updates"]').forEach(tab => { + tab.classList.toggle('active', tab.dataset.status === status); + }); + + this.loadRequests(); + } + + switchListingTab(status) { + this.listingStatus = status; + + // Update active tab (only for listings section) + document.querySelectorAll('.admin-tab[data-section="listings"]').forEach(tab => { + tab.classList.toggle('active', tab.dataset.status === status); + }); + + this.loadListings(); + } + + getChangesSummary(request) { + const fields = [ + { key: 'proposed_name', label: 'Name' }, + { key: 'proposed_resource_type', label: 'Type' }, + { key: 'proposed_address', label: 'Address' }, + { key: 'proposed_city', label: 'City' }, + { key: 'proposed_phone', label: 'Phone' }, + { key: 'proposed_email', label: 'Email' }, + { key: 'proposed_website', label: 'Website' }, + { key: 'proposed_hours_of_operation', label: 'Hours' }, + { key: 'proposed_description', label: 'Description' }, + { key: 'proposed_eligibility', label: 'Eligibility' }, + { key: 'proposed_services_offered', label: 'Services' } + ]; + + const changes = fields + .filter(f => request[f.key] !== null) + .map(f => f.label); + + if (changes.length === 0) { + return request.additional_notes ? 'Notes only' : 'No changes specified'; + } + + return changes.join(', '); + } + + openRequestModal(requestId) { + const request = this.requests.find(r => r.id === requestId); + if (!request) return; + + const modalBody = document.getElementById('requestModalBody'); + + const fields = [ + { key: 'name', label: 'Name' }, + { key: 'resource_type', label: 'Type' }, + { key: 'address', label: 'Address' }, + { key: 'city', label: 'City' }, + { key: 'phone', label: 'Phone' }, + { key: 'email', label: 'Email' }, + { key: 'website', label: 'Website' }, + { key: 'hours_of_operation', label: 'Hours of Operation' }, + { key: 'description', label: 'Description' }, + { key: 'eligibility', label: 'Eligibility' }, + { key: 'services_offered', label: 'Services Offered' } + ]; + + const changesHtml = fields.map(f => { + const current = request[`current_${f.key}`]; + const proposed = request[`proposed_${f.key}`]; + + if (proposed === null) return ''; + + return ` +
+
${f.label}
+
+
+ Current + ${this.escapeHtml(current) || 'Not set'} +
+
+
+ Proposed + ${this.escapeHtml(proposed)} +
+
+
+ `; + }).filter(html => html !== '').join(''); + + modalBody.innerHTML = ` +
+
+

Update Request #${request.id}

+ ${request.status} +
+ + + + + + ${changesHtml ? ` + + ` : ''} + + ${request.additional_notes ? ` + + ` : ''} + + ${request.status === 'pending' ? ` + + + + ` : ` + ${request.admin_notes ? ` + + ` : ''} + + + `} +
+ `; + + document.getElementById('requestModal').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + closeModal(modalId) { + document.getElementById(modalId).classList.add('hidden'); + document.body.style.overflow = ''; + } + + async approveRequest(requestId) { + const adminNotes = document.getElementById('adminNotes')?.value.trim() || null; + + if (!confirm('Are you sure you want to approve this request and apply the changes?')) { + return; + } + + try { + const response = await fetch(`/api/admin/update-requests/${requestId}/approve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.password}` + }, + body: JSON.stringify({ admin_notes: adminNotes }) + }); + + if (response.ok) { + alert('Request approved and changes applied successfully!'); + this.closeModal(); + this.loadCounts(); + this.loadRequests(); + } else { + const data = await response.json(); + alert(data.error || 'Failed to approve request'); + } + } catch (error) { + console.error('Failed to approve request:', error); + alert('Failed to approve request. Please try again.'); + } + } + + async rejectRequest(requestId) { + const adminNotes = document.getElementById('adminNotes')?.value.trim() || null; + + if (!confirm('Are you sure you want to reject this request?')) { + return; + } + + try { + const response = await fetch(`/api/admin/update-requests/${requestId}/reject`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.password}` + }, + body: JSON.stringify({ admin_notes: adminNotes }) + }); + + if (response.ok) { + alert('Request rejected.'); + this.closeModal(); + this.loadCounts(); + this.loadRequests(); + } else { + const data = await response.json(); + alert(data.error || 'Failed to reject request'); + } + } catch (error) { + console.error('Failed to reject request:', error); + alert('Failed to reject request. Please try again.'); + } + } + + // ========================================== + // Listing Submissions Methods + // ========================================== + + async loadListingCounts() { + try { + const response = await fetch('/api/admin/listing-submissions/counts', { + headers: { + 'Authorization': `Bearer ${this.password}` + } + }); + + if (response.ok) { + const data = await response.json(); + document.getElementById('listingPendingCount').textContent = data.counts.pending; + document.getElementById('listingApprovedCount').textContent = data.counts.approved; + document.getElementById('listingRejectedCount').textContent = data.counts.rejected; + } + } catch (error) { + console.error('Failed to load listing counts:', error); + } + } + + async loadListings() { + const container = document.getElementById('listingsList'); + const noResults = document.getElementById('noListings'); + const loading = document.getElementById('loadingListings'); + + container.innerHTML = ''; + noResults.classList.add('hidden'); + loading.classList.remove('hidden'); + + try { + const response = await fetch(`/api/admin/listing-submissions?status=${this.listingStatus}`, { + headers: { + 'Authorization': `Bearer ${this.password}` + } + }); + + if (response.status === 401) { + this.logout(); + return; + } + + const data = await response.json(); + this.listings = data.submissions; + + loading.classList.add('hidden'); + + if (this.listings.length === 0) { + noResults.classList.remove('hidden'); + return; + } + + container.innerHTML = this.listings.map(listing => ` +
+
+
+

${this.escapeHtml(listing.name)}

+ ${this.formatDate(listing.created_at)} +
+ ${listing.status} +
+
+
+ From: ${this.escapeHtml(listing.submitter_email)} + ${listing.submitter_name ? ` (${this.escapeHtml(listing.submitter_name)})` : ''} +
+
+ Type: ${this.formatType(listing.resource_type)} + ${listing.city ? ` | City: ${this.escapeHtml(listing.city)}` : ''} +
+
+
+ +
+
+ `).join(''); + + } catch (error) { + console.error('Failed to load listings:', error); + loading.classList.add('hidden'); + container.innerHTML = '
Failed to load submissions. Please try again.
'; + } + } + + openListingModal(listingId) { + const listing = this.listings.find(l => l.id === listingId); + if (!listing) return; + + const modalBody = document.getElementById('listingModalBody'); + + const fields = [ + { key: 'name', label: 'Name' }, + { key: 'resource_type', label: 'Type', format: (v) => this.formatType(v) }, + { key: 'address', label: 'Address' }, + { key: 'city', label: 'City' }, + { key: 'phone', label: 'Phone' }, + { key: 'email', label: 'Email' }, + { key: 'website', label: 'Website' }, + { key: 'hours_of_operation', label: 'Hours of Operation' }, + { key: 'description', label: 'Description' }, + { key: 'eligibility', label: 'Eligibility' }, + { key: 'services_offered', label: 'Services Offered' } + ]; + + const fieldsHtml = fields.map(f => { + const value = listing[f.key]; + if (!value) return ''; + + const displayValue = f.format ? f.format(value) : this.escapeHtml(value); + + return ` +
+
${f.label}
+
${displayValue}
+
+ `; + }).filter(html => html !== '').join(''); + + modalBody.innerHTML = ` +
+
+

New Listing Submission #${listing.id}

+ ${listing.status} +
+ + + + + + ${listing.additional_notes ? ` + + ` : ''} + + ${listing.status === 'pending' ? ` + + + + ` : ` + ${listing.admin_notes ? ` + + ` : ''} + + + `} +
+ `; + + document.getElementById('listingModal').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + async approveListingSubmission(listingId) { + const adminNotes = document.getElementById('listingAdminNotes')?.value.trim() || null; + + if (!confirm('Are you sure you want to approve this listing and publish it?')) { + return; + } + + try { + const response = await fetch(`/api/admin/listing-submissions/${listingId}/approve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.password}` + }, + body: JSON.stringify({ admin_notes: adminNotes }) + }); + + if (response.ok) { + const data = await response.json(); + alert(`Listing approved and published! New Resource ID: ${data.resourceId}`); + this.closeModal('listingModal'); + this.loadListingCounts(); + this.loadListings(); + } else { + const data = await response.json(); + alert(data.error || 'Failed to approve listing'); + } + } catch (error) { + console.error('Failed to approve listing:', error); + alert('Failed to approve listing. Please try again.'); + } + } + + async rejectListingSubmission(listingId) { + const adminNotes = document.getElementById('listingAdminNotes')?.value.trim() || null; + + if (!confirm('Are you sure you want to reject this listing?')) { + return; + } + + try { + const response = await fetch(`/api/admin/listing-submissions/${listingId}/reject`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.password}` + }, + body: JSON.stringify({ admin_notes: adminNotes }) + }); + + if (response.ok) { + alert('Listing submission rejected.'); + this.closeModal('listingModal'); + this.loadListingCounts(); + this.loadListings(); + } else { + const data = await response.json(); + alert(data.error || 'Failed to reject listing'); + } + } catch (error) { + console.error('Failed to reject listing:', error); + alert('Failed to reject listing. Please try again.'); + } + } + + formatType(type) { + const types = { + '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' + }; + return types[type] || type; + } + + formatDate(dateStr) { + const date = new Date(dateStr); + return date.toLocaleDateString('en-CA', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // ========================================== + // Geocoding Management Methods + // ========================================== + + switchGeocodingTab(filter) { + this.geocodingFilter = filter; + + // Update active tab (only for geocoding section) + document.querySelectorAll('.admin-tab[data-section="geocoding"]').forEach(tab => { + tab.classList.toggle('active', tab.dataset.filter === filter); + }); + + this.loadGeoResources(); + } + + async loadGeocodingStats() { + try { + const response = await fetch('/api/resources/map', { + headers: { + 'Authorization': `Bearer ${this.password}` + } + }); + + if (response.ok) { + const data = await response.json(); + const resources = data.resources || []; + + // Calculate stats + let total = 0, high = 0, medium = 0, low = 0; + + resources.forEach(r => { + total++; + const confidence = r.geocode_confidence || 0; + if (confidence >= 80) { + high++; + } else if (confidence >= 50) { + medium++; + } else { + low++; + } + }); + + document.getElementById('geoTotalCount').textContent = total; + document.getElementById('geoHighCount').textContent = high; + document.getElementById('geoMediumCount').textContent = medium; + document.getElementById('geoLowCount').textContent = low; + } + } catch (error) { + console.error('Failed to load geocoding stats:', error); + } + } + + async loadGeoResources() { + const container = document.getElementById('geocodingList'); + const noResults = document.getElementById('noGeoResources'); + const loading = document.getElementById('loadingGeoResources'); + + container.innerHTML = ''; + noResults.classList.add('hidden'); + loading.classList.remove('hidden'); + + try { + const response = await fetch('/api/resources/map', { + headers: { + 'Authorization': `Bearer ${this.password}` + } + }); + + if (response.status === 401) { + this.logout(); + return; + } + + const data = await response.json(); + let resources = data.resources || []; + + // Apply filter + if (this.geocodingFilter === 'low') { + resources = resources.filter(r => (r.geocode_confidence || 0) < 50); + } else if (this.geocodingFilter === 'missing') { + resources = resources.filter(r => !r.latitude || !r.longitude); + } + + // Sort by confidence (lowest first) for better workflow + resources.sort((a, b) => (a.geocode_confidence || 0) - (b.geocode_confidence || 0)); + + this.geoResources = resources; + + loading.classList.add('hidden'); + + if (resources.length === 0) { + noResults.classList.remove('hidden'); + return; + } + + container.innerHTML = resources.map(resource => ` +
+
+
+

${this.escapeHtml(resource.name)}

+ ${this.escapeHtml(resource.city || 'No city')} +
+ ${this.getConfidenceBadge(resource.geocode_confidence, true)} +
+
+
+ Address: ${this.escapeHtml(resource.address) || 'Not set'} +
+
+ Coords: ${resource.latitude && resource.longitude + ? `${parseFloat(resource.latitude).toFixed(4)}, ${parseFloat(resource.longitude).toFixed(4)}` + : 'Missing'} + ${resource.geocode_provider ? ` | Provider: ${this.escapeHtml(resource.geocode_provider)}` : ''} +
+
+
+ +
+
+ `).join(''); + + } catch (error) { + console.error('Failed to load resources:', error); + loading.classList.add('hidden'); + container.innerHTML = '
Failed to load resources. Please try again.
'; + } + } + + openGeocodingModal(resourceId) { + const resource = this.geoResources.find(r => r.id === resourceId); + if (!resource) return; + + const modalBody = document.getElementById('geocodingModalBody'); + + const hasCoords = resource.latitude && resource.longitude; + const confidence = resource.geocode_confidence || 0; + + modalBody.innerHTML = ` +
+
+

${this.escapeHtml(resource.name)}

+ ${this.getConfidenceBadge(confidence, true)} +
+ + + + + + +
+ `; + + document.getElementById('geocodingModal').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + async regeocodeResource(resourceId, button) { + const originalText = button.textContent; + button.textContent = 'Geocoding...'; + button.disabled = true; + + try { + const response = await fetch(`/api/admin/resources/${resourceId}/regeocode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.password}` + } + }); + + const data = await response.json(); + + if (response.ok && data.success) { + const result = data.result; + alert(`Successfully re-geocoded!\n\nLatitude: ${result.latitude.toFixed(6)}\nLongitude: ${result.longitude.toFixed(6)}\nConfidence: ${result.confidence}%\nProvider: ${result.provider}${result.warnings?.length ? '\n\nWarnings:\n' + result.warnings.join('\n') : ''}`); + + // Refresh the geocoding data + this.loadGeocodingStats(); + this.loadGeoResources(); + this.closeModal('geocodingModal'); + } else { + alert(data.error || 'Failed to re-geocode resource'); + button.textContent = originalText; + button.disabled = false; + } + } catch (error) { + console.error('Failed to re-geocode resource:', error); + alert('Failed to re-geocode resource. Please try again.'); + button.textContent = originalText; + button.disabled = false; + } + } + + getConfidenceBadge(confidence, showLabel = false) { + const conf = confidence || 0; + let level, color; + + if (conf >= 80) { + level = 'High'; + color = '#10b981'; + } else if (conf >= 50) { + level = 'Medium'; + color = '#f59e0b'; + } else { + level = 'Low'; + color = '#ef4444'; + } + + const label = showLabel ? `${level} (${conf}%)` : `${conf}%`; + return `${label}`; + } +} + +// Initialize admin panel +const adminPanel = new AdminPanel(); diff --git a/freealberta-food/app/public/js/app.js b/freealberta-food/app/public/js/app.js new file mode 100644 index 0000000..06117e0 --- /dev/null +++ b/freealberta-food/app/public/js/app.js @@ -0,0 +1,1421 @@ +// Free Alberta Food - Main Application + +class FoodResourceApp { + constructor() { + this.resources = []; // Resources displayed in the list (accumulated for infinite scroll) + this.mapResources = []; // All resources for map display + this.filteredResources = []; + this.currentPage = 1; + this.pageSize = 50; + this.totalPages = 1; + this.totalResources = 0; + this.isLoadingMore = false; // Prevent multiple simultaneous loads + this.map = null; + this.directionsMap = null; + this.markers = []; + this.routeLayer = null; + this.userLocation = null; + this.currentRoute = null; + this.currentDestination = null; + this.filters = { + cities: [], // Multi-select: array of city values + types: [], // Multi-select: array of type values + contact: [], // Multi-select: array of contact methods (phone, email, website) + search: '' + }; + + this.init(); + } + + async init() { + this.bindEvents(); + this.initMap(); + this.setupInfiniteScroll(); + await this.loadFilters(); + await Promise.all([ + this.loadResources(), + this.loadMapResources() + ]); + } + + bindEvents() { + // Search + document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch()); + document.getElementById('searchInput').addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.handleSearch(); + }); + + // Location + document.getElementById('locationBtn').addEventListener('click', () => this.handleLocation()); + + // Closest Resource + document.getElementById('closestBtn').addEventListener('click', () => this.handleClosestResource()); + + // Multi-select filters + this.initMultiSelectFilters(); + + // Resource Modal + document.querySelectorAll('#resourceModal .modal-overlay, #resourceModal .modal-close').forEach(el => { + el.addEventListener('click', () => this.closeModal('resourceModal')); + }); + + // Directions Modal + document.querySelectorAll('#directionsModal .modal-overlay, #directionsModal .modal-close').forEach(el => { + el.addEventListener('click', () => this.closeModal('directionsModal')); + }); + + // Update Modal + document.querySelectorAll('#updateModal .modal-overlay, #updateModal .modal-close').forEach(el => { + el.addEventListener('click', () => this.closeModal('updateModal')); + }); + + // Update form submission + document.getElementById('updateForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.submitUpdateRequest(); + }); + + // Travel mode change + document.getElementById('travelMode').addEventListener('change', (e) => { + if (this.currentDestination && this.userLocation) { + this.getDirections(this.currentDestination, e.target.value); + } + }); + + // Print directions + document.getElementById('printDirectionsBtn').addEventListener('click', () => this.printDirections()); + + // Escape key to close modals + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.closeModal('resourceModal'); + this.closeModal('directionsModal'); + this.closeModal('updateModal'); + this.closeModal('addListingModal'); + } + }); + + // Event delegation for resource list + document.getElementById('resourceList').addEventListener('click', (e) => { + // Handle directions button click + const directionsBtn = e.target.closest('.directions-btn'); + if (directionsBtn) { + e.stopPropagation(); + const resourceId = parseInt(directionsBtn.dataset.resourceId); + this.showDirectionsTo(resourceId); + return; + } + + // Handle update button click on card + const updateBtn = e.target.closest('.update-btn'); + if (updateBtn) { + e.stopPropagation(); + const resourceId = parseInt(updateBtn.dataset.resourceId); + this.openUpdateModal(resourceId); + return; + } + + // Handle retry button click + const retryBtn = e.target.closest('.retry-btn'); + if (retryBtn) { + this.loadResources(); + return; + } + + // Handle resource card click (open modal) + const resourceCard = e.target.closest('.resource-card'); + if (resourceCard && !e.target.closest('a')) { + const resourceId = parseInt(resourceCard.dataset.resourceId); + this.openModal(resourceId); + } + }); + + // Event delegation for modal body (directions and update buttons in modal) + document.getElementById('modalBody').addEventListener('click', (e) => { + const directionsBtn = e.target.closest('.modal-directions-btn'); + if (directionsBtn) { + const resourceId = parseInt(directionsBtn.dataset.resourceId); + this.showDirectionsTo(resourceId); + this.closeModal('resourceModal'); + } + + const updateBtn = e.target.closest('.modal-update-btn'); + if (updateBtn) { + const resourceId = parseInt(updateBtn.dataset.resourceId); + this.openUpdateModal(resourceId); + this.closeModal('resourceModal'); + } + }); + + // Event delegation for direction steps + document.getElementById('directionsSteps').addEventListener('click', (e) => { + const step = e.target.closest('.direction-step'); + if (step) { + const stepIndex = parseInt(step.dataset.stepIndex); + this.zoomToStep(stepIndex); + } + }); + + // Add Listing button + document.getElementById('addListingBtn').addEventListener('click', () => this.openAddListingModal()); + + // Add Listing modal + document.querySelectorAll('#addListingModal .modal-overlay, #addListingModal .modal-close').forEach(el => { + el.addEventListener('click', () => this.closeModal('addListingModal')); + }); + + // Add Listing form submission + document.getElementById('addListingForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.submitNewListing(); + }); + } + + initMultiSelectFilters() { + // Initialize each multi-select + ['cityFilterContainer', 'typeFilterContainer', 'contactFilterContainer'].forEach(containerId => { + const container = document.getElementById(containerId); + const btn = container.querySelector('.multi-select-btn'); + const dropdown = container.querySelector('.multi-select-dropdown'); + const clearBtn = container.querySelector('.multi-select-clear'); + const applyBtn = container.querySelector('.multi-select-apply'); + const searchInput = container.querySelector('.multi-select-search-input'); + + // Toggle dropdown + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.closeAllMultiSelects(container); + container.classList.toggle('open'); + dropdown.classList.toggle('hidden'); + if (searchInput && !dropdown.classList.contains('hidden')) { + searchInput.focus(); + } + }); + + // Search filter (for city filter) + if (searchInput) { + searchInput.addEventListener('input', (e) => { + const query = e.target.value.toLowerCase(); + const options = container.querySelectorAll('.multi-select-option'); + options.forEach(opt => { + const text = opt.textContent.toLowerCase(); + opt.style.display = text.includes(query) ? '' : 'none'; + }); + }); + } + + // Clear button + clearBtn.addEventListener('click', () => { + const checkboxes = container.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach(cb => cb.checked = false); + }); + + // Apply button + applyBtn.addEventListener('click', () => { + this.applyMultiSelectFilter(containerId); + container.classList.remove('open'); + dropdown.classList.add('hidden'); + }); + }); + + // Close dropdowns when clicking outside + document.addEventListener('click', (e) => { + if (!e.target.closest('.multi-select')) { + this.closeAllMultiSelects(); + } + }); + } + + closeAllMultiSelects(except = null) { + document.querySelectorAll('.multi-select').forEach(ms => { + if (ms !== except) { + ms.classList.remove('open'); + ms.querySelector('.multi-select-dropdown').classList.add('hidden'); + } + }); + } + + applyMultiSelectFilter(containerId) { + const container = document.getElementById(containerId); + const checkboxes = container.querySelectorAll('input[type="checkbox"]:checked'); + const values = Array.from(checkboxes).map(cb => cb.value); + const label = container.querySelector('.multi-select-label'); + + if (containerId === 'cityFilterContainer') { + this.filters.cities = values; + label.innerHTML = values.length === 0 ? 'All Cities' : + values.length === 1 ? values[0] : + `Cities ${values.length}`; + } else if (containerId === 'typeFilterContainer') { + this.filters.types = values; + label.innerHTML = values.length === 0 ? 'All Types' : + values.length === 1 ? this.formatType(values[0]) : + `Types ${values.length}`; + } else if (containerId === 'contactFilterContainer') { + this.filters.contact = values; + label.innerHTML = values.length === 0 ? 'Any Contact' : + values.length === 1 ? `Has ${values[0].charAt(0).toUpperCase() + values[0].slice(1)}` : + `Contact ${values.length}`; + } + + this.resetAndReload(); + } + + async loadFilters() { + try { + const [citiesRes, typesRes] = await Promise.all([ + fetch('/api/cities'), + fetch('/api/types') + ]); + + const citiesData = await citiesRes.json(); + const typesData = await typesRes.json(); + + // Populate city filter options + const cityOptions = document.getElementById('cityFilterOptions'); + cityOptions.innerHTML = citiesData.cities.map(city => ` + + `).join(''); + + // Populate type filter options + const typeOptions = document.getElementById('typeFilterOptions'); + typeOptions.innerHTML = typesData.types.map(type => ` + + `).join(''); + + } catch (error) { + console.error('Failed to load filters:', error); + } + } + + async loadResources(append = false) { + if (this.isLoadingMore) return; + + this.isLoadingMore = true; + if (!append) { + this.showLoading(true); + } else { + this.showLoadingMore(true); + } + + try { + const params = new URLSearchParams({ + page: this.currentPage, + limit: this.pageSize + }); + + // Multi-select filters: pass as comma-separated values + if (this.filters.cities.length > 0) params.append('cities', this.filters.cities.join(',')); + if (this.filters.types.length > 0) params.append('types', this.filters.types.join(',')); + if (this.filters.contact.length > 0) params.append('contact', this.filters.contact.join(',')); + + let url = '/api/resources?' + params.toString(); + + if (this.filters.search) { + url = `/api/resources/search?q=${encodeURIComponent(this.filters.search)}`; + if (this.filters.cities.length > 0) url += `&cities=${encodeURIComponent(this.filters.cities.join(','))}`; + if (this.filters.types.length > 0) url += `&types=${encodeURIComponent(this.filters.types.join(','))}`; + if (this.filters.contact.length > 0) url += `&contact=${encodeURIComponent(this.filters.contact.join(','))}`; + } + + const response = await fetch(url); + const data = await response.json(); + + const newResources = data.resources || []; + + if (append) { + this.resources = [...this.resources, ...newResources]; + } else { + this.resources = newResources; + } + + if (data.pagination) { + this.totalPages = data.pagination.pages; + this.totalResources = data.pagination.total; + this.currentPage = data.pagination.page; + } + + this.renderResources(append); + this.updateResultCount(); + } catch (error) { + console.error('Failed to load resources:', error); + if (!append) { + this.showError('Failed to load resources. Please try again.'); + } + } finally { + this.isLoadingMore = false; + if (!append) { + this.showLoading(false); + } else { + this.showLoadingMore(false); + } + } + } + + async loadMapResources() { + try { + const params = new URLSearchParams(); + if (this.filters.cities.length > 0) params.append('cities', this.filters.cities.join(',')); + if (this.filters.types.length > 0) params.append('types', this.filters.types.join(',')); + if (this.filters.contact.length > 0) params.append('contact', this.filters.contact.join(',')); + + const url = '/api/resources/map' + (params.toString() ? '?' + params.toString() : ''); + const response = await fetch(url); + const data = await response.json(); + + this.mapResources = data.resources || []; + this.updateMapMarkers(); + } catch (error) { + console.error('Failed to load map resources:', error); + } + } + + resetAndReload() { + this.currentPage = 1; + this.resources = []; + Promise.all([ + this.loadResources(), + this.loadMapResources() + ]); + } + + setupInfiniteScroll() { + const listContainer = document.getElementById('listContainer'); + + listContainer.addEventListener('scroll', () => { + if (this.isLoadingMore) return; + + const { scrollTop, scrollHeight, clientHeight } = listContainer; + + // Load more when user scrolls to within 200px of the bottom + if (scrollTop + clientHeight >= scrollHeight - 200) { + this.loadMoreResources(); + } + }); + } + + loadMoreResources() { + if (this.isLoadingMore) return; + if (this.currentPage >= this.totalPages) return; + + this.currentPage++; + this.loadResources(true); + } + + showLoadingMore(show) { + let loadingMore = document.getElementById('loadingMoreIndicator'); + + if (!loadingMore) { + loadingMore = document.createElement('div'); + loadingMore.id = 'loadingMoreIndicator'; + loadingMore.className = 'loading-more-indicator hidden'; + loadingMore.innerHTML = '
Loading more...'; + document.getElementById('resourceList').after(loadingMore); + } + + if (show) { + loadingMore.classList.remove('hidden'); + } else { + loadingMore.classList.add('hidden'); + } + } + + async handleSearch() { + const searchInput = document.getElementById('searchInput'); + this.filters.search = searchInput.value.trim(); + this.resetAndReload(); + } + + async handleLocation() { + if (!navigator.geolocation) { + alert('Geolocation is not supported by your browser'); + return; + } + + const btn = document.getElementById('locationBtn'); + btn.disabled = true; + + navigator.geolocation.getCurrentPosition( + async (position) => { + const { latitude, longitude } = position.coords; + this.userLocation = { lat: latitude, lng: longitude }; + + try { + const response = await fetch( + `/api/resources/nearby?lat=${latitude}&lng=${longitude}&radius=25` + ); + const data = await response.json(); + + this.resources = data.resources || []; + this.renderResources(); + this.updateResultCount(); + + this.updateMapMarkers(); + + if (this.map) { + this.map.setView([latitude, longitude], 14); + this.addUserLocationMarker(); + } + } catch (error) { + console.error('Failed to fetch nearby resources:', error); + } + + btn.disabled = false; + }, + (error) => { + let message = 'Unable to get your location.'; + if (error.message.includes('secure origins') || error.code === 1) { + message = 'Location access requires HTTPS. Please use the "Open in Maps" button to navigate, or access this site via HTTPS.'; + } else { + message += ' ' + error.message; + } + alert(message); + btn.disabled = false; + } + ); + } + + async handleClosestResource() { + if (!navigator.geolocation) { + alert('Geolocation is not supported by your browser'); + return; + } + + const btn = document.getElementById('closestBtn'); + btn.disabled = true; + + navigator.geolocation.getCurrentPosition( + async (position) => { + const { latitude, longitude } = position.coords; + this.userLocation = { lat: latitude, lng: longitude }; + + try { + // Fetch nearby resources and find the closest with a full address + const response = await fetch( + `/api/resources/nearby?lat=${latitude}&lng=${longitude}&radius=100&limit=20` + ); + const data = await response.json(); + + // Filter to only resources with a full street address (not just coordinates) + const resourcesWithAddress = (data.resources || []).filter(r => { + if (!r.address || !r.city) return false; + const addr = r.address.trim(); + // Must have address with street number, not PO Box or general delivery + return addr && + !addr.toLowerCase().startsWith('po box') && + !addr.toLowerCase().startsWith('p.o. box') && + !addr.toLowerCase().startsWith('general delivery') && + /\d/.test(addr); + }); + + // Debug: show all resources with distances and address info + console.log('Nearby resources with distances:'); + data.resources.forEach((r, i) => { + const hasAddr = r.address && r.city && /\d/.test(r.address); + console.log(` ${i + 1}. [${r.distance_km?.toFixed(2)} km] ${r.name} (ID: ${r.id}) ${hasAddr ? '✓' : '✗ no address'}`); + }); + + // Check if UofA/University is in the list + const uniResources = data.resources.filter(r => + r.name.toLowerCase().includes('university') || + r.name.toLowerCase().includes('uofa') || + r.name.toLowerCase().includes('u of a') + ); + if (uniResources.length > 0) { + console.log('University-related resources found:', uniResources.map(r => `${r.name} at ${r.distance_km?.toFixed(2)} km`)); + } + + console.log('Filtered resources with address:', resourcesWithAddress.length); + + if (resourcesWithAddress.length > 0) { + const closest = resourcesWithAddress[0]; + console.log('Closest resource:', closest.id, closest.name, `${closest.distance_km?.toFixed(2)} km`); + + // Fetch the full resource details first to ensure consistency + const detailResponse = await fetch(`/api/resources/${closest.id}`); + const detailData = await detailResponse.json(); + const resource = detailData.resource; + + if (resource) { + console.log('Fetched resource:', resource.id, resource.name, resource.latitude, resource.longitude); + + // Open the modal for this resource + await this.openModal(resource.id); + + // Zoom to show both user and the fetched resource's coordinates + if (this.map && resource.latitude && resource.longitude) { + this.addUserLocationMarker(); + const bounds = L.latLngBounds( + [latitude, longitude], + [resource.latitude, resource.longitude] + ); + this.map.fitBounds(bounds.pad(0.3)); + } + } else { + alert('Could not load resource details.'); + } + } else { + alert('No resources with full addresses found nearby. Try expanding your search.'); + } + } catch (error) { + console.error('Failed to fetch closest resource:', error); + alert('Failed to find closest resource. Please try again.'); + } + + btn.disabled = false; + }, + (error) => { + let message = 'Unable to get your location.'; + if (error.message.includes('secure origins') || error.code === 1) { + message = 'Location access requires HTTPS or permission. Please enable location access.'; + } else { + message += ' ' + error.message; + } + alert(message); + btn.disabled = false; + } + ); + } + + addUserLocationMarker() { + if (!this.map || !this.userLocation) return; + + // Remove existing user marker if any + if (this.userMarker) { + this.userMarker.remove(); + } + + this.userMarker = L.marker([this.userLocation.lat, this.userLocation.lng], { + icon: L.divIcon({ + className: 'user-location-marker', + html: '
', + iconSize: [16, 16] + }) + }).addTo(this.map).bindPopup('Your location'); + } + + initMap() { + this.map = L.map('map').setView([53.9333, -116.5765], 6); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + } + + initDirectionsMap() { + if (this.directionsMap) { + this.directionsMap.remove(); + } + + this.directionsMap = L.map('directionsMap').setView([53.9333, -116.5765], 10); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.directionsMap); + } + + updateMapMarkers() { + if (!this.map) return; + + this.markers.forEach(marker => marker.remove()); + this.markers = []; + + // Use mapResources (all resources) for map display + const resourcesWithCoords = this.mapResources.filter(r => r.latitude && r.longitude); + + resourcesWithCoords.forEach(resource => { + const popupContent = document.createElement('div'); + popupContent.innerHTML = ` + + +
+ + +
+ `; + + // Bind events to popup buttons + popupContent.querySelector('.popup-details-btn').addEventListener('click', (e) => { + this.openModal(parseInt(e.target.dataset.resourceId)); + }); + popupContent.querySelector('.popup-directions-btn').addEventListener('click', (e) => { + this.showDirectionsTo(parseInt(e.target.dataset.resourceId)); + }); + + const marker = L.marker([resource.latitude, resource.longitude]) + .addTo(this.map) + .bindPopup(popupContent); + + this.markers.push(marker); + }); + + if (this.markers.length > 0) { + const group = L.featureGroup(this.markers); + this.map.fitBounds(group.getBounds().pad(0.1)); + } + } + + renderResources(append = false) { + const container = document.getElementById('resourceList'); + const noResults = document.getElementById('noResults'); + + if (this.resources.length === 0) { + container.innerHTML = ''; + noResults.classList.remove('hidden'); + return; + } + + noResults.classList.add('hidden'); + + const resourcesHtml = this.resources.map(resource => ` +
+
+

${this.escapeHtml(resource.name)}

+ ${this.formatType(resource.resource_type)} +
+
Click for more details
+
+ ${resource.address ? ` +
+ + + + + ${this.escapeHtml(resource.address)}${resource.city ? `, ${resource.city}` : ''} +
+ ` : resource.city ? ` +
+ + + + + ${this.escapeHtml(resource.city)} +
+ ` : ''} + ${resource.phone ? ` +
+ + + + ${this.escapeHtml(resource.phone)} +
+ ` : ''} + ${resource.email ? ` +
+ + + + + ${this.escapeHtml(resource.email)} +
+ ` : ''} + ${resource.hours_of_operation ? ` +
+ + + + + ${this.escapeHtml(resource.hours_of_operation.substring(0, 60))}${resource.hours_of_operation.length > 60 ? '...' : ''} +
+ ` : ''} +
+
+ ${resource.phone ? ` + Call + ` : ''} + ${this.hasLocationData(resource) ? ` + + Open in Maps + ` : ''} + ${resource.website ? ` + Website + ` : ''} + ${this.hasIncompleteInfo(resource) ? ` + + ` : ''} +
+
+ `).join(''); + + // For infinite scroll: always replace since we accumulate in this.resources + container.innerHTML = resourcesHtml; + } + + async openModal(id) { + try { + const response = await fetch(`/api/resources/${id}`); + const data = await response.json(); + const resource = data.resource; + + if (!resource) { + alert('Resource not found'); + return; + } + + const modalBody = document.getElementById('modalBody'); + modalBody.innerHTML = ` + + + ${resource.description ? ` + + ` : ''} + + ${resource.address || resource.city ? ` + + ` : ''} + + ${resource.phone || resource.email || resource.website ? ` + + ` : ''} + + ${resource.hours_of_operation ? ` + + ` : ''} + + ${resource.eligibility ? ` + + ` : ''} + + ${resource.services_offered ? ` + + ` : ''} + + + + + +
+ Last updated: ${new Date(resource.updated_at).toLocaleDateString()} + ${resource.source_url ? ` | Source` : ''} +
+ `; + + document.getElementById('resourceModal').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } catch (error) { + console.error('Failed to load resource details:', error); + alert('Failed to load resource details'); + } + } + + async showDirectionsTo(resourceId) { + // Get user location first if not available + if (!this.userLocation) { + const gotLocation = await this.getUserLocation(); + if (!gotLocation) { + alert('Please enable location access to get directions'); + return; + } + } + + // Get resource details + try { + const response = await fetch(`/api/resources/${resourceId}`); + const data = await response.json(); + const resource = data.resource; + + if (!resource || (!resource.latitude && !resource.address)) { + alert('Location not available for this resource'); + return; + } + + this.currentDestination = resource; + + // If no coordinates, try to geocode the address + if (!resource.latitude || !resource.longitude) { + try { + const geoResponse = await fetch(`/api/geocode?address=${encodeURIComponent(resource.address + ', ' + (resource.city || 'Alberta'))}`); + const geoData = await geoResponse.json(); + if (geoData.success) { + resource.latitude = geoData.result.latitude; + resource.longitude = geoData.result.longitude; + } else { + alert('Could not locate this address'); + return; + } + } catch (e) { + alert('Could not locate this address'); + return; + } + } + + this.currentDestination = resource; + const mode = document.getElementById('travelMode').value; + await this.getDirections(resource, mode); + + } catch (error) { + console.error('Failed to get directions:', error); + alert('Failed to get directions'); + } + } + + getUserLocation() { + return new Promise((resolve) => { + if (this.userLocation) { + resolve(true); + return; + } + + if (!navigator.geolocation) { + resolve(false); + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => { + this.userLocation = { + lat: position.coords.latitude, + lng: position.coords.longitude + }; + resolve(true); + }, + () => { + resolve(false); + }, + { timeout: 10000 } + ); + }); + } + + async getDirections(destination, mode = 'driving') { + try { + const response = await fetch( + `/api/directions?startLat=${this.userLocation.lat}&startLng=${this.userLocation.lng}&endLat=${destination.latitude}&endLng=${destination.longitude}&profile=${mode}` + ); + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to get directions'); + } + + this.currentRoute = data.route; + this.displayDirections(destination, data.route, mode); + + } catch (error) { + console.error('Directions error:', error); + alert('Could not calculate route: ' + error.message); + } + } + + displayDirections(destination, route, mode) { + // Show modal + document.getElementById('directionsModal').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + + // Update summary + const modeText = mode === 'driving' ? 'Drive' : mode === 'walking' ? 'Walk' : 'Cycle'; + document.getElementById('directionsSummary').innerHTML = ` +
+ Distance + ${route.distanceText} +
+
+ ${modeText} Time + ${route.durationText} +
+
+ To + ${this.escapeHtml(destination.name)} +
+ `; + + // Initialize directions map + setTimeout(() => { + this.initDirectionsMap(); + this.drawRoute(route, destination); + }, 100); + + // Display turn-by-turn steps + this.displaySteps(route.steps); + } + + drawRoute(route, destination) { + if (!this.directionsMap) return; + + // Clear existing route + if (this.routeLayer) { + this.routeLayer.remove(); + } + + // Draw route line + const coordinates = route.geometry.coordinates.map(coord => [coord[1], coord[0]]); + this.routeLayer = L.polyline(coordinates, { + color: '#2563eb', + weight: 5, + opacity: 0.8 + }).addTo(this.directionsMap); + + // Add start marker (user location) + L.marker([this.userLocation.lat, this.userLocation.lng], { + icon: L.divIcon({ + className: 'start-marker', + html: '
A
', + iconSize: [20, 20], + iconAnchor: [10, 10] + }) + }).addTo(this.directionsMap).bindPopup('Your location'); + + // Add destination marker + L.marker([destination.latitude, destination.longitude], { + icon: L.divIcon({ + className: 'end-marker', + html: '
B
', + iconSize: [20, 20], + iconAnchor: [10, 10] + }) + }).addTo(this.directionsMap).bindPopup(destination.name); + + // Fit map to route bounds + this.directionsMap.fitBounds(route.bounds, { padding: [30, 30] }); + } + + displaySteps(steps) { + const container = document.getElementById('directionsSteps'); + + container.innerHTML = steps.map((step, index) => { + let numberClass = ''; + if (index === 0) numberClass = 'start'; + else if (index === steps.length - 1) numberClass = 'end'; + + return ` +
+
${index + 1}
+
+
${this.escapeHtml(step.instruction)}
+
${step.distanceText} - ${step.durationText}
+
+
+ `; + }).join(''); + } + + zoomToStep(stepIndex) { + if (!this.directionsMap || !this.currentRoute) return; + + const step = this.currentRoute.steps[stepIndex]; + if (step && step.maneuver && step.maneuver.location) { + this.directionsMap.setView(step.maneuver.location, 16); + } + } + + printDirections() { + if (!this.currentRoute || !this.currentDestination) return; + + const printContent = document.getElementById('printDirections'); + const printDate = document.getElementById('printDate'); + const route = this.currentRoute; + const dest = this.currentDestination; + const mode = document.getElementById('travelMode').value; + const modeText = mode === 'driving' ? 'Drive' : mode === 'walking' ? 'Walk' : 'Cycle'; + const modeIcon = mode === 'driving' ? 'By car' : mode === 'walking' ? 'On foot' : 'By bicycle'; + + // Set the print date + const now = new Date(); + printDate.textContent = `Generated on ${now.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + })}`; + + // Build address string + const addressParts = []; + if (dest.address) addressParts.push(dest.address); + if (dest.city) addressParts.push(dest.city); + if (dest.province) addressParts.push(dest.province); + if (dest.postal_code) addressParts.push(dest.postal_code); + const fullAddress = addressParts.join(', '); + + printContent.innerHTML = ` + + + + + + + `; + + window.print(); + } + + async openUpdateModal(resourceId) { + try { + const response = await fetch(`/api/resources/${resourceId}`); + const data = await response.json(); + const resource = data.resource; + + if (!resource) { + alert('Resource not found'); + return; + } + + // Store resource ID + document.getElementById('updateResourceId').value = resourceId; + + // Clear form + document.getElementById('updateForm').reset(); + + // Pre-populate fields with current values as placeholders + document.getElementById('proposedName').placeholder = resource.name || 'Resource name'; + document.getElementById('proposedResourceType').value = ''; + document.getElementById('proposedCity').placeholder = resource.city || 'City'; + document.getElementById('proposedAddress').placeholder = resource.address || 'Street address'; + document.getElementById('proposedPhone').placeholder = resource.phone || 'Phone number'; + document.getElementById('proposedEmail').placeholder = resource.email || 'Contact email'; + document.getElementById('proposedWebsite').placeholder = resource.website || 'https://...'; + document.getElementById('proposedHours').placeholder = resource.hours_of_operation || 'Hours of operation'; + document.getElementById('proposedDescription').placeholder = resource.description || 'Description'; + document.getElementById('proposedEligibility').placeholder = resource.eligibility || 'Eligibility requirements'; + document.getElementById('proposedServices').placeholder = resource.services_offered || 'Services offered'; + + // Show modal + document.getElementById('updateModal').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + + } catch (error) { + console.error('Failed to open update modal:', error); + alert('Failed to load resource details'); + } + } + + async submitUpdateRequest() { + const resourceId = document.getElementById('updateResourceId').value; + const submitterEmail = document.getElementById('submitterEmail').value.trim(); + const submitterName = document.getElementById('submitterName').value.trim(); + + if (!submitterEmail) { + alert('Please enter your email address'); + return; + } + + const data = { + submitter_email: submitterEmail, + submitter_name: submitterName || null, + proposed_name: document.getElementById('proposedName').value.trim() || null, + proposed_resource_type: document.getElementById('proposedResourceType').value || null, + proposed_city: document.getElementById('proposedCity').value.trim() || null, + proposed_address: document.getElementById('proposedAddress').value.trim() || null, + proposed_phone: document.getElementById('proposedPhone').value.trim() || null, + proposed_email: document.getElementById('proposedEmail').value.trim() || null, + proposed_website: document.getElementById('proposedWebsite').value.trim() || null, + proposed_hours_of_operation: document.getElementById('proposedHours').value.trim() || null, + proposed_description: document.getElementById('proposedDescription').value.trim() || null, + proposed_eligibility: document.getElementById('proposedEligibility').value.trim() || null, + proposed_services_offered: document.getElementById('proposedServices').value.trim() || null, + additional_notes: document.getElementById('additionalNotes').value.trim() || null + }; + + // Check if at least one change is proposed + const hasChanges = Object.entries(data) + .filter(([key]) => key.startsWith('proposed_')) + .some(([, value]) => value !== null); + + if (!hasChanges && !data.additional_notes) { + alert('Please enter at least one proposed change or note'); + return; + } + + try { + const response = await fetch(`/api/resources/${resourceId}/update-request`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (response.ok) { + alert('Thank you! Your update request has been submitted and will be reviewed by our team.'); + this.closeModal('updateModal'); + } else { + alert(result.error || 'Failed to submit update request'); + } + + } catch (error) { + console.error('Failed to submit update request:', error); + alert('Failed to submit update request. Please try again.'); + } + } + + openAddListingModal() { + // Clear form + document.getElementById('addListingForm').reset(); + + // Show modal + document.getElementById('addListingModal').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + async submitNewListing() { + const submitterEmail = document.getElementById('newListingSubmitterEmail').value.trim(); + const name = document.getElementById('newListingName').value.trim(); + + if (!submitterEmail) { + alert('Please enter your email address'); + return; + } + + if (!name) { + alert('Please enter a name for the listing'); + return; + } + + const data = { + submitter_email: submitterEmail, + submitter_name: document.getElementById('newListingSubmitterName').value.trim() || null, + name: name, + resource_type: document.getElementById('newListingResourceType').value || 'other', + city: document.getElementById('newListingCity').value.trim() || null, + address: document.getElementById('newListingAddress').value.trim() || null, + phone: document.getElementById('newListingPhone').value.trim() || null, + email: document.getElementById('newListingEmail').value.trim() || null, + website: document.getElementById('newListingWebsite').value.trim() || null, + hours_of_operation: document.getElementById('newListingHours').value.trim() || null, + description: document.getElementById('newListingDescription').value.trim() || null, + eligibility: document.getElementById('newListingEligibility').value.trim() || null, + services_offered: document.getElementById('newListingServices').value.trim() || null, + additional_notes: document.getElementById('newListingNotes').value.trim() || null + }; + + try { + const response = await fetch('/api/listings/submit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (response.ok) { + alert('Thank you! Your listing has been submitted and will be reviewed by our team.'); + this.closeModal('addListingModal'); + } else { + alert(result.error || 'Failed to submit listing'); + } + + } catch (error) { + console.error('Failed to submit listing:', error); + alert('Failed to submit listing. Please try again.'); + } + } + + closeModal(modalId) { + document.getElementById(modalId).classList.add('hidden'); + document.body.style.overflow = ''; + + // Clean up directions map + if (modalId === 'directionsModal' && this.directionsMap) { + this.directionsMap.remove(); + this.directionsMap = null; + } + } + + updateResultCount() { + const count = document.getElementById('resultCount'); + if (this.totalResources > 0 && this.resources.length < this.totalResources) { + count.textContent = `Showing ${this.resources.length} of ${this.totalResources} resources`; + } else { + count.textContent = `${this.resources.length} resource${this.resources.length !== 1 ? 's' : ''}`; + } + } + + showLoading(show) { + const loading = document.getElementById('loadingIndicator'); + const list = document.getElementById('resourceList'); + + if (show) { + loading.classList.remove('hidden'); + list.innerHTML = ''; + } else { + loading.classList.add('hidden'); + } + } + + showError(message) { + const container = document.getElementById('resourceList'); + container.innerHTML = ` +
+

${message}

+ +
+ `; + } + + formatType(type) { + const types = { + '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' + }; + return types[type] || type; + } + + escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + hasLocationData(resource) { + // Check if resource has enough location data for directions/maps + // Must have a valid street address (with number) or valid coordinates + + // Check for valid coordinates + if (resource.latitude && resource.longitude) { + const lat = parseFloat(resource.latitude); + const lng = parseFloat(resource.longitude); + // Validate coordinates are in Alberta region (roughly) + if (!isNaN(lat) && !isNaN(lng) && lat >= 48 && lat <= 60 && lng >= -120 && lng <= -110) { + return true; + } + } + + // Check for valid street address + if (resource.address && resource.city) { + const addr = resource.address.trim(); + // Skip PO Boxes, general delivery, and addresses without street numbers + if (addr && + !addr.toLowerCase().startsWith('po box') && + !addr.toLowerCase().startsWith('p.o. box') && + !addr.toLowerCase().startsWith('general delivery') && + /\d/.test(addr)) { // Must contain at least one digit (street number) + return true; + } + } + + return false; + } + + hasIncompleteInfo(resource) { + // Check if resource is missing important information + return !resource.phone || + !resource.address || + !this.hasLocationData(resource); + } + + getMapsUrl(resource) { + // Build a search query for maps - prefer address for better label in Maps app + const parts = []; + if (resource.address) parts.push(resource.address); + if (resource.city) parts.push(resource.city); + parts.push(resource.province || 'Alberta'); + if (resource.postal_code) parts.push(resource.postal_code); + + // Use address if we have at least city + if (parts.length >= 2) { + const query = encodeURIComponent(parts.join(', ')); + return `https://www.google.com/maps/search/?api=1&query=${query}`; + } + + // Fall back to coordinates if no address info + if (resource.latitude && resource.longitude) { + return `https://www.google.com/maps/search/?api=1&query=${resource.latitude},${resource.longitude}`; + } + + // Last resort - just the name + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(resource.name + ', Alberta')}`; + } +} + +// Initialize app +const app = new FoodResourceApp(); diff --git a/freealberta-food/app/routes/api.js b/freealberta-food/app/routes/api.js new file mode 100644 index 0000000..2467fc1 --- /dev/null +++ b/freealberta-food/app/routes/api.js @@ -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; diff --git a/freealberta-food/app/scrapers/ab211.js b/freealberta-food/app/scrapers/ab211.js new file mode 100644 index 0000000..154889c --- /dev/null +++ b/freealberta-food/app/scrapers/ab211.js @@ -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 }; diff --git a/freealberta-food/app/scrapers/informalberta.js b/freealberta-food/app/scrapers/informalberta.js new file mode 100644 index 0000000..2cf8ca3 --- /dev/null +++ b/freealberta-food/app/scrapers/informalberta.js @@ -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 }; diff --git a/freealberta-food/app/scrapers/pdf-parser.js b/freealberta-food/app/scrapers/pdf-parser.js new file mode 100644 index 0000000..d74b1f6 --- /dev/null +++ b/freealberta-food/app/scrapers/pdf-parser.js @@ -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 }; diff --git a/freealberta-food/app/scrapers/run-all.js b/freealberta-food/app/scrapers/run-all.js new file mode 100644 index 0000000..a687c68 --- /dev/null +++ b/freealberta-food/app/scrapers/run-all.js @@ -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 }; diff --git a/freealberta-food/app/scripts/batch-geocode.js b/freealberta-food/app/scripts/batch-geocode.js new file mode 100644 index 0000000..1fb4347 --- /dev/null +++ b/freealberta-food/app/scripts/batch-geocode.js @@ -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 }; diff --git a/freealberta-food/app/server.js b/freealberta-food/app/server.js new file mode 100644 index 0000000..c21d2a3 --- /dev/null +++ b/freealberta-food/app/server.js @@ -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; diff --git a/freealberta-food/app/services/geocoding.js b/freealberta-food/app/services/geocoding.js new file mode 100644 index 0000000..0cb7e12 --- /dev/null +++ b/freealberta-food/app/services/geocoding.js @@ -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 +}; diff --git a/freealberta-food/app/services/routing.js b/freealberta-food/app/services/routing.js new file mode 100644 index 0000000..7653c59 --- /dev/null +++ b/freealberta-food/app/services/routing.js @@ -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} 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 +}; diff --git a/freealberta-food/app/utils/logger.js b/freealberta-food/app/utils/logger.js new file mode 100644 index 0000000..07c1991 --- /dev/null +++ b/freealberta-food/app/utils/logger.js @@ -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; diff --git a/freealberta-food/docker-compose.yml b/freealberta-food/docker-compose.yml new file mode 100644 index 0000000..1fe19dc --- /dev/null +++ b/freealberta-food/docker-compose.yml @@ -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 diff --git a/freealberta-lander/docker-compose.yml b/freealberta-lander/docker-compose.yml new file mode 100644 index 0000000..0be0eb5 --- /dev/null +++ b/freealberta-lander/docker-compose.yml @@ -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 diff --git a/freealberta-lander/nginx/default.conf b/freealberta-lander/nginx/default.conf new file mode 100644 index 0000000..4a59419 --- /dev/null +++ b/freealberta-lander/nginx/default.conf @@ -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; +} diff --git a/freealberta-lander/public/assets/freealberta-logo-long-opaque.gif b/freealberta-lander/public/assets/freealberta-logo-long-opaque.gif new file mode 100644 index 0000000..98ea41b Binary files /dev/null and b/freealberta-lander/public/assets/freealberta-logo-long-opaque.gif differ diff --git a/freealberta-lander/public/assets/freealberta-logo.gif b/freealberta-lander/public/assets/freealberta-logo.gif new file mode 100644 index 0000000..9001112 Binary files /dev/null and b/freealberta-lander/public/assets/freealberta-logo.gif differ diff --git a/freealberta-lander/public/css/style.css b/freealberta-lander/public/css/style.css new file mode 100644 index 0000000..21d6bb2 --- /dev/null +++ b/freealberta-lander/public/css/style.css @@ -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; + } +} diff --git a/freealberta-lander/public/index.html b/freealberta-lander/public/index.html new file mode 100644 index 0000000..6044b3e --- /dev/null +++ b/freealberta-lander/public/index.html @@ -0,0 +1,96 @@ + + + + + + + Free Alberta - Redefining Freedom + + + + + + +
+
+
+ +
+

Redefining Freedom in Alberta

+

+ Because real freedom means everyone has access to what they need. + Not just the wealthy. Not just the connected. Everyone. +

+
+ +
+ +
+ + + +
+

Find Food

+

Because no one should go hungry in Canada's richest province

+
+ + +
+ + + +
+

Policy & Action

+

Real change requires real demands. See what we're fighting for.

+
+ + +
+ + + +
+

Influence

+

Write to those pulling the strings in Alberta politics

+
+
+ + +
+ + + + diff --git a/influence/docker-compose.yml b/influence/docker-compose.yml index d8cdb61..5f410c0 100644 --- a/influence/docker-compose.yml +++ b/influence/docker-compose.yml @@ -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 diff --git a/influence/example.env b/influence/example.env deleted file mode 100644 index b9cf545..0000000 --- a/influence/example.env +++ /dev/null @@ -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 \ No newline at end of file diff --git a/mkdocs/mkdocs.yml b/mkdocs/mkdocs.yml index fe11145..d61be06 100644 --- a/mkdocs/mkdocs.yml +++ b/mkdocs/mkdocs.yml @@ -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 @@ -25,12 +28,14 @@ theme: name: Switch to dark mode features: - navigation.instant - - navigation.instant.progress + - 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 @@ -69,7 +74,7 @@ extra: generator: false social: - icon: material/web - link: https://changemaker.bnkops.com + link: https://freealberta.org plugins: - social diff --git a/mkdocs/site/404.html b/mkdocs/site/404.html index d417a77..99d6ddf 100644 --- a/mkdocs/site/404.html +++ b/mkdocs/site/404.html @@ -269,6 +269,18 @@ + + @@ -564,6 +576,18 @@ Free Alberta + +