forked from wangziqi/gongxue-base
632 lines
22 KiB
TypeScript
632 lines
22 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
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<ProfileRow>(
|
|
`
|
|
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<string, unknown>;
|
|
}>(
|
|
`
|
|
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<string, unknown>>(
|
|
`
|
|
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<string, unknown>>(
|
|
`
|
|
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,
|
|
},
|
|
};
|
|
}
|