diff --git a/backend/routes/forum.js b/backend/routes/forum.js index 52379b5..ac75b73 100644 --- a/backend/routes/forum.js +++ b/backend/routes/forum.js @@ -240,7 +240,7 @@ router.get('/posts/:id', optionalAuth, async (req, res) => { // POST /api/forum/posts - Create new post router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) => { try { - let { title, content, category, tags, zipCode } = req.body; + let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng } = req.body; // Parse tags if they come as JSON string (from FormData) if (typeof tags === 'string') { @@ -258,26 +258,53 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) let latitude = null; let longitude = null; - // Geocode zip code for item requests + // Use provided coordinates if available, otherwise geocode zip code if (category === 'item_request' && zipCode) { - try { - const geocodeResult = await googleMapsService.geocodeAddress(zipCode); - latitude = geocodeResult.latitude; - longitude = geocodeResult.longitude; + // If coordinates were provided from a saved address, use them directly + if (providedLat && providedLng) { + latitude = parseFloat(providedLat); + longitude = parseFloat(providedLng); const reqLogger = logger.withRequestId(req.id); - reqLogger.info("Geocoded zip code for item request", { + reqLogger.info("Using provided coordinates for item request", { zipCode, latitude, - longitude + longitude, + source: 'saved_address' }); - } catch (error) { - const reqLogger = logger.withRequestId(req.id); - reqLogger.error("Geocoding failed for item request", { - error: error.message, - zipCode - }); - // Continue without coordinates - post will still be created + } else { + // Otherwise, geocode the zip code + try { + const geocodeResult = await googleMapsService.geocodeAddress(zipCode); + + // Check if geocoding was successful + if (geocodeResult.error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Geocoding failed for item request", { + error: geocodeResult.error, + status: geocodeResult.status, + zipCode + }); + } else if (geocodeResult.latitude && geocodeResult.longitude) { + latitude = geocodeResult.latitude; + longitude = geocodeResult.longitude; + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Geocoded zip code for item request", { + zipCode, + latitude, + longitude, + source: 'geocoded' + }); + } + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Geocoding failed for item request", { + error: error.message, + zipCode + }); + // Continue without coordinates - post will still be created + } } } @@ -332,6 +359,13 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) if (category === 'item_request' && latitude && longitude) { (async () => { try { + logger.info("Starting item request notifications", { + postId: post.id, + latitude, + longitude, + zipCode + }); + // Find all users within maximum radius (100 miles) const nearbyUsers = await locationService.findUsersInRadius( latitude, @@ -339,10 +373,17 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) 100 ); + logger.info("Found nearby users", { + postId: post.id, + count: nearbyUsers.length, + users: nearbyUsers.map(u => ({ id: u.id, distance: u.distance })) + }); + const postAuthor = await User.findByPk(req.user.id); let notificationsSent = 0; let usersChecked = 0; + let usersSkipped = 0; for (const user of nearbyUsers) { // Don't notify the requester @@ -356,6 +397,17 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) const userPreferredRadius = userProfile?.itemRequestNotificationRadius || 10; + logger.info("Checking user notification eligibility", { + postId: post.id, + userId: user.id, + userEmail: user.email, + userCoordinates: { lat: user.latitude, lng: user.longitude }, + postCoordinates: { lat: latitude, lng: longitude }, + userDistance: user.distance, + userPreferredRadius, + willNotify: parseFloat(user.distance) <= userPreferredRadius + }); + // Only notify if within user's preferred radius if (parseFloat(user.distance) <= userPreferredRadius) { try { @@ -366,6 +418,11 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) user.distance ); notificationsSent++; + logger.info("Sent notification to user", { + postId: post.id, + userId: user.id, + distance: user.distance + }); } catch (emailError) { logger.error("Failed to send item request notification", { error: emailError.message, @@ -373,14 +430,17 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) postId: post.id }); } + } else { + usersSkipped++; } } } - logger.info("Item request notifications sent", { + logger.info("Item request notifications complete", { postId: post.id, totalNearbyUsers: nearbyUsers.length, usersChecked, + usersSkipped, notificationsSent }); } catch (error) { @@ -391,6 +451,13 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) }); } })(); + } else if (category === 'item_request') { + logger.warn("Item request created without location", { + postId: post.id, + zipCode, + hasLatitude: !!latitude, + hasLongitude: !!longitude + }); } } catch (error) { const reqLogger = logger.withRequestId(req.id); diff --git a/backend/services/locationService.js b/backend/services/locationService.js index 18c76de..1c5dc72 100644 --- a/backend/services/locationService.js +++ b/backend/services/locationService.js @@ -20,6 +20,12 @@ class LocationService { throw new Error('Radius must be between 1 and 100 miles'); } + console.log('Finding users in radius:', { + centerLatitude: latitude, + centerLongitude: longitude, + radiusMiles + }); + try { // Haversine formula: // distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2)) @@ -27,26 +33,28 @@ class LocationService { // + sin(radians(lat1)) * sin(radians(lat2))) // Note: 3959 is Earth's radius in miles const query = ` - SELECT - u.id, - u.email, - u."firstName", - u."lastName", - ua.latitude, - ua.longitude, - (3959 * acos( - LEAST(1.0, - cos(radians(:lat)) * cos(radians(ua.latitude)) - * cos(radians(ua.longitude) - radians(:lng)) - + sin(radians(:lat)) * sin(radians(ua.latitude)) - ) - )) AS distance - FROM "Users" u - INNER JOIN "UserAddresses" ua ON u.id = ua."userId" - WHERE ua."isPrimary" = true - AND ua.latitude IS NOT NULL - AND ua.longitude IS NOT NULL - HAVING distance < :radiusMiles + SELECT * FROM ( + SELECT + u.id, + u.email, + u."firstName", + u."lastName", + ua.latitude, + ua.longitude, + (3959 * acos( + LEAST(1.0, + cos(radians(:lat)) * cos(radians(ua.latitude)) + * cos(radians(ua.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(ua.latitude)) + ) + )) AS distance + FROM "Users" u + INNER JOIN "UserAddresses" ua ON u.id = ua."userId" + WHERE ua."isPrimary" = true + AND ua.latitude IS NOT NULL + AND ua.longitude IS NOT NULL + ) AS user_distances + WHERE distance < :radiusMiles ORDER BY distance ASC `; @@ -59,6 +67,13 @@ class LocationService { type: QueryTypes.SELECT }); + console.log('Users found in radius:', users.map(u => ({ + id: u.id, + userLat: u.latitude, + userLng: u.longitude, + distance: parseFloat(u.distance).toFixed(2) + }))); + return users.map(user => ({ id: user.id, email: user.email, diff --git a/backend/templates/emails/forumItemRequestNotification.html b/backend/templates/emails/forumItemRequestNotification.html index 4fe6692..a02733a 100644 --- a/backend/templates/emails/forumItemRequestNotification.html +++ b/backend/templates/emails/forumItemRequestNotification.html @@ -1,285 +1,295 @@ - - - - + + + + Item Request Near You - - + +
-
- -
Item Request Near You
+
+ +
Item Request Near You
+
+ +
+

Hi {{recipientName}},

+ +

Someone nearby is looking for an item!

+ +

+ {{requesterName}} posted an item request in your + area. You might be able to help! +

+ +
+
{{itemRequested}}
+
{{requestDescription}}
+
Posted by {{requesterName}}
-
-

Hi {{recipientName}},

- -

Someone nearby is looking for an item!

- -
📍 About {{distance}} miles away
- -

{{requesterName}} posted an item request in your area. You might be able to help!

- -
-
{{itemRequested}}
-
{{requestDescription}}
-
Posted by {{requesterName}}
-
- -
-

💡 Have this item? You can help a neighbor and potentially earn money!

-
- - View Request & Respond - -

Click the button above to see the full details and offer your help if you have the item they're looking for.

- -
-

Why did I get this? You're receiving this notification because you're located within the requested area for this item. Help build your local community by responding to nearby requests!

-
+
+

💡 Have this item? You can help a neighbor!

- + +
- + diff --git a/frontend/package.json b/frontend/package.json index a66ee0e..8edf65f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,11 +27,9 @@ "web-vitals": "^2.1.4" }, "scripts": { - "start": "react-scripts start", "start:dev": "dotenv -e .env.dev react-scripts start", "start:qa": "dotenv -e .env.qa react-scripts start", "start:prod": "dotenv -e .env.prod react-scripts start", - "build": "react-scripts build", "build:dev": "dotenv -e .env.dev react-scripts build", "build:qa": "dotenv -e .env.qa react-scripts build", "build:prod": "dotenv -e .env.prod react-scripts build", diff --git a/frontend/src/components/LocationForm.tsx b/frontend/src/components/LocationForm.tsx index 649e2c7..1f9e93f 100644 --- a/frontend/src/components/LocationForm.tsx +++ b/frontend/src/components/LocationForm.tsx @@ -6,6 +6,10 @@ import { } from "../services/geocodingService"; import AddressAutocomplete from "./AddressAutocomplete"; import { PlaceDetails } from "../services/placesService"; +import { + useAddressAutocomplete, + usStates, +} from "../hooks/useAddressAutocomplete"; interface LocationFormData { address1: string; @@ -29,123 +33,14 @@ interface LocationFormProps { onAddressSelect: (addressId: string) => void; formatAddressDisplay: (address: Address) => string; onCoordinatesChange?: (latitude: number, longitude: number) => void; - onGeocodeRef?: (geocodeFunction: () => Promise) => void; + onGeocodeRef?: ( + geocodeFunction: () => Promise<{ + latitude: number; + longitude: number; + } | null> + ) => void; } -// State constants - moved to top to avoid hoisting issues -const usStates = [ - "Alabama", - "Alaska", - "Arizona", - "Arkansas", - "California", - "Colorado", - "Connecticut", - "Delaware", - "Florida", - "Georgia", - "Hawaii", - "Idaho", - "Illinois", - "Indiana", - "Iowa", - "Kansas", - "Kentucky", - "Louisiana", - "Maine", - "Maryland", - "Massachusetts", - "Michigan", - "Minnesota", - "Mississippi", - "Missouri", - "Montana", - "Nebraska", - "Nevada", - "New Hampshire", - "New Jersey", - "New Mexico", - "New York", - "North Carolina", - "North Dakota", - "Ohio", - "Oklahoma", - "Oregon", - "Pennsylvania", - "Rhode Island", - "South Carolina", - "South Dakota", - "Tennessee", - "Texas", - "Utah", - "Vermont", - "Virginia", - "Washington", - "West Virginia", - "Wisconsin", - "Wyoming", -]; - -// State code to full name mapping for Google Places API integration -const stateCodeToName: { [key: string]: string } = { - AL: "Alabama", - AK: "Alaska", - AZ: "Arizona", - AR: "Arkansas", - CA: "California", - CO: "Colorado", - CT: "Connecticut", - DE: "Delaware", - FL: "Florida", - GA: "Georgia", - HI: "Hawaii", - ID: "Idaho", - IL: "Illinois", - IN: "Indiana", - IA: "Iowa", - KS: "Kansas", - KY: "Kentucky", - LA: "Louisiana", - ME: "Maine", - MD: "Maryland", - MA: "Massachusetts", - MI: "Michigan", - MN: "Minnesota", - MS: "Mississippi", - MO: "Missouri", - MT: "Montana", - NE: "Nebraska", - NV: "Nevada", - NH: "New Hampshire", - NJ: "New Jersey", - NM: "New Mexico", - NY: "New York", - NC: "North Carolina", - ND: "North Dakota", - OH: "Ohio", - OK: "Oklahoma", - OR: "Oregon", - PA: "Pennsylvania", - RI: "Rhode Island", - SC: "South Carolina", - SD: "South Dakota", - TN: "Tennessee", - TX: "Texas", - UT: "Utah", - VT: "Vermont", - VA: "Virginia", - WA: "Washington", - WV: "West Virginia", - WI: "Wisconsin", - WY: "Wyoming", - DC: "District of Columbia", - PR: "Puerto Rico", - VI: "Virgin Islands", - AS: "American Samoa", - GU: "Guam", - MP: "Northern Mariana Islands", -}; - const LocationForm: React.FC = ({ data, userAddresses, @@ -164,11 +59,13 @@ const LocationForm: React.FC = ({ // Debounced geocoding function const geocodeAddress = useCallback( - async (addressData: LocationFormData) => { + async ( + addressData: LocationFormData + ): Promise<{ latitude: number; longitude: number } | null> => { if ( !geocodingService.isAddressComplete(addressData as AddressComponents) ) { - return; + return null; } setGeocoding(true); @@ -182,6 +79,7 @@ const LocationForm: React.FC = ({ if ("error" in result) { setGeocodeError(result.details || result.error); + return null; } else { setGeocodeSuccess(true); if (onCoordinatesChange) { @@ -189,9 +87,13 @@ const LocationForm: React.FC = ({ } // Clear success message after 3 seconds setTimeout(() => setGeocodeSuccess(false), 3000); + + // Return the coordinates + return { latitude: result.latitude, longitude: result.longitude }; } } catch (error) { setGeocodeError("Failed to geocode address"); + return null; } finally { setGeocoding(false); } @@ -202,10 +104,10 @@ const LocationForm: React.FC = ({ // Expose geocoding function to parent components const triggerGeocoding = useCallback(async () => { if (data.address1 && data.city && data.state && data.zipCode) { - await geocodeAddress(data); - return true; // Successfully triggered + const coordinates = await geocodeAddress(data); + return coordinates; // Return coordinates directly from geocoding } - return false; // Incomplete address + return null; // Incomplete address }, [data, geocodeAddress]); // Pass geocoding function to parent component @@ -215,16 +117,19 @@ const LocationForm: React.FC = ({ } }, [onGeocodeRef, triggerGeocoding]); + // Use address autocomplete hook + const { parsePlace } = useAddressAutocomplete(); + // Handle place selection from autocomplete const handlePlaceSelect = useCallback( (place: PlaceDetails) => { try { - const addressComponents = place.addressComponents; + const parsedAddress = parsePlace(place); - // Build address1 from street number and route - const streetNumber = addressComponents.streetNumber || ""; - const route = addressComponents.route || ""; - const address1 = `${streetNumber} ${route}`.trim(); + if (!parsedAddress) { + setPlacesApiError(true); + return; + } // Create synthetic events to update form data const createSyntheticEvent = (name: string, value: string) => @@ -237,52 +142,15 @@ const LocationForm: React.FC = ({ } as React.ChangeEvent); // Update all address fields - onChange( - createSyntheticEvent("address1", address1 || place.formattedAddress) - ); - - if (addressComponents.locality) { - onChange(createSyntheticEvent("city", addressComponents.locality)); - } - - if (addressComponents.administrativeAreaLevel1) { - // Convert state code to full name using mapping, with fallback to long name or original code - const stateCode = addressComponents.administrativeAreaLevel1; - const stateName = - stateCodeToName[stateCode] || - addressComponents.administrativeAreaLevel1Long || - stateCode; - - // Only set the state if it exists in our dropdown options - if (usStates.includes(stateName)) { - onChange(createSyntheticEvent("state", stateName)); - } else { - console.warn( - `State not found in dropdown options: ${stateName} (code: ${stateCode})` - ); - } - } - - if (addressComponents.postalCode) { - onChange( - createSyntheticEvent("zipCode", addressComponents.postalCode) - ); - } - - if (addressComponents.country) { - onChange(createSyntheticEvent("country", addressComponents.country)); - } + onChange(createSyntheticEvent("address1", parsedAddress.address1)); + onChange(createSyntheticEvent("city", parsedAddress.city)); + onChange(createSyntheticEvent("state", parsedAddress.state)); + onChange(createSyntheticEvent("zipCode", parsedAddress.zipCode)); + onChange(createSyntheticEvent("country", parsedAddress.country)); // Set coordinates immediately - if ( - onCoordinatesChange && - place.geometry.latitude && - place.geometry.longitude - ) { - onCoordinatesChange( - place.geometry.latitude, - place.geometry.longitude - ); + if (onCoordinatesChange) { + onCoordinatesChange(parsedAddress.latitude, parsedAddress.longitude); } // Clear any previous geocoding messages @@ -295,7 +163,7 @@ const LocationForm: React.FC = ({ setPlacesApiError(true); } }, - [onChange, onCoordinatesChange] + [onChange, onCoordinatesChange, parsePlace] ); // Handle Places API errors diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index ce1c09b..89713bf 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -89,8 +89,8 @@ const Navbar: React.FC = () => { navigate("/"); }; - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); + const handleSearch = (e?: React.FormEvent | React.MouseEvent) => { + e?.preventDefault(); const params = new URLSearchParams(); if (searchFilters.search.trim()) { @@ -142,7 +142,7 @@ const Navbar: React.FC = () => { @@ -807,6 +788,39 @@ const Profile: React.FC = () => {
{showPersonalInfo && (
+
+
+ + +
+
+ + +
+
+