feat: implement user profiles with achievements and primary badge system (#58)
Node.js CI / CI (push) Successful in 1m21s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m22s

## Summary

This PR implements comprehensive user profile enhancements including:
- User profile pages showing stats, badges, social links, and bio
- Achievement system with 62 achievements across 5 categories
- Primary badge selection allowing users to display their preferred badge
- Admin profile editing capabilities

## Changes

### User Profiles (#45)
- **Frontend**: User profile pages with stats display
  - Profile cards showing avatar, display name, username, and bio
  - Social links section (Website, GitHub, Bluesky, LinkedIn, Twitch, YouTube, Discord)
  - Stats display (suggestions, accepted suggestions, likes, comments)
  - Recent achievements section
  - Badge display
  - Report button for other users' profiles
- **Backend**: Profile API endpoints
  - Get user profile by username or ID
  - Profile includes stats, badges, and achievement points

### Achievement System (#48)
- **Database**: UserAchievement model for tracking progress
- **62 Total Achievements** across 5 categories:
  - **Suggestions (15)**: First suggestion through ultimate curator
  - **Likes (12)**: First like through legendary fan
  - **Comments (12)**: First comment through review legend
  - **Engagement (15)**: Login streaks and activity milestones
  - **Reports (8)**: Valid reports and accuracy tracking
- **Backend**: AchievementService with real-time checking
  - Integrated into all user interaction points
  - API endpoints for achievement data
  - Progress tracking to avoid recalculation
- **Frontend**: Achievements page and profile integration
  - Full achievements page with category filtering
  - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond)
  - Progress indicators for in-progress achievements
  - Recent achievements on profile pages

### Primary Badge System (#49)
- **Database**: Add primaryBadge field to User model
- **Backend**: Update profile endpoints to include primary badge
- **Frontend**: Primary badge selection in settings
  - Only shows badges the user has earned
  - Displayed on profile page
  - Displayed in comments (next to username)
  - Falls back to no badge if selection is invalid
- **Admin Features**: Admin can edit any user's primary badge

### Admin Enhancements
- Comprehensive profile editing modal for admins
  - Edit display name, bio, slug, social links
  - Set primary badge for users
  - Visual feedback for save/error states
- Admin action buttons in report review modals
  - Ban user, delete comment, edit profile
  - Integrated with report workflow

### Quality Improvements
- Improved dropdown option contrast for readability
- Hide all badges when no primary badge is selected
- "View All" achievements link only shown on own profile
- Improved achievement text readability

## Testing

-  User profiles display correctly with stats and badges
-  Achievement checking works for all interaction types
-  Primary badge selection persists and displays correctly
-  Admin profile editing saves successfully
-  Report workflow integrated with admin actions
-  Achievements page shows all 62 achievements with filtering
-  Text readability improved across components

Closes #45
Closes #48
Closes #49

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #58
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #58.
This commit is contained in:
2026-02-19 22:21:17 -08:00
committed by Naomi Carrigan
parent 7579f1ec97
commit 86404497f0
58 changed files with 8325 additions and 449 deletions
+206 -1
View File
@@ -5,11 +5,56 @@
*/
import { FastifyPluginAsync } from "fastify";
import { User, AuditAction, AuditCategory } from "@library/shared-types";
import { User, AuditAction, AuditCategory, PrimaryBadge } 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;
primaryBadge?: PrimaryBadge;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
interface UserProfileResponse {
id: string;
username: string;
displayName?: string;
avatar?: string;
bio?: string;
slug?: string;
primaryBadge?: PrimaryBadge;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: 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 +68,108 @@ 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,
primaryBadge: profile.primaryBadge,
website: profile.website,
discordServer: profile.discordServer,
bluesky: profile.bluesky,
github: profile.github,
linkedin: profile.linkedin,
twitch: profile.twitch,
youtube: profile.youtube,
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",
{
@@ -87,6 +234,64 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
return user;
}
);
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
"/:id/make-private",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id } = request.params;
const user = await userService.updateUserSettings(id, { profilePublic: false });
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.admin,
targetUserId: id,
details: `Admin made profile private for user: ${user.username}`,
});
return user;
}
);
app.put<{ Params: { id: string }; Body: UpdateUserSettingsBody; Reply: User | { error: string } }>(
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id } = request.params;
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 !== id) {
return reply.code(400).send({ error: "Slug already taken" });
}
}
const updatedUser = await userService.updateUserSettings(id, updates);
if (!updatedUser) {
return reply.code(404).send({ error: "User not found" });
}
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.admin,
targetUserId: id,
details: `Admin updated profile for user: ${updatedUser.username}`,
});
return updatedUser;
}
);
};
export default usersRoutes;