import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; import { query, queryOne, transaction } 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 : []); } function nullableString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : null; } function objectValue(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function optionalChoice(value: unknown, allowed: string[], fallback: string) { const candidate = nullableString(value) || fallback; if (!allowed.includes(candidate)) { throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE'); } return candidate; } function toDateOnly(value: unknown) { if (!value) return null; if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10); if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10); return null; } function daysBetween(dateValue: unknown, today: Date) { const dateText = toDateOnly(dateValue); if (!dateText) return null; const target = new Date(`${dateText}T00:00:00.000Z`); if (Number.isNaN(target.getTime())) return null; const current = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); return Math.ceil((target.getTime() - current.getTime()) / 86_400_000); } const FEEDBACK_TYPES = ['question_error', 'content_error', 'video_error', 'asset_error', 'system_bug', 'suggestion', 'other']; const BADGE_CATEGORIES = ['learning', 'practice', 'vocabulary', 'mock_exam', 'activity', 'feedback', 'sales', 'system', 'custom']; 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 }; } export async function checkInRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); const item = await transaction(async client => { const today = new Date().toISOString().slice(0, 10); const existing = await client.query<{ lastCheckInDate: string | null; score: number; stats: Record; }>( ` select sp.last_check_in_date as "lastCheckInDate", u.score, sp.stats from public.student_profiles sp join public.platform_users u on u.id = sp.user_id where sp.tenant_id = $1 and sp.user_id = $2 limit 1 for update of sp, u `, [tenantId, userId], ); const profile = existing.rows[0]; if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND'); if (profile.lastCheckInDate === today) { return { checkedIn: false, alreadyCheckedIn: true, pointsAdded: 0, score: Number(profile.score || 0), lastCheckInDate: today, }; } const yesterday = new Date(); yesterday.setUTCDate(yesterday.getUTCDate() - 1); const yesterdayText = yesterday.toISOString().slice(0, 10); const stats = objectValue(profile.stats); const previousStreak = Number(stats.checkInStreak || 0); const streak = profile.lastCheckInDate === yesterdayText ? previousStreak + 1 : 1; const pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6); const balanceAfter = Number(profile.score || 0) + pointsAdded; const nextStats = { ...stats, checkInStreak: streak, lastCheckInPoints: pointsAdded, }; const ledger = await client.query( ` insert into public.user_score_events ( tenant_id, user_id, event_type, points, balance_after, source_type, idempotency_key, metadata ) values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb) on conflict (tenant_id, idempotency_key) do nothing returning id, event_type as "eventType", points, balance_after as "balanceAfter", source_type as "sourceType", created_at as "createdAt" `, [ tenantId, userId, pointsAdded, balanceAfter, `check_in:${userId}:${today}`, JSON.stringify({ checkInDate: today, streak }), ], ); if (!ledger.rows[0]) { return { checkedIn: false, alreadyCheckedIn: true, pointsAdded: 0, score: Number(profile.score || 0), lastCheckInDate: today, }; } const updatedUser = await client.query<{ score: number }>( ` update public.platform_users set score = score + $2, updated_at = now() where id = $1 returning score `, [userId, pointsAdded], ); await client.query( ` update public.student_profiles set last_check_in_date = $3::date, stats = $4::jsonb, updated_at = now() where tenant_id = $1 and user_id = $2 `, [tenantId, userId, today, JSON.stringify(nextStats)], ); return { checkedIn: true, alreadyCheckedIn: false, pointsAdded, streak, score: updatedUser.rows[0]?.score || balanceAfter, lastCheckInDate: today, ledger: { ...ledger.rows[0], balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter, }, }; }); return { item }; } export async function scoreEventsRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); const limit = intParam(ctx, 'limit', 50, 200); const items = await query( ` select id, event_type as "eventType", points, balance_after as "balanceAfter", source_type as "sourceType", source_id as "sourceId", metadata, created_at as "createdAt" from public.user_score_events where tenant_id = $1 and user_id = $2 order by created_at desc limit $3 `, [tenantId, userId, limit], ); return { items }; } export async function profileBadgesRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); const limit = intParam(ctx, 'limit', 100, 300); const includeLocked = ctx.url.searchParams.get('includeLocked') === 'true'; const category = stringParam(ctx, 'category'); const params: unknown[] = [tenantId, userId]; const filters = ['b.tenant_id = $1', 'b.is_active = true']; if (category) { if (!BADGE_CATEGORIES.includes(category)) { throw new HttpError(400, `Invalid badge category: ${category}`, 'INVALID_BADGE_CATEGORY'); } params.push(category); filters.push(`b.category = $${params.length}`); } if (!includeLocked) { filters.push('ub.id is not null'); } params.push(limit); const items = await query<{ unlocked: boolean } & Record>( ` select b.id as "badgeId", b.legacy_id as "legacyId", b.name, b.description, b.category, b.icon_url as "iconUrl", b.level, b.unlock_type as "unlockType", b.condition_field as "conditionField", b.condition_operator as "conditionOperator", b.condition_value as "conditionValue", b.condition_extra as "conditionExtra", b.metadata, b.sort_order as "order", ub.id as "grantId", ub.note, ub.metadata as "grantMetadata", ub.granted_at as "grantedAt", (ub.id is not null) as unlocked from public.badges b left join public.user_badges ub on ub.tenant_id = b.tenant_id and ub.badge_id = b.id and ub.user_id = $2 where ${filters.join(' and ')} order by (ub.id is not null) desc, coalesce(ub.granted_at, ub.created_at) desc nulls last, b.sort_order asc, b.level asc nulls last, b.created_at desc limit $${params.length} `, params, ); return { items, summary: { total: items.length, unlocked: items.filter(item => item.unlocked).length, includeLocked, }, }; } export async function feedbacksRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); const limit = intParam(ctx, 'limit', 50, 200); const status = stringParam(ctx, 'status'); const params: unknown[] = [tenantId, userId]; const filters = ['r.tenant_id = $1', 'r.user_id = $2']; if (status) { params.push(status); filters.push(`r.status = $${params.length}`); } params.push(limit); const items = await query( ` select r.id, r.question_id as "questionId", q.type as "questionType", r.type, r.category, r.title, r.description, r.status, r.priority, r.resolution, r.handled_by as "handledBy", r.handled_at as "handledAt", r.attachments, r.metadata, r.created_at as "createdAt", r.updated_at as "updatedAt" from public.reports r left join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id where ${filters.join(' and ')} order by r.created_at desc limit $${params.length} `, params, ); return { items }; } export async function submitFeedbackRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const body = await readJsonBody(ctx); const userId = await userIdFrom(ctx, body); const questionId = nullableString(body.questionId); const type = optionalChoice(body.type, FEEDBACK_TYPES, 'question_error'); const description = requiredString(body, 'description'); const attachments = Array.isArray(body.attachments) ? body.attachments : []; const item = await transaction(async client => { if (questionId) { const question = await client.query<{ id: string }>( 'select id from public.questions where tenant_id = $1 and id = $2 and status <> \'archived\' limit 1', [tenantId, questionId], ); if (!question.rows[0]) throw new HttpError(404, 'Question not found for this tenant', 'QUESTION_NOT_FOUND'); } const result = await client.query( ` insert into public.reports ( tenant_id, question_id, user_id, type, category, title, description, status, priority, contact, attachments, metadata ) values ($1, $2::uuid, $3, $4, $5, $6, $7, 'pending', $8, $9, $10::jsonb, $11::jsonb) returning id, question_id as "questionId", user_id as "userId", type, category, title, description, status, priority, contact, attachments, metadata, created_at as "createdAt", updated_at as "updatedAt" `, [ tenantId, questionId, userId, type, nullableString(body.category), nullableString(body.title), description, optionalChoice(body.priority, ['low', 'normal', 'high', 'urgent'], 'normal'), nullableString(body.contact), JSON.stringify(attachments), JSON.stringify(objectValue(body.metadata)), ], ); await client.query( ` insert into public.report_status_events (tenant_id, report_id, from_status, to_status, note, actor_user_id) values ($1, $2, null, 'pending', 'student submitted feedback', $3) `, [tenantId, result.rows[0].id, userId], ); return result.rows[0]; }); return { item }; } export async function examCountdownRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); const limit = intParam(ctx, 'limit', 5, 20); const profile = await queryOne<{ regionId: string | null; selectedSchoolId: string | null; }>( ` select region_id as "regionId", selected_school_id as "selectedSchoolId" from public.student_profiles where tenant_id = $1 and user_id = $2 limit 1 `, [tenantId, userId], ); if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND'); const items = await query<{ id: string; examName: string; examDate: string | null; } & Record>( ` select id, region_id as "regionId", school_id as "schoolId", exam_name as "examName", exam_date::text as "examDate", exam_type as "examType", description, metadata, sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt" from public.exam_dates where tenant_id = $1 and is_active = true and ($2::uuid is null or region_id is null or region_id = $2::uuid) and ($3::uuid is null or school_id is null or school_id = $3::uuid) order by exam_date asc nulls last, sort_order asc limit $4 `, [tenantId, profile.regionId, profile.selectedSchoolId, limit], ); const today = new Date(); return { items: items.map(item => ({ ...item, daysLeft: daysBetween(item.examDate, today), })), target: { regionId: profile.regionId, schoolId: profile.selectedSchoolId, }, }; }