forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
259
apps/api/src/features/profile/routes.ts
Normal file
259
apps/api/src/features/profile/routes.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
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<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 : []);
|
||||
}
|
||||
|
||||
export async function profileMeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = 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 = tenantIdFrom(ctx);
|
||||
const userId = 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user