From 413ac6b6e2b2926b38b1f97eb7a7fa60aaeb8c29 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:28:47 -0500 Subject: [PATCH] Item request notifications --- backend/models/ForumPost.js | 12 + backend/models/User.js | 9 + backend/routes/forum.js | 101 +++- backend/routes/users.js | 18 +- .../services/email/core/TemplateManager.js | 1 + .../email/domain/ForumEmailService.js | 61 +++ backend/services/locationService.js | 112 +++++ .../emails/forumItemRequestNotification.html | 285 +++++++++++ frontend/src/pages/CreateForumPost.tsx | 36 ++ frontend/src/pages/Profile.tsx | 463 ++++++++++-------- frontend/src/types/index.ts | 1 + 11 files changed, 875 insertions(+), 224 deletions(-) create mode 100644 backend/services/locationService.js create mode 100644 backend/templates/emails/forumItemRequestNotification.html diff --git a/backend/models/ForumPost.js b/backend/models/ForumPost.js index 5bbf877..a612b89 100644 --- a/backend/models/ForumPost.js +++ b/backend/models/ForumPost.js @@ -57,6 +57,18 @@ const ForumPost = sequelize.define('ForumPost', { allowNull: true, defaultValue: [] }, + zipCode: { + type: DataTypes.STRING, + allowNull: true + }, + latitude: { + type: DataTypes.DECIMAL(10, 8), + allowNull: true + }, + longitude: { + type: DataTypes.DECIMAL(11, 8), + allowNull: true + }, isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false diff --git a/backend/models/User.js b/backend/models/User.js index f791c78..01c4113 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -142,6 +142,15 @@ const User = sequelize.define( defaultValue: "user", allowNull: false, }, + itemRequestNotificationRadius: { + type: DataTypes.INTEGER, + defaultValue: 10, + allowNull: true, + validate: { + min: 1, + max: 100, + }, + }, }, { hooks: { diff --git a/backend/routes/forum.js b/backend/routes/forum.js index ac68bb2..52379b5 100644 --- a/backend/routes/forum.js +++ b/backend/routes/forum.js @@ -5,6 +5,8 @@ const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload'); const logger = require('../utils/logger'); const emailServices = require('../services/email'); +const googleMapsService = require('../services/googleMapsService'); +const locationService = require('../services/locationService'); const router = express.Router(); // Helper function to build nested comment tree @@ -238,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 } = req.body; + let { title, content, category, tags, zipCode } = req.body; // Parse tags if they come as JSON string (from FormData) if (typeof tags === 'string') { @@ -252,12 +254,42 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) // Extract image filenames if uploaded const images = req.files ? req.files.map(file => file.filename) : []; + // Initialize location fields + let latitude = null; + let longitude = null; + + // Geocode zip code for item requests + if (category === 'item_request' && zipCode) { + try { + const geocodeResult = await googleMapsService.geocodeAddress(zipCode); + latitude = geocodeResult.latitude; + longitude = geocodeResult.longitude; + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Geocoded zip code for item request", { + zipCode, + latitude, + longitude + }); + } 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 + } + } + const post = await ForumPost.create({ title, content, category, authorId: req.user.id, - images + images, + zipCode: zipCode || null, + latitude, + longitude }); // Create tags if provided @@ -295,6 +327,71 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) }); res.status(201).json(postWithDetails); + + // Send location-based notifications for item requests (asynchronously) + if (category === 'item_request' && latitude && longitude) { + (async () => { + try { + // Find all users within maximum radius (100 miles) + const nearbyUsers = await locationService.findUsersInRadius( + latitude, + longitude, + 100 + ); + + const postAuthor = await User.findByPk(req.user.id); + + let notificationsSent = 0; + let usersChecked = 0; + + for (const user of nearbyUsers) { + // Don't notify the requester + if (user.id !== req.user.id) { + usersChecked++; + + // Get user's notification preference + const userProfile = await User.findByPk(user.id, { + attributes: ['itemRequestNotificationRadius'] + }); + + const userPreferredRadius = userProfile?.itemRequestNotificationRadius || 10; + + // Only notify if within user's preferred radius + if (parseFloat(user.distance) <= userPreferredRadius) { + try { + await emailServices.forum.sendItemRequestNotification( + user, + postAuthor, + post, + user.distance + ); + notificationsSent++; + } catch (emailError) { + logger.error("Failed to send item request notification", { + error: emailError.message, + recipientId: user.id, + postId: post.id + }); + } + } + } + } + + logger.info("Item request notifications sent", { + postId: post.id, + totalNearbyUsers: nearbyUsers.length, + usersChecked, + notificationsSent + }); + } catch (error) { + logger.error("Failed to process item request notifications", { + error: error.message, + stack: error.stack, + postId: post.id + }); + } + })(); + } } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum post creation failed", { diff --git a/backend/routes/users.js b/backend/routes/users.js index b6cf983..d3baf49 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -211,19 +211,20 @@ router.get('/:id', async (req, res) => { router.put('/profile', authenticateToken, async (req, res) => { try { - const { - firstName, - lastName, - email, - phone, + const { + firstName, + lastName, + email, + phone, address1, address2, city, state, zipCode, - country + country, + itemRequestNotificationRadius } = req.body; - + // Build update object, excluding empty email const updateData = { firstName, @@ -234,7 +235,8 @@ router.put('/profile', authenticateToken, async (req, res) => { city, state, zipCode, - country + country, + itemRequestNotificationRadius }; // Only include email if it's not empty diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js index 737750e..d534c4c 100644 --- a/backend/services/email/core/TemplateManager.js +++ b/backend/services/email/core/TemplateManager.js @@ -63,6 +63,7 @@ class TemplateManager { "forumAnswerAcceptedToCommentAuthor.html", "forumThreadActivityToParticipant.html", "forumPostClosed.html", + "forumItemRequestNotification.html", ]; for (const templateFile of templateFiles) { diff --git a/backend/services/email/domain/ForumEmailService.js b/backend/services/email/domain/ForumEmailService.js index 0c52c00..4db01cf 100644 --- a/backend/services/email/domain/ForumEmailService.js +++ b/backend/services/email/domain/ForumEmailService.js @@ -8,6 +8,7 @@ const TemplateManager = require("../core/TemplateManager"); * - Sending reply notifications to comment authors * - Sending answer accepted notifications * - Sending thread activity notifications to participants + * - Sending location-based item request notifications to nearby users */ class ForumEmailService { constructor() { @@ -384,6 +385,66 @@ class ForumEmailService { return { success: false, error: error.message }; } } + + /** + * Send notification to nearby users about an item request + * @param {Object} recipient - Recipient user object + * @param {string} recipient.firstName - Recipient's first name + * @param {string} recipient.email - Recipient's email + * @param {Object} requester - User who posted the item request + * @param {string} requester.firstName - Requester's first name + * @param {string} requester.lastName - Requester's last name + * @param {Object} post - Forum post object (item request) + * @param {number} post.id - Post ID + * @param {string} post.title - Item being requested + * @param {string} post.content - Request description + * @param {string|number} distance - Distance from recipient to request location (in miles) + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendItemRequestNotification(recipient, requester, post, distance) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const postUrl = `${frontendUrl}/forum/posts/${post.id}`; + + const variables = { + recipientName: recipient.firstName || "there", + requesterName: + `${requester.firstName} ${requester.lastName}`.trim() || "Someone", + itemRequested: post.title, + requestDescription: post.content, + postUrl: postUrl, + distance: distance, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "forumItemRequestNotification", + variables + ); + + const subject = `Someone nearby is looking for: ${post.title}`; + + const result = await this.emailClient.sendEmail( + recipient.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Item request notification email sent to ${recipient.email}` + ); + } + + return result; + } catch (error) { + console.error("Failed to send item request notification email:", error); + return { success: false, error: error.message }; + } + } } module.exports = ForumEmailService; diff --git a/backend/services/locationService.js b/backend/services/locationService.js new file mode 100644 index 0000000..18c76de --- /dev/null +++ b/backend/services/locationService.js @@ -0,0 +1,112 @@ +const { sequelize } = require('../models'); +const { QueryTypes } = require('sequelize'); + +class LocationService { + /** + * Find users within a specified radius of coordinates + * Uses the Haversine formula to calculate great-circle distance between two points + * + * @param {number} latitude - Center point latitude + * @param {number} longitude - Center point longitude + * @param {number} radiusMiles - Search radius in miles (default: 10) + * @returns {Promise} Array of users with their distance from the center point + */ + async findUsersInRadius(latitude, longitude, radiusMiles = 10) { + if (!latitude || !longitude) { + throw new Error('Latitude and longitude are required'); + } + + if (radiusMiles <= 0 || radiusMiles > 100) { + throw new Error('Radius must be between 1 and 100 miles'); + } + + try { + // Haversine formula: + // distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2)) + // * cos(radians(lng2) - radians(lng1)) + // + 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 + ORDER BY distance ASC + `; + + const users = await sequelize.query(query, { + replacements: { + lat: parseFloat(latitude), + lng: parseFloat(longitude), + radiusMiles: parseFloat(radiusMiles) + }, + type: QueryTypes.SELECT + }); + + return users.map(user => ({ + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + latitude: parseFloat(user.latitude), + longitude: parseFloat(user.longitude), + distance: parseFloat(user.distance).toFixed(2) // Round to 2 decimal places + })); + } catch (error) { + console.error('Error finding users in radius:', error); + throw new Error(`Failed to find users in radius: ${error.message}`); + } + } + + /** + * Calculate distance between two points using Haversine formula + * + * @param {number} lat1 - First point latitude + * @param {number} lon1 - First point longitude + * @param {number} lat2 - Second point latitude + * @param {number} lon2 - Second point longitude + * @returns {number} Distance in miles + */ + calculateDistance(lat1, lon1, lat2, lon2) { + const R = 3959; // Earth's radius in miles + const dLat = this.toRadians(lat2 - lat1); + const dLon = this.toRadians(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const distance = R * c; + + return distance; + } + + /** + * Convert degrees to radians + * @param {number} degrees + * @returns {number} Radians + */ + toRadians(degrees) { + return degrees * (Math.PI / 180); + } +} + +module.exports = new LocationService(); diff --git a/backend/templates/emails/forumItemRequestNotification.html b/backend/templates/emails/forumItemRequestNotification.html new file mode 100644 index 0000000..4fe6692 --- /dev/null +++ b/backend/templates/emails/forumItemRequestNotification.html @@ -0,0 +1,285 @@ + + + + + + + Item Request Near You + + + +
+
+ +
Item Request Near You
+
+ +
+

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!

+
+
+ + +
+ + diff --git a/frontend/src/pages/CreateForumPost.tsx b/frontend/src/pages/CreateForumPost.tsx index a9d2d82..551f20d 100644 --- a/frontend/src/pages/CreateForumPost.tsx +++ b/frontend/src/pages/CreateForumPost.tsx @@ -20,6 +20,7 @@ const CreateForumPost: React.FC = () => { | "community_resources" | "general_discussion", tags: [] as string[], + zipCode: user?.zipCode || "", }); const [imageFiles, setImageFiles] = useState([]); @@ -111,6 +112,11 @@ const CreateForumPost: React.FC = () => { return; } + if (formData.category === "item_request" && !formData.zipCode.trim()) { + setError("Zip code is required for item requests"); + return; + } + try { setIsSubmitting(true); @@ -125,6 +131,11 @@ const CreateForumPost: React.FC = () => { submitData.append('tags', JSON.stringify(formData.tags)); } + // Add location data for item requests + if (formData.category === 'item_request' && formData.zipCode) { + submitData.append('zipCode', formData.zipCode); + } + // Add images imageFiles.forEach((file) => { submitData.append('images', file); @@ -247,6 +258,31 @@ const CreateForumPost: React.FC = () => { + {/* Location fields for item requests */} + {formData.category === "item_request" && ( +
+ + +
+ Your zip code helps notify nearby users who might have + the item you're looking for +
+
+ )} + {/* Content */}
+
+ + {/* Saved Addresses Section */} +
+ + + {addressesLoading ? ( +
+
+ + Loading addresses... + +
+
+ ) : ( + <> + {userAddresses.length === 0 && !showAddressForm ? ( +
+

No saved addresses yet

+ + Add an address or create your first listing to save + one automatically + +
+ ) : ( + <> + {userAddresses.length > 0 && !showAddressForm && ( + <> +
+ {userAddresses.map((address) => ( +
+
+
+ {formatAddressDisplay(address)} +
+ {address.address2 && ( + + {address.address2} + + )} +
+
+ + +
+
+ ))} +
+ + + )} + + )} + + {/* Show Add New Address button even when no addresses exist */} + {userAddresses.length === 0 && !showAddressForm && ( +
+ +
+ )} + + {/* Address Form */} + {showAddressForm && ( +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ )} + + )} +
+ +
+ + {/* Notification Preferences Section */} +
+ + +
+ + +
+ You'll receive notifications when someone posts an item request within this distance from your primary address +
+
+
+ +
+ {editing ? (
- -
- - ))} - - - - )} - - )} - - {/* Show Add New Address button even when no addresses exist */} - {userAddresses.length === 0 && !showAddressForm && ( -
- -
- )} - - {/* Address Form */} - {showAddressForm && ( -
-
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
- )} - - )} - - - {/* Availability Card */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 58fa000..e9f1476 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -32,6 +32,7 @@ export interface User { role?: "user" | "admin"; stripeConnectedAccountId?: string; addresses?: Address[]; + itemRequestNotificationRadius?: number; } export interface Message {