386 lines
12 KiB
JavaScript

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
};