/** * @copyright 2026 NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import { User, PrimaryBadge } from "@library/shared-types"; import { prisma } from "../lib/prisma"; import { SuggestionStatus } from "@prisma/client"; import { validateUrl, validateSlug, validateStringLength, MAX_LENGTHS, } from "../utils/validation"; export class UserService { private prisma = prisma; async getAllUsers(): Promise { 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, primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, github: user.github || undefined, linkedin: user.linkedin || undefined, twitch: user.twitch || undefined, youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, isVip: user.isVip, isMod: user.isMod, isStaff: user.isStaff, })); } async getUserById(id: string): Promise { 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, primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, github: user.github || undefined, linkedin: user.linkedin || undefined, twitch: user.twitch || undefined, youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, isVip: user.isVip, isMod: user.isMod, isStaff: user.isStaff, }; } async banUser(id: string): Promise { 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, primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, github: user.github || undefined, linkedin: user.linkedin || undefined, twitch: user.twitch || undefined, youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, isVip: user.isVip, isMod: user.isMod, isStaff: user.isStaff, }; } async unbanUser(id: string): Promise { 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, primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, github: user.github || undefined, linkedin: user.linkedin || undefined, twitch: user.twitch || undefined, youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, isVip: user.isVip, isMod: user.isMod, isStaff: user.isStaff, }; } async isUserBanned(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id }, select: { isBanned: true }, }); 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, primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, github: user.github || undefined, linkedin: user.linkedin || undefined, twitch: user.twitch || undefined, youtube: user.youtube || undefined, 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; primaryBadge?: PrimaryBadge; website?: string; discordServer?: string; bluesky?: string; github?: string; linkedin?: string; twitch?: string; youtube?: string; } ): Promise { // Validate slug format if (updates.slug && !validateSlug(updates.slug)) { throw new Error("Invalid slug format. Use only letters, numbers, hyphens, and underscores."); } // Validate string lengths if (!validateStringLength(updates.displayName, MAX_LENGTHS.DISPLAY_NAME)) { throw new Error(`Display name must be ${MAX_LENGTHS.DISPLAY_NAME} characters or less.`); } if (!validateStringLength(updates.bio, MAX_LENGTHS.BIO)) { throw new Error(`Bio must be ${MAX_LENGTHS.BIO} characters or less.`); } // Validate URLs const urlFields = [ { field: "website", value: updates.website }, { field: "discordServer", value: updates.discordServer }, { field: "bluesky", value: updates.bluesky }, { field: "github", value: updates.github }, { field: "linkedin", value: updates.linkedin }, { field: "twitch", value: updates.twitch }, { field: "youtube", value: updates.youtube }, ]; for (const { field, value } of urlFields) { if (value && !validateUrl(value)) { throw new Error(`Invalid URL format for ${field}. Only http and https URLs are allowed.`); } if (!validateStringLength(value, MAX_LENGTHS.URL)) { throw new Error(`${field} URL must be ${MAX_LENGTHS.URL} characters or less.`); } } 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, primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, github: user.github || undefined, linkedin: user.linkedin || undefined, twitch: user.twitch || undefined, youtube: user.youtube || undefined, 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; primaryBadge?: PrimaryBadge | null; website?: string | null; discordServer?: string | null; bluesky?: string | null; github?: string | null; linkedin?: string | null; twitch?: string | null; youtube?: string | null; isStaff: boolean; isMod: boolean; isVip: boolean; inDiscord: boolean; profilePublic: boolean; createdAt: Date; achievementPoints: number; 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, primaryBadge: user.primaryBadge as PrimaryBadge, website: user.website, discordServer: user.discordServer, bluesky: user.bluesky, github: user.github, linkedin: user.linkedin, twitch: user.twitch, youtube: user.youtube, isStaff: user.isStaff, isMod: user.isMod, isVip: user.isVip, inDiscord: user.inDiscord, profilePublic: user.profilePublic, createdAt: user.createdAt, achievementPoints: user.achievementPoints, stats: { suggestionsCount: user.suggestions.length, suggestionsAcceptedCount: user.suggestions.filter( (suggestion) => suggestion.status === SuggestionStatus.ACCEPTED ).length, likesCount: user.likes.length, commentsCount: user.comments.length, }, }; } }