386 lines
12 KiB
JavaScript
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
|
|
};
|