feat: implement user profile endpoints (task 31)

- Add GET /api/users/:id for profile retrieval with stats
- Add PATCH /api/users/:id for profile updates
- Add GET /api/users/:id/reports to fetch user's reports
- Add GET /api/users/:id/events to fetch user's events (registered & organized)
- Add PATCH /api/users/:id/preferences for notification settings
- Add POST /api/users/:id/calculate-impact for impact score calculation
- Create userService with all business logic
- Create userController with request handling
- Create userRoutes and register in main server
- Update frontend userService with new endpoints
- Add comprehensive documentation in USER_PROFILE_ENDPOINTS.md
- Add test script for manual endpoint testing

Requirements satisfied: 6.3, 6.4, 6.5, 10.4
This commit is contained in:
2025-11-16 21:38:16 +00:00
parent f02f23e185
commit c84bcb2fd6
8 changed files with 1151 additions and 4 deletions

View File

@@ -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();

View File

@@ -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) => {

View File

@@ -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;

View 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();

View File

@@ -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<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);
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<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();