import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, optionalString, readJsonBody, tenantIdFrom, userIdFrom } from '../../core/request.js'; import { query, queryOne } from '../../core/db.js'; type JsonMap = Record; interface ProfileRow { id: string; userId: string; username: string | null; phone: string | null; email: string | null; name: string | null; avatarUrl: string | null; primaryRole: string; score: number; regionId: string | null; regionName: string | null; selectedSchoolId: string | null; selectedSchoolName: string | null; selectedMajorId: string | null; selectedMajorName: string | null; questionsAnsweredToday: number; masteredWordsCount: number; lastCheckInDate: string | null; stats: JsonMap; progress: JsonMap; moduleSelections: JsonMap; recentActivities: unknown[]; createdAt: string; updatedAt: string; } function jsonBodyValue(value: unknown) { return JSON.stringify(value && typeof value === 'object' ? value : {}); } function jsonArrayBodyValue(value: unknown) { return JSON.stringify(Array.isArray(value) ? value : []); } export async function profileMeRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); const limit = intParam(ctx, 'recentLimit', 8, 50); const profile = await queryOne( ` select sp.id, u.id as "userId", u.username, u.phone, u.email::text, u.name, u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.score, sp.region_id as "regionId", r.name as "regionName", sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName", sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName", sp.questions_answered_today as "questionsAnsweredToday", sp.mastered_words_count as "masteredWordsCount", sp.last_check_in_date as "lastCheckInDate", sp.stats, sp.progress, sp.module_selections as "moduleSelections", sp.recent_activities as "recentActivities", sp.created_at as "createdAt", sp.updated_at as "updatedAt" from public.student_profiles sp join public.platform_users u on u.id = sp.user_id left join public.regions r on r.id = sp.region_id and r.tenant_id = sp.tenant_id left join public.schools s on s.id = sp.selected_school_id and s.tenant_id = sp.tenant_id left join public.majors m on m.id = sp.selected_major_id and m.tenant_id = sp.tenant_id where sp.tenant_id = $1 and sp.user_id = $2 limit 1 `, [tenantId, userId], ); if (!profile) { throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND'); } const recentPractices = await query( ` select id, practice_type as "practiceType", target_legacy_id as "targetLegacyId", target_name as "targetName", progress, color, last_access_at as "lastAccessAt", last_practice_at as "lastPracticeAt", metadata, created_at as "createdAt", updated_at as "updatedAt" from public.recent_practices where tenant_id = $1 and user_id = $2 order by last_practice_at desc nulls last, last_access_at desc nulls last, updated_at desc limit $3 `, [tenantId, userId, limit], ); const answerStats = await queryOne<{ totalAnswered: string; correctCount: string; wrongCount: string; latestAnsweredAt: string | null; }>( ` select count(*)::text as "totalAnswered", count(*) filter (where is_correct is true)::text as "correctCount", count(*) filter (where is_correct is false)::text as "wrongCount", max(answered_at) as "latestAnsweredAt" from public.answer_records where tenant_id = $1 and user_id = $2 `, [tenantId, userId], ); const wordStats = await queryOne<{ totalWords: string; progressedWords: string; masteredWords: string; learningWords: string; favoriteWords: string; }>( ` select (select count(*) from public.vocabulary_words where tenant_id = $1 and is_active = true)::text as "totalWords", count(*)::text as "progressedWords", count(*) filter (where status = 'mastered')::text as "masteredWords", count(*) filter (where status in ('learning', 'reviewing'))::text as "learningWords", (select count(*) from public.user_word_favorites where tenant_id = $1 and user_id = $2)::text as "favoriteWords" from public.user_word_progress where tenant_id = $1 and user_id = $2 `, [tenantId, userId], ); const entitlement = await queryOne( ` select id, entitlement_type as "entitlementType", scope_type as "scopeType", scope_id as "scopeId", starts_at as "startsAt", expires_at as "expiresAt", status, metadata from public.entitlements where tenant_id = $1 and user_id = $2 and entitlement_type = 'svip' and status = 'active' and starts_at <= now() and (expires_at is null or expires_at > now()) order by expires_at desc nulls first, created_at desc limit 1 `, [tenantId, userId], ); const orderSummary = await queryOne<{ totalOrders: string; paidOrders: string; paidAmountCents: string; }>( ` select count(*)::text as "totalOrders", count(*) filter (where status = 'paid')::text as "paidOrders", coalesce(sum(amount_cents) filter (where status = 'paid'), 0)::text as "paidAmountCents" from public.orders where tenant_id = $1 and user_id = $2 `, [tenantId, userId], ); return { item: { ...profile, target: { regionId: profile.regionId, regionName: profile.regionName, schoolId: profile.selectedSchoolId, schoolName: profile.selectedSchoolName, majorId: profile.selectedMajorId, majorName: profile.selectedMajorName, }, membership: { isSvip: !!entitlement, entitlement, }, stats: { ...profile.stats, answers: { totalAnswered: Number(answerStats?.totalAnswered || 0), correctCount: Number(answerStats?.correctCount || 0), wrongCount: Number(answerStats?.wrongCount || 0), latestAnsweredAt: answerStats?.latestAnsweredAt || null, }, vocabulary: { totalWords: Number(wordStats?.totalWords || 0), progressedWords: Number(wordStats?.progressedWords || 0), masteredWords: Number(wordStats?.masteredWords || 0), learningWords: Number(wordStats?.learningWords || 0), favoriteWords: Number(wordStats?.favoriteWords || 0), }, orders: { totalOrders: Number(orderSummary?.totalOrders || 0), paidOrders: Number(orderSummary?.paidOrders || 0), paidAmountCents: Number(orderSummary?.paidAmountCents || 0), }, }, recentPractices, }, }; } export async function updateProfileMeRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx, body); const name = optionalString(body, 'name') || null; const avatarUrl = optionalString(body, 'avatarUrl') || null; const regionId = optionalString(body, 'regionId') || null; const selectedSchoolId = optionalString(body, 'selectedSchoolId') || null; const selectedMajorId = optionalString(body, 'selectedMajorId') || null; const item = await queryOne( ` with updated_user as ( update public.platform_users set name = coalesce($3, name), avatar_url = coalesce($4, avatar_url), updated_at = now() where id = $2 returning id ) insert into public.student_profiles ( tenant_id, user_id, region_id, selected_school_id, selected_major_id, stats, progress, module_selections, recent_activities ) values ($1, $2, $5::uuid, $6::uuid, $7::uuid, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb) on conflict (tenant_id, user_id) do update set region_id = coalesce(excluded.region_id, public.student_profiles.region_id), selected_school_id = coalesce(excluded.selected_school_id, public.student_profiles.selected_school_id), selected_major_id = coalesce(excluded.selected_major_id, public.student_profiles.selected_major_id), stats = case when $12::boolean then excluded.stats else public.student_profiles.stats end, progress = case when $13::boolean then excluded.progress else public.student_profiles.progress end, module_selections = case when $14::boolean then excluded.module_selections else public.student_profiles.module_selections end, recent_activities = case when $15::boolean then excluded.recent_activities else public.student_profiles.recent_activities end, updated_at = now() returning tenant_id as "tenantId", user_id as "userId", region_id as "regionId", selected_school_id as "selectedSchoolId", selected_major_id as "selectedMajorId", stats, progress, module_selections as "moduleSelections", recent_activities as "recentActivities", updated_at as "updatedAt" `, [ tenantId, userId, name, avatarUrl, regionId, selectedSchoolId, selectedMajorId, jsonBodyValue(body.stats), jsonBodyValue(body.progress), jsonBodyValue(body.moduleSelections), jsonArrayBodyValue(body.recentActivities), Object.hasOwn(body, 'stats'), Object.hasOwn(body, 'progress'), Object.hasOwn(body, 'moduleSelections'), Object.hasOwn(body, 'recentActivities'), ], ); return { item }; }