Compare commits
10 Commits
5cbdb8f284
...
515e85b1eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 515e85b1eb | |||
| fdeaa4ff3a | |||
| cc7298bc56 | |||
| ad06833842 | |||
| 701735a7e4 | |||
| 258cbcf238 | |||
| 426c4c7d7e | |||
| bee80be87d | |||
| c84bcb2fd6 | |||
| f02f23e185 |
@@ -117,7 +117,7 @@
|
|||||||
|
|
||||||
## User Profile and Engagement
|
## User Profile and Engagement
|
||||||
|
|
||||||
- [ ] 15. Build user profile page
|
- [x] 15. Build user profile page
|
||||||
- Display user statistics (reports, events attended)
|
- Display user statistics (reports, events attended)
|
||||||
- Calculate and show impact score
|
- Calculate and show impact score
|
||||||
- Create personal contribution map
|
- Create personal contribution map
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
- Create verification audit log
|
- Create verification audit log
|
||||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
||||||
|
|
||||||
- [ ] 31. Build user profile endpoints
|
- [x] 31. Build user profile endpoints
|
||||||
- Create GET /api/users/:id for profile retrieval
|
- Create GET /api/users/:id for profile retrieval
|
||||||
- Implement PATCH /api/users/:id for profile updates
|
- Implement PATCH /api/users/:id for profile updates
|
||||||
- Build GET /api/users/:id/reports endpoint
|
- Build GET /api/users/:id/reports endpoint
|
||||||
|
|||||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"recharts": "^3.4.1"
|
"recharts": "^3.4.1"
|
||||||
},
|
},
|
||||||
@@ -1088,6 +1089,17 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.10.1",
|
"version": "2.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz",
|
||||||
@@ -3667,6 +3679,20 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"recharts": "^3.4.1"
|
"recharts": "^3.4.1"
|
||||||
},
|
},
|
||||||
|
|||||||
61
server/USERNAME_FIX.md
Normal file
61
server/USERNAME_FIX.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Username Display Fix
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
Users were seeing duplicate prefixes in their usernames (e.g., `@user_user_35ZFZ...`) on their profile pages.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
When auto-creating users from Clerk authentication, the code was generating usernames using the pattern:
|
||||||
|
```typescript
|
||||||
|
username: clerkUser.username || `user_${clerkId}`
|
||||||
|
```
|
||||||
|
|
||||||
|
Since Clerk IDs already start with `user_`, this resulted in usernames like `user_user_35ZFZ...`.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### 1. Updated User Creation Logic
|
||||||
|
Modified the username generation in both `userService.ts` and `reportController.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Generate a clean username from Clerk ID (remove 'user_' prefix if present)
|
||||||
|
const cleanClerkId = clerkId.startsWith('user_') ? clerkId.substring(5) : clerkId;
|
||||||
|
const defaultUsername = clerkUser.username || `user${cleanClerkId.substring(0, 8)}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Now generates usernames like `user35ZFZ...` instead of `user_user_35ZFZ...`.
|
||||||
|
|
||||||
|
### 2. Migration Script
|
||||||
|
Created `fixDuplicateUsernames.ts` to update existing users with duplicate prefixes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx ts-node src/scripts/fixDuplicateUsernames.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
- Finds all users with usernames starting with `user_user_`
|
||||||
|
- Updates them to remove the duplicate prefix
|
||||||
|
- Example: `user_user_35Z` → `user35Z`
|
||||||
|
|
||||||
|
### 3. Profile Display
|
||||||
|
Also removed email display from the user profile page for privacy.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- New users will have clean usernames: `user35ZFZ...`
|
||||||
|
- Existing users have been migrated to the new format
|
||||||
|
- Profile pages now show `@user35ZFZ...` instead of `@user_user_35ZFZ...`
|
||||||
|
- Email addresses are no longer displayed on profile pages
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
- `server/src/services/userService.ts` - Updated user creation logic
|
||||||
|
- `server/src/controllers/reportController.ts` - Updated user creation logic
|
||||||
|
- `server/src/scripts/fixDuplicateUsernames.ts` - Migration script (new)
|
||||||
|
- `src/pages/UserProfilePage.tsx` - Removed email display
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
Run the migration script to fix any existing users:
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npx ts-node src/scripts/fixDuplicateUsernames.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The script is idempotent and safe to run multiple times.
|
||||||
401
server/USER_PROFILE_ENDPOINTS.md
Normal file
401
server/USER_PROFILE_ENDPOINTS.md
Normal file
@@ -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 <clerk_session_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 <token>" \
|
||||||
|
-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 <token>" \
|
||||||
|
-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
|
||||||
@@ -22,11 +22,15 @@ export class ReportController {
|
|||||||
try {
|
try {
|
||||||
const clerkUser = await clerkClient.users.getUser(clerkId);
|
const clerkUser = await clerkClient.users.getUser(clerkId);
|
||||||
|
|
||||||
|
// Generate a clean username from Clerk ID (remove 'user_' prefix if present)
|
||||||
|
const cleanClerkId = clerkId.startsWith('user_') ? clerkId.substring(5) : clerkId;
|
||||||
|
const defaultUsername = clerkUser.username || `user${cleanClerkId.substring(0, 8)}`;
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
clerkId,
|
clerkId,
|
||||||
email: clerkUser.emailAddresses[0]?.emailAddress || `${clerkId}@temp.com`,
|
email: clerkUser.emailAddresses[0]?.emailAddress || `${clerkId}@temp.com`,
|
||||||
username: clerkUser.username || `user_${clerkId.substring(0, 8)}`,
|
username: defaultUsername,
|
||||||
firstName: clerkUser.firstName,
|
firstName: clerkUser.firstName,
|
||||||
lastName: clerkUser.lastName,
|
lastName: clerkUser.lastName,
|
||||||
avatarUrl: clerkUser.imageUrl,
|
avatarUrl: clerkUser.imageUrl,
|
||||||
|
|||||||
314
server/src/controllers/userController.ts
Normal file
314
server/src/controllers/userController.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/users/:id/activity - Get user's activity timeline
|
||||||
|
*/
|
||||||
|
async getUserActivity(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const activities = await userService.getUserActivity(id);
|
||||||
|
res.json(activities);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching user activity:', 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 activity',
|
||||||
|
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();
|
||||||
@@ -5,6 +5,7 @@ import dotenv from 'dotenv';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import reportRoutes from './routes/reportRoutes';
|
import reportRoutes from './routes/reportRoutes';
|
||||||
import eventRoutes from './routes/eventRoutes';
|
import eventRoutes from './routes/eventRoutes';
|
||||||
|
import userRoutes from './routes/userRoutes';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -39,6 +40,7 @@ app.get('/health', (req, res) => {
|
|||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/reports', reportRoutes);
|
app.use('/api/reports', reportRoutes);
|
||||||
app.use('/api/events', eventRoutes);
|
app.use('/api/events', eventRoutes);
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
|||||||
22
server/src/routes/userRoutes.ts
Normal file
22
server/src/routes/userRoutes.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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));
|
||||||
|
router.get('/:id/activity', userController.getUserActivity.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;
|
||||||
61
server/src/scripts/fixDuplicateUsernames.ts
Normal file
61
server/src/scripts/fixDuplicateUsernames.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Script to fix duplicate usernames that have the pattern user_user_xxxxx
|
||||||
|
*
|
||||||
|
* This script updates usernames that were created with the old logic
|
||||||
|
* where we used `user_${clerkId}` and the clerkId already started with 'user_'
|
||||||
|
*
|
||||||
|
* Usage: npx ts-node src/scripts/fixDuplicateUsernames.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '../config/database';
|
||||||
|
|
||||||
|
async function fixDuplicateUsernames() {
|
||||||
|
console.log('🔍 Finding users with duplicate username pattern...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find all users with usernames starting with 'user_user_'
|
||||||
|
const usersToFix = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
username: {
|
||||||
|
startsWith: 'user_user_',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Found ${usersToFix.length} users to fix\n`);
|
||||||
|
|
||||||
|
if (usersToFix.length === 0) {
|
||||||
|
console.log('✅ No users need fixing!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix each user
|
||||||
|
for (const user of usersToFix) {
|
||||||
|
const oldUsername = user.username;
|
||||||
|
|
||||||
|
// Remove the duplicate 'user_' prefix
|
||||||
|
const newUsername = oldUsername.replace(/^user_user_/, 'user');
|
||||||
|
|
||||||
|
console.log(`Updating user ${user.id}:`);
|
||||||
|
console.log(` Old: ${oldUsername}`);
|
||||||
|
console.log(` New: ${newUsername}`);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { username: newUsername },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(' ✅ Updated\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Successfully fixed ${usersToFix.length} usernames!`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error fixing usernames:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
fixDuplicateUsernames();
|
||||||
135
server/src/scripts/testUserEndpoints.ts
Normal file
135
server/src/scripts/testUserEndpoints.ts
Normal file
@@ -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();
|
||||||
448
server/src/services/userService.ts
Normal file
448
server/src/services/userService.ts
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
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<string> {
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Generate a clean username from Clerk ID (remove 'user_' prefix if present)
|
||||||
|
const cleanClerkId = clerkId.startsWith('user_') ? clerkId.substring(5) : clerkId;
|
||||||
|
const defaultUsername = clerkUser.username || `user${cleanClerkId.substring(0, 8)}`;
|
||||||
|
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
clerkId,
|
||||||
|
email: clerkUser.emailAddresses[0]?.emailAddress || `${clerkId}@temp.com`,
|
||||||
|
username: defaultUsername,
|
||||||
|
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 or Clerk ID
|
||||||
|
*/
|
||||||
|
async getUserProfile(userId: string) {
|
||||||
|
// Check if this is a Clerk ID (starts with 'user_')
|
||||||
|
const isClerkId = userId.startsWith('user_');
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: isClerkId ? { clerkId: userId } : { 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: {
|
||||||
|
where: { attended: true },
|
||||||
|
},
|
||||||
|
organizedEvents: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
reportCount: user._count.reports,
|
||||||
|
eventCount: user._count.eventRegistrations,
|
||||||
|
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 by database ID or Clerk ID
|
||||||
|
*/
|
||||||
|
async getUserReports(userId: string) {
|
||||||
|
// Check if this is a Clerk ID and convert to database ID
|
||||||
|
const isClerkId = userId.startsWith('user_');
|
||||||
|
let dbUserId = userId;
|
||||||
|
|
||||||
|
if (isClerkId) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { clerkId: userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
dbUserId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reports = await prisma.report.findMany({
|
||||||
|
where: { userId: dbUserId },
|
||||||
|
include: {
|
||||||
|
environmentalConcerns: true,
|
||||||
|
verifications: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's events (both registered and organized) by database ID or Clerk ID
|
||||||
|
*/
|
||||||
|
async getUserEvents(userId: string) {
|
||||||
|
// Check if this is a Clerk ID and convert to database ID
|
||||||
|
const isClerkId = userId.startsWith('user_');
|
||||||
|
let dbUserId = userId;
|
||||||
|
|
||||||
|
if (isClerkId) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { clerkId: userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
dbUserId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get events user has registered for
|
||||||
|
const registeredEvents = await prisma.eventRegistration.findMany({
|
||||||
|
where: { userId: dbUserId },
|
||||||
|
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: dbUserId },
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's activity timeline
|
||||||
|
*/
|
||||||
|
async getUserActivity(userId: string) {
|
||||||
|
// Check if this is a Clerk ID and convert to database ID
|
||||||
|
const isClerkId = userId.startsWith('user_');
|
||||||
|
let dbUserId = userId;
|
||||||
|
|
||||||
|
if (isClerkId) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { clerkId: userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
dbUserId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's reports
|
||||||
|
const reports = await prisma.report.findMany({
|
||||||
|
where: { userId: dbUserId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
litterType: true,
|
||||||
|
quantity: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's event registrations
|
||||||
|
const eventRegistrations = await prisma.eventRegistration.findMany({
|
||||||
|
where: { userId: dbUserId },
|
||||||
|
include: {
|
||||||
|
event: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
scheduledDate: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
locationName: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { registeredAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's organized events
|
||||||
|
const organizedEvents = await prisma.event.findMany({
|
||||||
|
where: { organizerId: dbUserId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
createdAt: true,
|
||||||
|
scheduledDate: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
locationName: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine and format activities
|
||||||
|
const activities = [
|
||||||
|
...reports.map(report => ({
|
||||||
|
id: report.id,
|
||||||
|
type: 'report' as const,
|
||||||
|
timestamp: report.createdAt || new Date(),
|
||||||
|
title: `Reported ${report.litterType} litter`,
|
||||||
|
description: report.description || `${report.quantity} amount of ${report.litterType}`,
|
||||||
|
location: {
|
||||||
|
latitude: Number(report.latitude),
|
||||||
|
longitude: Number(report.longitude),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...eventRegistrations.map(reg => {
|
||||||
|
const activityType: 'event_attended' = 'event_attended';
|
||||||
|
return {
|
||||||
|
id: reg.event.id,
|
||||||
|
type: activityType,
|
||||||
|
timestamp: reg.registeredAt || new Date(),
|
||||||
|
title: reg.event.title,
|
||||||
|
description: reg.event.description,
|
||||||
|
location: {
|
||||||
|
latitude: Number(reg.event.latitude),
|
||||||
|
longitude: Number(reg.event.longitude),
|
||||||
|
locationName: reg.event.locationName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...organizedEvents.map(event => {
|
||||||
|
const activityType: 'event_created' | 'event_completed' =
|
||||||
|
event.status === 'completed' ? 'event_completed' : 'event_created';
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
type: activityType,
|
||||||
|
timestamp: event.createdAt || new Date(),
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
location: {
|
||||||
|
latitude: Number(event.latitude),
|
||||||
|
longitude: Number(event.longitude),
|
||||||
|
locationName: event.locationName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sort by timestamp descending
|
||||||
|
activities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||||
|
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate and update user impact score
|
||||||
|
* Impact score = (reports * 10) + (events attended * 20) + (events organized * 50)
|
||||||
|
*/
|
||||||
|
async calculateImpactScore(userId: string): Promise<number> {
|
||||||
|
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();
|
||||||
13
src/App.tsx
13
src/App.tsx
@@ -22,7 +22,8 @@ import {
|
|||||||
PhotoUploadDemo,
|
PhotoUploadDemo,
|
||||||
LocationSelectorDemo,
|
LocationSelectorDemo,
|
||||||
ReportDetailDemo,
|
ReportDetailDemo,
|
||||||
WhyCleanPage
|
WhyCleanPage,
|
||||||
|
UserProfilePage
|
||||||
} from './pages';
|
} from './pages';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
@@ -129,6 +130,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile/:userId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<UserProfilePage />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Demo routes for testing */}
|
{/* Demo routes for testing */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export function UserProfile() {
|
|||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewProfile = () => {
|
||||||
|
if (user?.id) {
|
||||||
|
navigate(`/profile/${user.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
@@ -30,7 +36,10 @@ export function UserProfile() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex items-center space-x-3">
|
<button
|
||||||
|
onClick={handleViewProfile}
|
||||||
|
className="flex items-center space-x-3 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
{user.imageUrl && (
|
{user.imageUrl && (
|
||||||
<img
|
<img
|
||||||
src={user.imageUrl}
|
src={user.imageUrl}
|
||||||
@@ -38,7 +47,7 @@ export function UserProfile() {
|
|||||||
className="h-10 w-10 rounded-full object-cover border-2 border-gray-200"
|
className="h-10 w-10 rounded-full object-cover border-2 border-gray-200"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block text-left">
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<p className="text-sm font-medium text-gray-900">
|
||||||
{user.fullName || user.username || 'User'}
|
{user.fullName || user.username || 'User'}
|
||||||
</p>
|
</p>
|
||||||
@@ -46,7 +55,7 @@ export function UserProfile() {
|
|||||||
{user.primaryEmailAddress?.emailAddress}
|
{user.primaryEmailAddress?.emailAddress}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
|||||||
146
src/pages/USER_PROFILE_IMPLEMENTATION.md
Normal file
146
src/pages/USER_PROFILE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# User Profile Page Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The User Profile Page displays comprehensive user statistics, contributions, and activity history. It provides both public profile viewing and personal profile editing capabilities.
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### 1. User Statistics Display
|
||||||
|
- **Reports Submitted**: Total count of litter reports submitted by the user
|
||||||
|
- **Events Attended**: Total count of cleanup events the user has participated in
|
||||||
|
- **Impact Score**: Calculated score based on user contributions (reports × 10 + events × 20)
|
||||||
|
|
||||||
|
### 2. Profile Information
|
||||||
|
- User avatar, name, username, and email
|
||||||
|
- Editable profile fields (first name, last name, username) for own profile
|
||||||
|
- Edit/Save/Cancel functionality for profile updates
|
||||||
|
|
||||||
|
### 3. Personal Contribution Map
|
||||||
|
- Interactive Leaflet map showing all report locations submitted by the user
|
||||||
|
- Markers for each report with popup details (litter type, date)
|
||||||
|
- Automatically centers on user's first report location
|
||||||
|
|
||||||
|
### 4. Activity Timeline
|
||||||
|
- Chronological list of all user activities
|
||||||
|
- Activity types:
|
||||||
|
- Report submissions
|
||||||
|
- Event creation
|
||||||
|
- Event attendance
|
||||||
|
- Event completion
|
||||||
|
- Each activity shows title, description, location, and timestamp
|
||||||
|
- Visual icons for different activity types
|
||||||
|
|
||||||
|
### 5. Tabbed Interface
|
||||||
|
Four tabs for organizing profile content:
|
||||||
|
- **Overview**: Statistics, contribution map, and recent activity
|
||||||
|
- **Reports**: Grid view of all submitted reports with photos
|
||||||
|
- **Events**: List of all events (created and attended) with details
|
||||||
|
- **Activity**: Full activity timeline
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- `/profile/:userId` - View user profile (protected route)
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### UserProfilePage (`src/pages/UserProfilePage.tsx`)
|
||||||
|
Main profile page component with:
|
||||||
|
- Profile header with avatar and statistics
|
||||||
|
- Tabbed navigation
|
||||||
|
- Contribution map
|
||||||
|
- Reports grid
|
||||||
|
- Events list
|
||||||
|
- Activity timeline
|
||||||
|
- Profile editing functionality
|
||||||
|
|
||||||
|
### UserProfile (`src/components/auth/UserProfile.tsx`)
|
||||||
|
Updated header component that now links to the user's profile page when clicked.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### userService (`src/services/userService.ts`)
|
||||||
|
API service for user-related operations:
|
||||||
|
- `getProfile(userId)` - Fetch user profile with statistics
|
||||||
|
- `updateProfile(userId, data)` - Update user profile information
|
||||||
|
- `getUserReports(userId)` - Get all reports by user
|
||||||
|
- `getUserEvents(userId)` - Get all events (created and attended)
|
||||||
|
- `getUserActivity(userId)` - Get activity timeline
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### UserProfile
|
||||||
|
```typescript
|
||||||
|
interface UserProfile extends User {
|
||||||
|
reportCount: number;
|
||||||
|
eventCount: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UserActivity
|
||||||
|
```typescript
|
||||||
|
interface UserActivity {
|
||||||
|
id: string;
|
||||||
|
type: 'report' | 'event_created' | 'event_attended' | 'event_completed';
|
||||||
|
timestamp: Date;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
locationName?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UpdateProfileDTO
|
||||||
|
```typescript
|
||||||
|
interface UpdateProfileDTO {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
username?: string;
|
||||||
|
notificationEmail?: boolean;
|
||||||
|
notificationInApp?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Requirements
|
||||||
|
|
||||||
|
The following API endpoints need to be implemented on the backend:
|
||||||
|
|
||||||
|
1. `GET /api/users/:id` - Get user profile with statistics
|
||||||
|
2. `PATCH /api/users/:id` - Update user profile
|
||||||
|
3. `GET /api/users/:id/reports` - Get user's reports
|
||||||
|
4. `GET /api/users/:id/events` - Get user's events
|
||||||
|
5. `GET /api/users/:id/activity` - Get user's activity timeline
|
||||||
|
|
||||||
|
## Impact Score Calculation
|
||||||
|
|
||||||
|
The impact score is calculated as:
|
||||||
|
```
|
||||||
|
Impact Score = (Number of Reports × 10) + (Number of Events × 20)
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives more weight to event participation, encouraging community engagement.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
- Any authenticated user can view any profile
|
||||||
|
- Only the profile owner can edit their own profile
|
||||||
|
- Edit button only appears when viewing own profile
|
||||||
|
|
||||||
|
## Mobile Responsiveness
|
||||||
|
|
||||||
|
- Responsive grid layouts for statistics and reports
|
||||||
|
- Collapsible navigation for mobile devices
|
||||||
|
- Touch-friendly map controls
|
||||||
|
- Optimized for screens from 320px to desktop
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Achievement badges based on milestones
|
||||||
|
- Leaderboard integration
|
||||||
|
- Social features (follow users, share profiles)
|
||||||
|
- Export personal data
|
||||||
|
- Profile privacy settings
|
||||||
|
- Custom profile themes
|
||||||
511
src/pages/UserProfilePage.tsx
Normal file
511
src/pages/UserProfilePage.tsx
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { userService, type UserProfile, type UserActivity } from '@/services/userService';
|
||||||
|
import type { Report, CleanupEvent } from '@/types';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
// Fix Leaflet default marker icon issue
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function UserProfilePage() {
|
||||||
|
const { userId } = useParams<{ userId: string }>();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
|
const [events, setEvents] = useState<{ registered: CleanupEvent[]; organized: CleanupEvent[] }>({ registered: [], organized: [] });
|
||||||
|
const [activity, setActivity] = useState<UserActivity[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'reports' | 'events' | 'activity'>('overview');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
username: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOwnProfile = currentUser?.id === userId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) {
|
||||||
|
setError('User ID is required');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfileData();
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const loadProfileData = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const [profileData, reportsData, eventsData, activityData] = await Promise.all([
|
||||||
|
userService.getProfile(userId),
|
||||||
|
userService.getUserReports(userId),
|
||||||
|
userService.getUserEvents(userId),
|
||||||
|
userService.getUserActivity(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setProfile(profileData);
|
||||||
|
setReports(reportsData);
|
||||||
|
setEvents(eventsData);
|
||||||
|
setActivity(activityData);
|
||||||
|
|
||||||
|
setEditForm({
|
||||||
|
firstName: profileData.firstName || '',
|
||||||
|
lastName: profileData.lastName || '',
|
||||||
|
username: profileData.username || '',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load profile');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProfile = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (profile) {
|
||||||
|
setEditForm({
|
||||||
|
firstName: profile.firstName || '',
|
||||||
|
lastName: profile.lastName || '',
|
||||||
|
username: profile.username || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userService.updateProfile(userId, editForm);
|
||||||
|
setIsEditing(false);
|
||||||
|
await loadProfileData();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateImpactScore = () => {
|
||||||
|
if (!profile) return 0;
|
||||||
|
// Impact score calculation: reports * 10 + events attended * 20
|
||||||
|
const reportCount = profile.reportCount || 0;
|
||||||
|
const eventCount = profile.eventCount || 0;
|
||||||
|
return reportCount * 10 + eventCount * 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Date(date).toLocaleDateString('en-IE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading profile...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !profile) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600 mb-4">{error || 'Profile not found'}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{profile.avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={profile.avatarUrl}
|
||||||
|
alt={profile.username}
|
||||||
|
className="h-20 w-20 rounded-full object-cover border-4 border-blue-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.firstName}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, firstName: e.target.value })}
|
||||||
|
placeholder="First name"
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.lastName}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, lastName: e.target.value })}
|
||||||
|
placeholder="Last name"
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.username}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
|
||||||
|
placeholder="Username"
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{profile.firstName && profile.lastName
|
||||||
|
? `${profile.firstName} ${profile.lastName}`
|
||||||
|
: profile.username}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">@{profile.username}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOwnProfile && (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveProfile}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleEditProfile}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600">Reports Submitted</p>
|
||||||
|
<p className="text-3xl font-bold text-blue-600">{profile.reportCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600">Events Attended</p>
|
||||||
|
<p className="text-3xl font-bold text-green-600">{profile.eventCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600">Impact Score</p>
|
||||||
|
<p className="text-3xl font-bold text-purple-600">{calculateImpactScore()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md mb-6">
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex -mb-px">
|
||||||
|
{(['overview', 'reports', 'events', 'activity'] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`px-6 py-3 text-sm font-medium capitalize ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'border-b-2 border-blue-600 text-blue-600'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Contribution Map</h2>
|
||||||
|
{reports.length > 0 && reports[0] ? (
|
||||||
|
<div className="h-96 rounded-lg overflow-hidden">
|
||||||
|
<MapContainer
|
||||||
|
center={[reports[0].latitude, reports[0].longitude]}
|
||||||
|
zoom={10}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{reports.map((report) => (
|
||||||
|
<Marker
|
||||||
|
key={report.id}
|
||||||
|
position={[report.latitude, report.longitude]}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold capitalize">{report.litterType}</p>
|
||||||
|
<p className="text-sm text-gray-600">{formatDate(report.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No reports submitted yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Recent Activity</h2>
|
||||||
|
{activity.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activity.slice(0, 5).map((item, index) => (
|
||||||
|
<div key={`overview-${item.type}-${item.id}-${index}`} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{item.type === 'report' && (
|
||||||
|
<div className="h-10 w-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-blue-600">📍</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.type.startsWith('event') && (
|
||||||
|
<div className="h-10 w-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-green-600">🗓️</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900">{item.title}</p>
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-sm text-gray-600">{item.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{formatDate(item.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No activity yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'reports' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Submitted Reports</h2>
|
||||||
|
{reports.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<div key={report.id} className="bg-gray-50 rounded-lg p-4">
|
||||||
|
{report.photoUrls.length > 0 && (
|
||||||
|
<img
|
||||||
|
src={report.photoUrls[0]}
|
||||||
|
alt="Report"
|
||||||
|
className="w-full h-48 object-cover rounded-md mb-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-semibold capitalize">{report.litterType}</p>
|
||||||
|
<p className="text-sm text-gray-600 capitalize">Quantity: {report.quantity}</p>
|
||||||
|
{report.description && (
|
||||||
|
<p className="text-sm text-gray-700">{report.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500">{formatDate(report.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No reports submitted yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'events' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Registered Events */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Registered Events</h2>
|
||||||
|
{events.registered.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{events.registered.map((event) => (
|
||||||
|
<div key={`registered-${event.id}`} className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{event.title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{event.description}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
📍 {event.locationName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
🗓️ {formatDate(event.scheduledDate)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
👥 {event.participantCount} participants
|
||||||
|
</p>
|
||||||
|
{event.litterCollected && (
|
||||||
|
<p className="text-sm text-green-600 font-medium mt-2">
|
||||||
|
♻️ {event.litterCollected} kg collected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
event.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: event.status === 'upcoming'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No registered events yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organized Events */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Organized Events</h2>
|
||||||
|
{events.organized.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{events.organized.map((event) => (
|
||||||
|
<div key={`organized-${event.id}`} className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{event.title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{event.description}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
📍 {event.locationName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
🗓️ {formatDate(event.scheduledDate)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
👥 {event.participantCount} participants
|
||||||
|
</p>
|
||||||
|
{event.litterCollected && (
|
||||||
|
<p className="text-sm text-green-600 font-medium mt-2">
|
||||||
|
♻️ {event.litterCollected} kg collected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
event.status === 'completed'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: event.status === 'upcoming'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No organized events yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Activity Timeline</h2>
|
||||||
|
{activity.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activity.map((item, index) => (
|
||||||
|
<div key={`${item.type}-${item.id}-${index}`} className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
{item.type === 'report' && <span className="text-xl">📍</span>}
|
||||||
|
{item.type === 'event_created' && <span className="text-xl">➕</span>}
|
||||||
|
{item.type === 'event_attended' && <span className="text-xl">✅</span>}
|
||||||
|
{item.type === 'event_completed' && <span className="text-xl">🎉</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-gray-50 rounded-lg p-4">
|
||||||
|
<p className="font-medium text-gray-900">{item.title}</p>
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
|
||||||
|
)}
|
||||||
|
{item.location && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
📍 {item.location.locationName || `${item.location.latitude?.toFixed(4)}, ${item.location.longitude?.toFixed(4)}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 mt-2">{formatDate(item.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No activity yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ export { PhotoUploadDemo } from './PhotoUploadDemo';
|
|||||||
export { default as LocationSelectorDemo } from './LocationSelectorDemo';
|
export { default as LocationSelectorDemo } from './LocationSelectorDemo';
|
||||||
export { ReportDetailDemo } from './ReportDetailDemo';
|
export { ReportDetailDemo } from './ReportDetailDemo';
|
||||||
export { WhyCleanPage } from './WhyCleanPage';
|
export { WhyCleanPage } from './WhyCleanPage';
|
||||||
|
export { UserProfilePage } from './UserProfilePage';
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export { apiClient } from './api';
|
|||||||
export { reportService } from './reportService';
|
export { reportService } from './reportService';
|
||||||
export { analyticsService } from './analyticsService';
|
export { analyticsService } from './analyticsService';
|
||||||
export { eventService } from './eventService';
|
export { eventService } from './eventService';
|
||||||
|
export { userService } from './userService';
|
||||||
export type { ApiError } from './api';
|
export type { ApiError } from './api';
|
||||||
export type { CreateReportPayload, UploadPhotoResponse } from './reportService';
|
export type { CreateReportPayload, UploadPhotoResponse } from './reportService';
|
||||||
export type { CreateEventDTO, UpdateEventDTO, EventFilters, EventRegistration } from './eventService';
|
export type { CreateEventDTO, UpdateEventDTO, EventFilters, EventRegistration } from './eventService';
|
||||||
|
export type { UserProfile, UserActivity, UpdateProfileDTO } from './userService';
|
||||||
|
|||||||
91
src/services/userService.ts
Normal file
91
src/services/userService.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// User service for profile and user-related API calls
|
||||||
|
import { apiClient } from './api';
|
||||||
|
import type { User, Report, CleanupEvent } from '@/types';
|
||||||
|
|
||||||
|
export interface UserProfile extends User {
|
||||||
|
reportCount: number;
|
||||||
|
eventCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserActivity {
|
||||||
|
id: string;
|
||||||
|
type: 'report' | 'event_created' | 'event_attended' | 'event_completed';
|
||||||
|
timestamp: Date;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
locationName?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileDTO {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
notificationEmail: boolean;
|
||||||
|
notificationInApp: boolean;
|
||||||
|
areasOfInterest?: Array<{
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
radius: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userService = {
|
||||||
|
/**
|
||||||
|
* Get user profile by ID
|
||||||
|
*/
|
||||||
|
async getProfile(userId: string): Promise<UserProfile> {
|
||||||
|
return apiClient.get<UserProfile>(`/users/${userId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user profile
|
||||||
|
*/
|
||||||
|
async updateProfile(userId: string, data: UpdateProfileDTO): Promise<User> {
|
||||||
|
return apiClient.patch<User>(`/users/${userId}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's reports
|
||||||
|
*/
|
||||||
|
async getUserReports(userId: string): Promise<Report[]> {
|
||||||
|
return apiClient.get<Report[]>(`/users/${userId}/reports`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's events (created and attended)
|
||||||
|
*/
|
||||||
|
async getUserEvents(userId: string): Promise<CleanupEvent[]> {
|
||||||
|
return apiClient.get<CleanupEvent[]>(`/users/${userId}/events`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's activity timeline
|
||||||
|
*/
|
||||||
|
async getUserActivity(userId: string): Promise<UserActivity[]> {
|
||||||
|
return apiClient.get<UserActivity[]>(`/users/${userId}/activity`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification preferences
|
||||||
|
*/
|
||||||
|
async updateNotificationPreferences(
|
||||||
|
userId: string,
|
||||||
|
preferences: NotificationPreferences
|
||||||
|
): Promise<NotificationPreferences> {
|
||||||
|
return apiClient.patch<NotificationPreferences>(`/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`, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user