Files
library/api/src/app/services/user.service.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

258 lines
6.4 KiB
TypeScript

/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { User } from "@library/shared-types";
import { prisma } from "../lib/prisma";
import { SuggestionStatus } from "@prisma/client";
export class UserService {
private prisma = prisma;
async getAllUsers(): Promise<User[]> {
const users = await this.prisma.user.findMany({
orderBy: { username: "asc" },
});
return users.map((user) => ({
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 getUserById(id: string): Promise<User | null> {
const user = await this.prisma.user.findUnique({
where: { id },
});
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 banUser(id: string): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: { isBanned: true },
});
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 unbanUser(id: string): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: { isBanned: false },
});
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 isUserBanned(id: string): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id },
select: { isBanned: true },
});
return user?.isBanned ?? false;
}
async getUserBySlug(slug: string): Promise<User | null> {
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<User | null> {
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,
},
};
}
}