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, subscriptions } 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; role: string; plan: 'free' | 'pro'; }; 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, plan: 'free' | 'pro' = 'free' ): PrivateProfile { return { ...toPublicProfile(user, stats), email: user.email, emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt.toISOString(), role: user.role, plan, }; } 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, plan: 'free' | 'pro' = 'free' ): 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, plan); } 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); const plan = await this.getPlanForUser(userId); return toPrivateProfile(updated, stats, plan); } async getPlanForUser(userId: string): Promise<'free' | 'pro'> { const [sub] = await this.db .select() .from(subscriptions) .where(eq(subscriptions.userId, userId)) .limit(1); if (!sub || (sub.expiresAt && sub.expiresAt < new Date())) return 'free'; if (sub.plan === 'pro' && (sub.status === 'active' || sub.status === 'trialing')) return 'pro'; return 'free'; } }