Files
library/api/src/app/routes/users/index.ts
T
hikari 34c7ca8ba2 feat: implement user profiles and settings
Add comprehensive user profile system allowing users to showcase
their activity and customize their profiles.

Database Changes:
- Added profile fields to User model: slug, displayName, bio, profilePublic
- Added index on slug field for efficient lookups

API Changes:
- Added GET /users/me endpoint to fetch current user
- Added PUT /users/me endpoint to update user settings
- Added GET /users/profile/:identifier endpoint for public profiles
- Updated UserService with profile methods and statistics
- Modified AuthService to include profile fields in user responses

Frontend Changes:
- Created ProfileComponent to display user profiles with stats
- Created SettingsComponent for profile customization
- Added profile and settings routes
- Updated header dropdown menu with profile links
- Enhanced UserService with profile methods
- Added updateUser method to AuthService

Features:
- Custom profile slugs for clean URLs
- Display names separate from usernames
- User bios (up to 500 characters)
- Public/private profile toggle
- Activity statistics (suggestions, likes, comments, acceptance rate)
- Badge display (Staff, Mod, VIP, Discord Member)
- Beautiful witch-themed styling

Closes #45
2026-02-19 17:27:35 -08:00

216 lines
5.4 KiB
TypeScript

/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import { User, AuditAction, AuditCategory } from "@library/shared-types";
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();
app.get<{ Reply: User[] }>(
"/",
{
preValidation: [app.authenticate, adminGuard],
},
async () => {
return userService.getAllUsers();
}
);
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",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { id } = request.params;
return userService.getUserById(id);
}
);
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
"/:id/ban",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id } = request.params;
const currentUser = request.user as { id: string };
if (currentUser.id === id) {
return reply.code(400).send({ error: "You cannot ban yourself" });
}
const user = await userService.banUser(id);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
await AuditService.logFromRequest(request, {
action: AuditAction.userBan,
category: AuditCategory.admin,
targetUserId: id,
details: `Banned user: ${user.username}`,
});
return user;
}
);
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
"/:id/unban",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id } = request.params;
const user = await userService.unbanUser(id);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
await AuditService.logFromRequest(request, {
action: AuditAction.userUnban,
category: AuditCategory.admin,
targetUserId: id,
details: `Unbanned user: ${user.username}`,
});
return user;
}
);
};
export default usersRoutes;