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:
281
server/src/services/userService.ts
Normal file
281
server/src/services/userService.ts
Normal 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();
|
||||
Reference in New Issue
Block a user