import { eq } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type * as schema from '../../db/schema/index.js'; import { users, userStats } from '../../db/schema/index.js'; import { notFound, conflict, ERROR_CODES } from '../../utils/errors.js'; import type { User } from '../../db/schema/users.js'; import type { SelfLevel } from '../../db/schema/index.js'; type Db = NodePgDatabase; export type UserStatItem = { stack: string; level: string; totalQuestions: number; correctAnswers: number; testsTaken: number; lastTestAt: string | null; }; export type ProfileStats = { byStack: UserStatItem[]; totalTestsTaken: number; totalQuestions: number; correctAnswers: number; accuracy: number | null; }; export type ProfileUpdateInput = { nickname?: string; avatarUrl?: string | null; country?: string | null; city?: string | null; selfLevel?: SelfLevel | null; isPublic?: boolean; }; export type PublicProfile = { id: string; nickname: string; avatarUrl: string | null; country: string | null; city: string | null; selfLevel: string | null; isPublic: boolean; stats: ProfileStats; }; export type PrivateProfile = PublicProfile & { email: string; emailVerifiedAt: string | null; createdAt: string; updatedAt: string; }; async function getStatsForUser(db: Db, userId: string): Promise { const rows = await db .select() .from(userStats) .where(eq(userStats.userId, userId)); const byStack: UserStatItem[] = rows.map((r) => ({ stack: r.stack, level: r.level, totalQuestions: r.totalQuestions, correctAnswers: r.correctAnswers, testsTaken: r.testsTaken, lastTestAt: r.lastTestAt?.toISOString() ?? null, })); const totalTestsTaken = rows.reduce((sum, r) => sum + r.testsTaken, 0); const totalQuestions = rows.reduce((sum, r) => sum + r.totalQuestions, 0); const correctAnswers = rows.reduce((sum, r) => sum + r.correctAnswers, 0); const accuracy = totalQuestions > 0 ? correctAnswers / totalQuestions : null; return { byStack, totalTestsTaken, totalQuestions, correctAnswers, accuracy }; } function toPublicProfile(user: User, stats: ProfileStats): PublicProfile { return { id: user.id, nickname: user.nickname, avatarUrl: user.avatarUrl, country: user.country, city: user.city, selfLevel: user.selfLevel, isPublic: user.isPublic, stats, }; } function toPrivateProfile(user: User, stats: ProfileStats): PrivateProfile { return { ...toPublicProfile(user, stats), email: user.email, emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt.toISOString(), }; } export class UserService { constructor(private readonly db: Db) {} async getById(userId: string): Promise { const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1); return user ?? null; } async getByNickname(nickname: string): Promise { const [user] = await this.db .select() .from(users) .where(eq(users.nickname, nickname.trim())) .limit(1); return user ?? null; } async getPrivateProfile(userId: string): Promise { const [user, stats] = await Promise.all([this.getById(userId), getStatsForUser(this.db, userId)]); if (!user) { throw notFound('User not found'); } return toPrivateProfile(user, stats); } async getPublicProfile(username: string): Promise { const user = await this.getByNickname(username); if (!user) { throw notFound('User not found'); } if (!user.isPublic) { throw notFound('User not found'); } const stats = await getStatsForUser(this.db, user.id); return toPublicProfile(user, stats); } async updateProfile(userId: string, input: ProfileUpdateInput): Promise { const updateData: Partial = { updatedAt: new Date(), }; if (input.nickname !== undefined) { const trimmed = input.nickname.trim(); const [existing] = await this.db .select({ id: users.id }) .from(users) .where(eq(users.nickname, trimmed)) .limit(1); if (existing && existing.id !== userId) { throw conflict(ERROR_CODES.NICKNAME_TAKEN, 'Nickname already taken'); } updateData.nickname = trimmed; } if (input.avatarUrl !== undefined) updateData.avatarUrl = input.avatarUrl; if (input.country !== undefined) updateData.country = input.country; if (input.city !== undefined) updateData.city = input.city; if (input.selfLevel !== undefined) updateData.selfLevel = input.selfLevel; if (input.isPublic !== undefined) updateData.isPublic = input.isPublic; const [updated] = await this.db .update(users) .set(updateData) .where(eq(users.id, userId)) .returning(); if (!updated) { throw notFound('User not found'); } const stats = await getStatsForUser(this.db, userId); return toPrivateProfile(updated, stats); } }