diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9f8043e..77b0191 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -182,6 +182,10 @@ model User { username String email String @unique avatar String? + slug String? + displayName String? + bio String? + profilePublic Boolean @default(true) isAdmin Boolean @default(false) isBanned Boolean @default(false) inDiscord Boolean @default(false) @@ -194,6 +198,8 @@ model User { suggestions Suggestion[] likes Like[] refreshTokens RefreshToken[] + + @@index([slug], map: "User_slug_key") } model Comment { diff --git a/api/src/app/routes/users/index.ts b/api/src/app/routes/users/index.ts index 710f765..390c958 100644 --- a/api/src/app/routes/users/index.ts +++ b/api/src/app/routes/users/index.ts @@ -10,6 +10,35 @@ import { UserService } from "../../services/user.service"; import { AuditService } from "../../services/audit.service"; import { adminGuard } from "../../middleware/admin-guard"; +interface UpdateUserSettingsBody { + slug?: string; + displayName?: string; + bio?: string; + profilePublic?: boolean; +} + +interface UserProfileResponse { + id: string; + username: string; + displayName?: string; + avatar?: string; + bio?: string; + slug?: string; + badges: { + isStaff: boolean; + isMod: boolean; + isVip: boolean; + inDiscord: boolean; + }; + stats: { + suggestionsCount: number; + suggestionsAcceptedCount: number; + likesCount: number; + commentsCount: number; + }; + createdAt: Date; +} + const usersRoutes: FastifyPluginAsync = async (app) => { const userService = new UserService(); @@ -23,6 +52,100 @@ const usersRoutes: FastifyPluginAsync = async (app) => { } ); + app.get<{ Reply: User }>( + "/me", + { + preValidation: [app.authenticate], + }, + async (request) => { + const currentUser = request.user as { id: string }; + const user = await userService.getUserById(currentUser.id); + if (!user) { + throw new Error("User not found"); + } + return user; + } + ); + + app.put<{ Body: UpdateUserSettingsBody; Reply: User | { error: string } }>( + "/me", + { + preValidation: [app.authenticate], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const currentUser = request.user as { id: string }; + const updates = request.body; + + // If slug is being updated, check if it's unique + if (updates.slug) { + const existingUser = await userService.getUserBySlug(updates.slug); + if (existingUser && existingUser.id !== currentUser.id) { + return reply.code(400).send({ error: "Slug already taken" }); + } + } + + const updatedUser = await userService.updateUserSettings( + currentUser.id, + updates + ); + + if (!updatedUser) { + return reply.code(404).send({ error: "User not found" }); + } + + return updatedUser; + } + ); + + app.get<{ + Params: { identifier: string }; + Reply: UserProfileResponse | { error: string }; + }>( + "/profile/:identifier", + async (request, reply) => { + const { identifier } = request.params; + + try { + const profile = await userService.getUserProfile(identifier); + + if (!profile) { + return reply.code(404).send({ error: "User not found" }); + } + + if (!profile.profilePublic) { + // Check if the requesting user is viewing their own profile + const currentUser = request.user as { id: string } | undefined; + if (!currentUser || currentUser.id !== profile.id) { + return reply + .code(403) + .send({ error: "This profile is private" }); + } + } + + return { + id: profile.id, + username: profile.username, + displayName: profile.displayName, + avatar: profile.avatar, + bio: profile.bio, + slug: profile.slug, + badges: { + isStaff: profile.isStaff, + isMod: profile.isMod, + isVip: profile.isVip, + inDiscord: profile.inDiscord, + }, + stats: profile.stats, + createdAt: profile.createdAt, + }; + } catch (error) { + console.error("Error fetching profile:", error); + return reply.code(500).send({ error: "Failed to fetch profile" }); + } + } + ); + app.get<{ Params: { id: string }; Reply: User | null }>( "/:id", { diff --git a/api/src/app/services/auth.service.ts b/api/src/app/services/auth.service.ts index 010473d..10ad88d 100644 --- a/api/src/app/services/auth.service.ts +++ b/api/src/app/services/auth.service.ts @@ -71,6 +71,10 @@ export class AuthService { username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, + slug: dbUser.slug || undefined, + displayName: dbUser.displayName || undefined, + bio: dbUser.bio || undefined, + profilePublic: dbUser.profilePublic, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -167,6 +171,10 @@ export class AuthService { username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, + slug: dbUser.slug || undefined, + displayName: dbUser.displayName || undefined, + bio: dbUser.bio || undefined, + profilePublic: dbUser.profilePublic, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -217,6 +225,10 @@ export class AuthService { username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, + slug: dbUser.slug || undefined, + displayName: dbUser.displayName || undefined, + bio: dbUser.bio || undefined, + profilePublic: dbUser.profilePublic, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts index 6d825aa..ad6009d 100644 --- a/api/src/app/services/user.service.ts +++ b/api/src/app/services/user.service.ts @@ -6,6 +6,7 @@ import { User } from "@library/shared-types"; import { prisma } from "../lib/prisma"; +import { SuggestionStatus } from "@prisma/client"; export class UserService { private prisma = prisma; @@ -21,6 +22,10 @@ export class UserService { username: user.username, email: user.email, avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -45,6 +50,10 @@ export class UserService { username: user.username, email: user.email, avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -66,6 +75,10 @@ export class UserService { username: user.username, email: user.email, avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -87,6 +100,10 @@ export class UserService { username: user.username, email: user.email, avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -104,4 +121,137 @@ export class UserService { return user?.isBanned ?? false; } + + async getUserBySlug(slug: string): Promise { + const user = await this.prisma.user.findFirst({ + where: { slug }, + }); + + if (!user) { + return null; + } + + return { + id: user.id, + discordId: user.discordId, + username: user.username, + email: user.email, + avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, + isAdmin: user.isAdmin, + isBanned: user.isBanned, + inDiscord: user.inDiscord, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + }; + } + + async updateUserSettings( + id: string, + updates: { + slug?: string; + displayName?: string; + bio?: string; + profilePublic?: boolean; + } + ): Promise { + const user = await this.prisma.user.update({ + where: { id }, + data: updates, + }); + + return { + id: user.id, + discordId: user.discordId, + username: user.username, + email: user.email, + avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, + isAdmin: user.isAdmin, + isBanned: user.isBanned, + inDiscord: user.inDiscord, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + }; + } + + async getUserProfile(identifier: string): Promise<{ + id: string; + username: string; + displayName?: string | null; + avatar?: string | null; + bio?: string | null; + slug?: string | null; + isStaff: boolean; + isMod: boolean; + isVip: boolean; + inDiscord: boolean; + profilePublic: boolean; + createdAt: Date; + stats: { + suggestionsCount: number; + suggestionsAcceptedCount: number; + likesCount: number; + commentsCount: number; + }; + } | null> { + // Try to find by slug first, then by id if it's a valid ObjectId + const isValidObjectId = /^[0-9a-f]{24}$/i.test(identifier); + + const whereConditions = isValidObjectId + ? [{ slug: identifier }, { id: identifier }] + : [{ slug: identifier }]; + + const user = await this.prisma.user.findFirst({ + where: { + OR: whereConditions, + }, + include: { + suggestions: { + select: { id: true, status: true }, + }, + likes: { + select: { id: true }, + }, + comments: { + select: { id: true }, + }, + }, + }); + + if (!user) { + return null; + } + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + avatar: user.avatar, + bio: user.bio, + slug: user.slug, + isStaff: user.isStaff, + isMod: user.isMod, + isVip: user.isVip, + inDiscord: user.inDiscord, + profilePublic: user.profilePublic, + createdAt: user.createdAt, + stats: { + suggestionsCount: user.suggestions.length, + suggestionsAcceptedCount: user.suggestions.filter( + (suggestion) => suggestion.status === SuggestionStatus.ACCEPTED + ).length, + likesCount: user.likes.length, + commentsCount: user.comments.length, + }, + }; + } } diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 9e77a19..87dcca1 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -49,6 +49,14 @@ export const appRoutes: Route[] = [ path: 'my-likes', loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent) }, + { + path: 'profile/:identifier', + loadComponent: () => import('./components/profile/profile.component').then(m => m.ProfileComponent) + }, + { + path: 'settings', + loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent) + }, { path: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 6801e87..05931ad 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -50,6 +50,8 @@ import { ApiService } from '../../services/api.service'; } @if (showDropdown()) {