diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index 96917af..3497293 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -117,7 +117,7 @@ ## User Profile and Engagement -- [x] 15. Build user profile page +- [ ] 15. Build user profile page - Display user statistics (reports, events attended) - Calculate and show impact score - Create personal contribution map @@ -259,7 +259,7 @@ - Create verification audit log - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_ -- [ ] 31. Build user profile endpoints +- [-] 31. Build user profile endpoints - Create GET /api/users/:id for profile retrieval - Implement PATCH /api/users/:id for profile updates - Build GET /api/users/:id/reports endpoint diff --git a/server/USER_PROFILE_ENDPOINTS.md b/server/USER_PROFILE_ENDPOINTS.md new file mode 100644 index 0000000..7d75793 --- /dev/null +++ b/server/USER_PROFILE_ENDPOINTS.md @@ -0,0 +1,401 @@ +# User Profile Endpoints Documentation + +This document describes the user profile API endpoints implemented for the Beach Litter Mapping Platform. + +## Overview + +The user profile endpoints provide functionality for: +- Retrieving user profiles with statistics +- Updating user profile information +- Managing notification preferences +- Fetching user's reports and events +- Calculating impact scores + +## Authentication + +Most endpoints require authentication via Clerk session token: +``` +Authorization: Bearer +``` + +## Endpoints + +### 1. Get User Profile + +**Endpoint:** `GET /api/users/:id` + +**Description:** Retrieve a user's profile including statistics about their contributions. + +**Authentication:** Not required (public profiles) + +**Parameters:** +- `id` (path) - User database ID + +**Response:** +```json +{ + "id": "uuid", + "clerkId": "user_xxx", + "email": "user@example.com", + "username": "johndoe", + "firstName": "John", + "lastName": "Doe", + "avatarUrl": "https://...", + "impactScore": 150, + "notificationEmail": true, + "notificationInApp": true, + "areasOfInterest": [ + { + "lat": 53.3498, + "lng": -6.2603, + "radius": 10 + } + ], + "createdAt": "2024-01-01T00:00:00.000Z", + "updatedAt": "2024-01-01T00:00:00.000Z", + "stats": { + "reportsSubmitted": 5, + "eventsAttended": 3, + "eventsOrganized": 1 + } +} +``` + +**Error Responses:** +- `404` - User not found +- `500` - Internal server error + +--- + +### 2. Update User Profile + +**Endpoint:** `PATCH /api/users/:id` + +**Description:** Update user profile information (first name, last name, username). + +**Authentication:** Required (users can only update their own profile) + +**Parameters:** +- `id` (path) - User database ID + +**Request Body:** +```json +{ + "firstName": "John", + "lastName": "Doe", + "username": "johndoe123" +} +``` + +All fields are optional. Only provided fields will be updated. + +**Response:** +```json +{ + "id": "uuid", + "clerkId": "user_xxx", + "email": "user@example.com", + "username": "johndoe123", + "firstName": "John", + "lastName": "Doe", + "avatarUrl": "https://...", + "impactScore": 150, + "createdAt": "2024-01-01T00:00:00.000Z", + "updatedAt": "2024-01-01T00:00:00.000Z" +} +``` + +**Error Responses:** +- `400` - Username already taken +- `401` - Not authenticated +- `403` - Cannot update another user's profile +- `404` - User not found +- `500` - Internal server error + +--- + +### 3. Get User Reports + +**Endpoint:** `GET /api/users/:id/reports` + +**Description:** Retrieve all reports submitted by a user. + +**Authentication:** Not required + +**Parameters:** +- `id` (path) - User database ID + +**Response:** +```json +[ + { + "id": "uuid", + "userId": "uuid", + "latitude": 53.3498, + "longitude": -6.2603, + "locationSource": "gps", + "photoUrls": ["/uploads/photo1.jpg"], + "photoTimestamp": "2024-01-01T12:00:00.000Z", + "litterType": "plastic", + "quantity": "moderate", + "description": "Plastic bottles on beach", + "verificationStatus": "verified", + "createdAt": "2024-01-01T12:00:00.000Z", + "environmentalConcerns": [], + "verifications": [] + } +] +``` + +**Error Responses:** +- `500` - Internal server error + +--- + +### 4. Get User Events + +**Endpoint:** `GET /api/users/:id/events` + +**Description:** Retrieve events the user has registered for and organized. + +**Authentication:** Not required + +**Parameters:** +- `id` (path) - User database ID + +**Response:** +```json +{ + "registered": [ + { + "id": "uuid", + "title": "Beach Cleanup", + "description": "Community cleanup event", + "latitude": 53.3498, + "longitude": -6.2603, + "locationName": "Sandymount Beach", + "scheduledDate": "2024-02-01T10:00:00.000Z", + "duration": 120, + "status": "upcoming", + "participantCount": 15, + "organizer": { + "id": "uuid", + "username": "organizer", + "avatarUrl": "https://..." + }, + "registeredAt": "2024-01-15T10:00:00.000Z", + "attended": false + } + ], + "organized": [ + { + "id": "uuid", + "title": "My Beach Cleanup", + "description": "Event I organized", + "latitude": 53.3498, + "longitude": -6.2603, + "locationName": "Dollymount Beach", + "scheduledDate": "2024-03-01T10:00:00.000Z", + "duration": 180, + "status": "upcoming", + "participantCount": 20, + "organizer": { + "id": "uuid", + "username": "johndoe", + "avatarUrl": "https://..." + }, + "registrations": [] + } + ] +} +``` + +**Error Responses:** +- `500` - Internal server error + +--- + +### 5. Update Notification Preferences + +**Endpoint:** `PATCH /api/users/:id/preferences` + +**Description:** Update user's notification preferences and areas of interest. + +**Authentication:** Required (users can only update their own preferences) + +**Parameters:** +- `id` (path) - User database ID + +**Request Body:** +```json +{ + "notificationEmail": true, + "notificationInApp": true, + "areasOfInterest": [ + { + "lat": 53.3498, + "lng": -6.2603, + "radius": 10 + }, + { + "lat": 51.8985, + "lng": -8.4756, + "radius": 25 + } + ] +} +``` + +**Field Validation:** +- `radius` must be between 1 and 50 kilometers +- Each area must have `lat`, `lng`, and `radius` + +**Response:** +```json +{ + "id": "uuid", + "notificationEmail": true, + "notificationInApp": true, + "areasOfInterest": [ + { + "lat": 53.3498, + "lng": -6.2603, + "radius": 10 + } + ] +} +``` + +**Error Responses:** +- `400` - Validation error (invalid radius, missing fields, etc.) +- `401` - Not authenticated +- `403` - Cannot update another user's preferences +- `404` - User not found +- `500` - Internal server error + +--- + +### 6. Calculate Impact Score + +**Endpoint:** `POST /api/users/:id/calculate-impact` + +**Description:** Calculate and update the user's impact score based on their contributions. + +**Authentication:** Not required + +**Parameters:** +- `id` (path) - User database ID + +**Impact Score Formula:** +``` +Impact Score = (reports Ɨ 10) + (events attended Ɨ 20) + (events organized Ɨ 50) +``` + +**Response:** +```json +{ + "impactScore": 150 +} +``` + +**Error Responses:** +- `404` - User not found +- `500` - Internal server error + +--- + +## Usage Examples + +### Get User Profile +```bash +curl http://localhost:3005/api/users/abc-123 +``` + +### Update Profile +```bash +curl -X PATCH http://localhost:3005/api/users/abc-123 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "firstName": "John", + "lastName": "Doe", + "username": "johndoe" + }' +``` + +### Update Notification Preferences +```bash +curl -X PATCH http://localhost:3005/api/users/abc-123/preferences \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "notificationEmail": true, + "notificationInApp": true, + "areasOfInterest": [ + { + "lat": 53.3498, + "lng": -6.2603, + "radius": 10 + } + ] + }' +``` + +### Calculate Impact Score +```bash +curl -X POST http://localhost:3005/api/users/abc-123/calculate-impact +``` + +## Frontend Integration + +The frontend service (`src/services/userService.ts`) provides TypeScript methods for all endpoints: + +```typescript +import { userService } from '@/services/userService'; + +// Get profile +const profile = await userService.getProfile(userId); + +// Update profile +const updated = await userService.updateProfile(userId, { + firstName: 'John', + lastName: 'Doe', + username: 'johndoe' +}); + +// Get user's reports +const reports = await userService.getUserReports(userId); + +// Get user's events +const events = await userService.getUserEvents(userId); + +// Update notification preferences +const preferences = await userService.updateNotificationPreferences(userId, { + notificationEmail: true, + notificationInApp: true, + areasOfInterest: [ + { lat: 53.3498, lng: -6.2603, radius: 10 } + ] +}); + +// Calculate impact score +const { impactScore } = await userService.calculateImpactScore(userId); +``` + +## Requirements Satisfied + +This implementation satisfies the following requirements from the specification: + +- **Requirement 6.3**: User profile display showing submitted reports and events attended +- **Requirement 6.4**: Impact score calculation based on contributions +- **Requirement 6.5**: User profile showing map of submitted report locations (data provided via reports endpoint) +- **Requirement 10.4**: Notification preferences with area of interest configuration (1-50km radius) + +## Notes + +- User authentication is handled by Clerk +- Users are automatically created in the database when they first interact with the API +- The `areasOfInterest` field is stored as JSON in the database +- Impact scores are calculated on-demand but can be cached for performance +- All endpoints follow RESTful conventions +- Error responses follow a consistent format with error codes and timestamps diff --git a/server/src/controllers/userController.ts b/server/src/controllers/userController.ts new file mode 100644 index 0000000..2276fd1 --- /dev/null +++ b/server/src/controllers/userController.ts @@ -0,0 +1,282 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import { userService } from '../services/userService'; + +export class UserController { + /** + * GET /api/users/:id - Get user profile + */ + async getUserProfile(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + + const profile = await userService.getUserProfile(id); + res.json(profile); + } catch (error: any) { + console.error('Error fetching user profile:', error); + + if (error.message === 'User not found') { + return res.status(404).json({ + error: { + code: 'RESOURCE_NOT_FOUND', + message: 'User not found', + timestamp: new Date().toISOString(), + }, + }); + } + + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch user profile', + timestamp: new Date().toISOString(), + }, + }); + } + } + + /** + * PATCH /api/users/:id - Update user profile + */ + async updateUserProfile(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + const { firstName, lastName, username } = req.body; + + // Authorization check - users can only update their own profile + if (!req.userId) { + return res.status(401).json({ + error: { + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }, + }); + } + + // Find or create user to get database ID + const dbUserId = await userService.findOrCreateUser(req.userId); + + if (dbUserId !== id) { + return res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'You can only update your own profile', + timestamp: new Date().toISOString(), + }, + }); + } + + const updatedProfile = await userService.updateUserProfile(id, { + firstName, + lastName, + username, + }); + + res.json(updatedProfile); + } catch (error: any) { + console.error('Error updating user profile:', error); + + if (error.message === 'User not found') { + return res.status(404).json({ + error: { + code: 'RESOURCE_NOT_FOUND', + message: 'User not found', + timestamp: new Date().toISOString(), + }, + }); + } + + if (error.message === 'Username already taken') { + return res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'Username already taken', + timestamp: new Date().toISOString(), + }, + }); + } + + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to update user profile', + timestamp: new Date().toISOString(), + }, + }); + } + } + + /** + * GET /api/users/:id/reports - Get user's reports + */ + async getUserReports(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + + const reports = await userService.getUserReports(id); + res.json(reports); + } catch (error) { + console.error('Error fetching user reports:', error); + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch user reports', + timestamp: new Date().toISOString(), + }, + }); + } + } + + /** + * GET /api/users/:id/events - Get user's events + */ + async getUserEvents(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + + const events = await userService.getUserEvents(id); + res.json(events); + } catch (error) { + console.error('Error fetching user events:', error); + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch user events', + timestamp: new Date().toISOString(), + }, + }); + } + } + + /** + * PATCH /api/users/:id/preferences - Update notification preferences + */ + async updateNotificationPreferences(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + const { notificationEmail, notificationInApp, areasOfInterest } = req.body; + + // Authorization check + if (!req.userId) { + return res.status(401).json({ + error: { + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }, + }); + } + + // Find or create user to get database ID + const dbUserId = await userService.findOrCreateUser(req.userId); + + if (dbUserId !== id) { + return res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'You can only update your own preferences', + timestamp: new Date().toISOString(), + }, + }); + } + + // Validate areasOfInterest if provided + if (areasOfInterest) { + if (!Array.isArray(areasOfInterest)) { + return res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'areasOfInterest must be an array', + timestamp: new Date().toISOString(), + }, + }); + } + + for (const area of areasOfInterest) { + if (!area.lat || !area.lng || !area.radius) { + return res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'Each area must have lat, lng, and radius', + timestamp: new Date().toISOString(), + }, + }); + } + + if (area.radius < 1 || area.radius > 50) { + return res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'Radius must be between 1 and 50 kilometers', + timestamp: new Date().toISOString(), + }, + }); + } + } + } + + const updatedPreferences = await userService.updateNotificationPreferences(id, { + notificationEmail, + notificationInApp, + areasOfInterest, + }); + + res.json(updatedPreferences); + } catch (error: any) { + console.error('Error updating notification preferences:', error); + + if (error.message === 'User not found') { + return res.status(404).json({ + error: { + code: 'RESOURCE_NOT_FOUND', + message: 'User not found', + timestamp: new Date().toISOString(), + }, + }); + } + + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to update notification preferences', + timestamp: new Date().toISOString(), + }, + }); + } + } + + /** + * POST /api/users/:id/calculate-impact - Calculate and update impact score + */ + async calculateImpactScore(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + + const impactScore = await userService.calculateImpactScore(id); + res.json({ impactScore }); + } catch (error: any) { + console.error('Error calculating impact score:', error); + + if (error.message === 'User not found') { + return res.status(404).json({ + error: { + code: 'RESOURCE_NOT_FOUND', + message: 'User not found', + timestamp: new Date().toISOString(), + }, + }); + } + + res.status(500).json({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to calculate impact score', + timestamp: new Date().toISOString(), + }, + }); + } + } +} + +export const userController = new UserController(); diff --git a/server/src/index.ts b/server/src/index.ts index bab3501..0594654 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import dotenv from 'dotenv'; import path from 'path'; import reportRoutes from './routes/reportRoutes'; import eventRoutes from './routes/eventRoutes'; +import userRoutes from './routes/userRoutes'; // Load environment variables dotenv.config(); @@ -39,6 +40,7 @@ app.get('/health', (req, res) => { // API Routes app.use('/api/reports', reportRoutes); app.use('/api/events', eventRoutes); +app.use('/api/users', userRoutes); // Error handling middleware app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { diff --git a/server/src/routes/userRoutes.ts b/server/src/routes/userRoutes.ts new file mode 100644 index 0000000..db16f33 --- /dev/null +++ b/server/src/routes/userRoutes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { userController } from '../controllers/userController'; +import { requireAuth } from '../middleware/auth'; + +const router = Router(); + +// User profile endpoints +router.get('/:id', userController.getUserProfile.bind(userController)); +router.patch('/:id', requireAuth, userController.updateUserProfile.bind(userController)); + +// User reports and events +router.get('/:id/reports', userController.getUserReports.bind(userController)); +router.get('/:id/events', userController.getUserEvents.bind(userController)); + +// Notification preferences +router.patch('/:id/preferences', requireAuth, userController.updateNotificationPreferences.bind(userController)); + +// Impact score calculation +router.post('/:id/calculate-impact', userController.calculateImpactScore.bind(userController)); + +export default router; diff --git a/server/src/scripts/testUserEndpoints.ts b/server/src/scripts/testUserEndpoints.ts new file mode 100644 index 0000000..89cc470 --- /dev/null +++ b/server/src/scripts/testUserEndpoints.ts @@ -0,0 +1,135 @@ +/** + * Test script for user profile endpoints + * + * This script tests the user profile API endpoints to ensure they work correctly. + * + * Usage: + * 1. Make sure the server is running (npm run dev) + * 2. Run: npx ts-node src/scripts/testUserEndpoints.ts + */ + +import axios from 'axios'; + +const API_BASE_URL = 'http://localhost:3005/api'; + +// You'll need to replace this with a valid Clerk session token +const AUTH_TOKEN = 'your_clerk_session_token_here'; + +async function testUserEndpoints() { + console.log('🧪 Testing User Profile Endpoints\n'); + + try { + // Test 1: Get user profile + console.log('1ļøāƒ£ Testing GET /api/users/:id'); + try { + const userId = 'test-user-id'; // Replace with actual user ID + const profileResponse = await axios.get(`${API_BASE_URL}/users/${userId}`); + console.log('āœ… Get user profile successful'); + console.log('Profile:', JSON.stringify(profileResponse.data, null, 2)); + } catch (error: any) { + console.log('āŒ Get user profile failed:', error.response?.data || error.message); + } + + console.log('\n'); + + // Test 2: Update user profile + console.log('2ļøāƒ£ Testing PATCH /api/users/:id'); + try { + const userId = 'test-user-id'; // Replace with actual user ID + const updateResponse = await axios.patch( + `${API_BASE_URL}/users/${userId}`, + { + firstName: 'John', + lastName: 'Doe', + username: 'johndoe123', + }, + { + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + } + ); + console.log('āœ… Update user profile successful'); + console.log('Updated profile:', JSON.stringify(updateResponse.data, null, 2)); + } catch (error: any) { + console.log('āŒ Update user profile failed:', error.response?.data || error.message); + } + + console.log('\n'); + + // Test 3: Get user reports + console.log('3ļøāƒ£ Testing GET /api/users/:id/reports'); + try { + const userId = 'test-user-id'; // Replace with actual user ID + const reportsResponse = await axios.get(`${API_BASE_URL}/users/${userId}/reports`); + console.log('āœ… Get user reports successful'); + console.log(`Found ${reportsResponse.data.length} reports`); + } catch (error: any) { + console.log('āŒ Get user reports failed:', error.response?.data || error.message); + } + + console.log('\n'); + + // Test 4: Get user events + console.log('4ļøāƒ£ Testing GET /api/users/:id/events'); + try { + const userId = 'test-user-id'; // Replace with actual user ID + const eventsResponse = await axios.get(`${API_BASE_URL}/users/${userId}/events`); + console.log('āœ… Get user events successful'); + console.log('Events:', JSON.stringify(eventsResponse.data, null, 2)); + } catch (error: any) { + console.log('āŒ Get user events failed:', error.response?.data || error.message); + } + + console.log('\n'); + + // Test 5: Update notification preferences + console.log('5ļøāƒ£ Testing PATCH /api/users/:id/preferences'); + try { + const userId = 'test-user-id'; // Replace with actual user ID + const preferencesResponse = await axios.patch( + `${API_BASE_URL}/users/${userId}/preferences`, + { + notificationEmail: true, + notificationInApp: true, + areasOfInterest: [ + { + lat: 53.3498, + lng: -6.2603, + radius: 10, + }, + ], + }, + { + headers: { + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + } + ); + console.log('āœ… Update notification preferences successful'); + console.log('Preferences:', JSON.stringify(preferencesResponse.data, null, 2)); + } catch (error: any) { + console.log('āŒ Update notification preferences failed:', error.response?.data || error.message); + } + + console.log('\n'); + + // Test 6: Calculate impact score + console.log('6ļøāƒ£ Testing POST /api/users/:id/calculate-impact'); + try { + const userId = 'test-user-id'; // Replace with actual user ID + const impactResponse = await axios.post(`${API_BASE_URL}/users/${userId}/calculate-impact`); + console.log('āœ… Calculate impact score successful'); + console.log('Impact score:', impactResponse.data.impactScore); + } catch (error: any) { + console.log('āŒ Calculate impact score failed:', error.response?.data || error.message); + } + + console.log('\nāœ… All tests completed!\n'); + } catch (error) { + console.error('āŒ Test suite failed:', error); + } +} + +// Run tests +testUserEndpoints(); diff --git a/server/src/services/userService.ts b/server/src/services/userService.ts new file mode 100644 index 0000000..251027d --- /dev/null +++ b/server/src/services/userService.ts @@ -0,0 +1,281 @@ +import prisma from '../config/database'; +import { clerkClient } from '@clerk/clerk-sdk-node'; + +export interface UpdateUserProfileData { + firstName?: string; + lastName?: string; + username?: string; +} + +export interface UpdateNotificationPreferencesData { + notificationEmail?: boolean; + notificationInApp?: boolean; + areasOfInterest?: Array<{ + lat: number; + lng: number; + radius: number; + }>; +} + +export class UserService { + /** + * Find or create user from Clerk ID + */ + async findOrCreateUser(clerkId: string): Promise { + // Check if user exists + let user = await prisma.user.findUnique({ + where: { clerkId }, + select: { id: true }, + }); + + if (user) { + return user.id; + } + + // User doesn't exist, fetch from Clerk and create + try { + const clerkUser = await clerkClient.users.getUser(clerkId); + + user = await prisma.user.create({ + data: { + clerkId, + email: clerkUser.emailAddresses[0]?.emailAddress || `${clerkId}@temp.com`, + username: clerkUser.username || `user_${clerkId.substring(0, 8)}`, + firstName: clerkUser.firstName, + lastName: clerkUser.lastName, + avatarUrl: clerkUser.imageUrl, + }, + select: { id: true }, + }); + + return user.id; + } catch (error) { + console.error('Error creating user from Clerk:', error); + throw new Error('Failed to create user'); + } + } + + /** + * Get user profile by database ID + */ + async getUserProfile(userId: string) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + clerkId: true, + email: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + impactScore: true, + notificationEmail: true, + notificationInApp: true, + areasOfInterest: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + reports: true, + eventRegistrations: true, + organizedEvents: true, + }, + }, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + return { + ...user, + stats: { + reportsSubmitted: user._count.reports, + eventsAttended: user._count.eventRegistrations, + eventsOrganized: user._count.organizedEvents, + }, + }; + } + + /** + * Update user profile + */ + async updateUserProfile(userId: string, data: UpdateUserProfileData) { + // Check if username is being changed and if it's already taken + if (data.username) { + const existingUser = await prisma.user.findFirst({ + where: { + username: data.username, + id: { not: userId }, + }, + }); + + if (existingUser) { + throw new Error('Username already taken'); + } + } + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + firstName: data.firstName, + lastName: data.lastName, + username: data.username, + }, + select: { + id: true, + clerkId: true, + email: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + impactScore: true, + createdAt: true, + updatedAt: true, + }, + }); + + return updatedUser; + } + + /** + * Get user's reports + */ + async getUserReports(userId: string) { + const reports = await prisma.report.findMany({ + where: { userId }, + include: { + environmentalConcerns: true, + verifications: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return reports; + } + + /** + * Get user's events (both registered and organized) + */ + async getUserEvents(userId: string) { + // Get events user has registered for + const registeredEvents = await prisma.eventRegistration.findMany({ + where: { userId }, + include: { + event: { + include: { + organizer: { + select: { + id: true, + username: true, + avatarUrl: true, + }, + }, + }, + }, + }, + orderBy: { + registeredAt: 'desc', + }, + }); + + // Get events user has organized + const organizedEvents = await prisma.event.findMany({ + where: { organizerId: userId }, + include: { + organizer: { + select: { + id: true, + username: true, + avatarUrl: true, + }, + }, + registrations: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return { + registered: registeredEvents.map(reg => ({ + ...reg.event, + registeredAt: reg.registeredAt, + attended: reg.attended, + })), + organized: organizedEvents, + }; + } + + /** + * Update notification preferences + */ + async updateNotificationPreferences( + userId: string, + data: UpdateNotificationPreferencesData + ) { + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + notificationEmail: data.notificationEmail, + notificationInApp: data.notificationInApp, + areasOfInterest: data.areasOfInterest ? JSON.stringify(data.areasOfInterest) : undefined, + }, + select: { + id: true, + notificationEmail: true, + notificationInApp: true, + areasOfInterest: true, + }, + }); + + return updatedUser; + } + + /** + * Calculate and update user impact score + * Impact score = (reports * 10) + (events attended * 20) + (events organized * 50) + */ + async calculateImpactScore(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + _count: { + select: { + reports: true, + eventRegistrations: { + where: { attended: true }, + }, + organizedEvents: { + where: { status: 'completed' }, + }, + }, + }, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + const impactScore = + user._count.reports * 10 + + user._count.eventRegistrations * 20 + + user._count.organizedEvents * 50; + + // Update the impact score + await prisma.user.update({ + where: { id: userId }, + data: { impactScore }, + }); + + return impactScore; + } +} + +export const userService = new UserService(); diff --git a/src/services/userService.ts b/src/services/userService.ts index 65ac9b6..17b142b 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -24,8 +24,16 @@ export interface UpdateProfileDTO { firstName?: string; lastName?: string; username?: string; - notificationEmail?: boolean; - notificationInApp?: boolean; +} + +export interface NotificationPreferences { + notificationEmail: boolean; + notificationInApp: boolean; + areasOfInterest?: Array<{ + lat: number; + lng: number; + radius: number; + }>; } export const userService = { @@ -63,4 +71,21 @@ export const userService = { async getUserActivity(userId: string): Promise { return apiClient.get(`/users/${userId}/activity`); }, + + /** + * Update notification preferences + */ + async updateNotificationPreferences( + userId: string, + preferences: NotificationPreferences + ): Promise { + return apiClient.patch(`/users/${userId}/preferences`, preferences); + }, + + /** + * Calculate and update user impact score + */ + async calculateImpactScore(userId: string): Promise<{ impactScore: number }> { + return apiClient.post<{ impactScore: number }>(`/users/${userId}/calculate-impact`, {}); + }, };