From f7e865721a5856212174ddca6f5087366ff39243 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 14:16:59 +0300 Subject: [PATCH] feat: add stats to profile response Made-with: Cursor --- src/services/user/user.service.ts | 60 ++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/services/user/user.service.ts b/src/services/user/user.service.ts index fb6bf5c..3d50a3d 100644 --- a/src/services/user/user.service.ts +++ b/src/services/user/user.service.ts @@ -1,13 +1,30 @@ import { eq } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type * as schema from '../../db/schema/index.js'; -import { users } 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; @@ -25,6 +42,7 @@ export type PublicProfile = { city: string | null; selfLevel: string | null; isPublic: boolean; + stats: ProfileStats; }; export type PrivateProfile = PublicProfile & { @@ -34,7 +52,30 @@ export type PrivateProfile = PublicProfile & { updatedAt: string; }; -function toPublicProfile(user: User): PublicProfile { +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, @@ -43,12 +84,13 @@ function toPublicProfile(user: User): PublicProfile { city: user.city, selfLevel: user.selfLevel, isPublic: user.isPublic, + stats, }; } -function toPrivateProfile(user: User): PrivateProfile { +function toPrivateProfile(user: User, stats: ProfileStats): PrivateProfile { return { - ...toPublicProfile(user), + ...toPublicProfile(user, stats), email: user.email, emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null, createdAt: user.createdAt.toISOString(), @@ -74,11 +116,11 @@ export class UserService { } async getPrivateProfile(userId: string): Promise { - const user = await this.getById(userId); + const [user, stats] = await Promise.all([this.getById(userId), getStatsForUser(this.db, userId)]); if (!user) { throw notFound('User not found'); } - return toPrivateProfile(user); + return toPrivateProfile(user, stats); } async getPublicProfile(username: string): Promise { @@ -89,7 +131,8 @@ export class UserService { if (!user.isPublic) { throw notFound('User not found'); } - return toPublicProfile(user); + const stats = await getStatsForUser(this.db, user.id); + return toPublicProfile(user, stats); } async updateProfile(userId: string, input: ProfileUpdateInput): Promise { @@ -126,6 +169,7 @@ export class UserService { throw notFound('User not found'); } - return toPrivateProfile(updated); + const stats = await getStatsForUser(this.db, userId); + return toPrivateProfile(updated, stats); } }