feat: use preset avatars and disable leaderboard by default

This commit is contained in:
Codex
2026-06-30 14:07:48 +08:00
parent 8e3252081f
commit 9299552451
18 changed files with 228 additions and 52 deletions

View File

@@ -76,6 +76,21 @@ function metricUnit(metric: LeaderboardMetric) {
}[metric];
}
async function assertLeaderboardEnabled(tenantId: string) {
const settings = await queryOne<{ enabled: boolean }>(
`
select coalesce(feature_flags ->> 'enableLeaderboard', 'false') = 'true' as enabled
from public.tenant_settings
where tenant_id = $1
limit 1
`,
[tenantId],
);
if (!settings?.enabled) {
throw new HttpError(403, 'Leaderboard is disabled for this tenant', 'LEADERBOARD_DISABLED');
}
}
function rowToItem(row: RankedRow, currentUserId: string) {
return {
rank: Number(row.rank),
@@ -270,6 +285,8 @@ function buildMetricCte(metric: LeaderboardMetric, period: LeaderboardPeriod, te
export async function learningLeaderboardRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
await assertLeaderboardEnabled(tenantId);
const metric = metricFrom(stringParam(ctx, 'metric'));
const period = periodFrom(stringParam(ctx, 'period'));
const limit = intParam(ctx, 'limit', 25, 100);

View File

@@ -4,6 +4,7 @@ import { query, queryOne, transaction } from '../../core/db.js';
import { autoGrantBadges } from './badges.js';
type JsonMap = Record<string, unknown>;
type AvatarPreset = 'male' | 'female';
interface ProfileRow {
id: string;
@@ -12,7 +13,7 @@ interface ProfileRow {
phone: string | null;
email: string | null;
name: string | null;
avatarUrl: string | null;
avatarPreset: AvatarPreset;
primaryRole: string;
score: number;
regionId: string | null;
@@ -74,6 +75,20 @@ function daysBetween(dateValue: unknown, today: Date) {
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'];
const AVATAR_PRESETS = ['male', 'female'];
function avatarPresetFrom(value: unknown): AvatarPreset | null {
if (value === undefined) return null;
const preset = nullableString(value);
if (!preset || !AVATAR_PRESETS.includes(preset)) {
throw new HttpError(400, 'avatarPreset must be male or female', 'INVALID_AVATAR_PRESET');
}
return preset as AvatarPreset;
}
function avatarDisplayUrl(preset: AvatarPreset) {
return `/assets/avatars/default-${preset}.svg`;
}
export async function profileMeRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
@@ -83,7 +98,8 @@ export async function profileMeRoute(ctx: RequestContext) {
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,
coalesce(sp.avatar_preset, 'male') as "avatarPreset",
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",
@@ -206,6 +222,10 @@ export async function profileMeRoute(ctx: RequestContext) {
isSvip: !!entitlement,
entitlement,
},
avatar: {
preset: profile.avatarPreset,
displayUrl: avatarDisplayUrl(profile.avatarPreset),
},
stats: {
...profile.stats,
answers: {
@@ -238,7 +258,14 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
const userId = await userIdFrom(ctx, body);
const name = optionalString(body, 'name') || null;
const avatarUrl = optionalString(body, 'avatarUrl') || null;
if (Object.hasOwn(body, 'avatarUrl')) {
throw new HttpError(
400,
'avatarUrl cannot be updated directly. Use avatarPreset.',
'PROFILE_AVATAR_URL_DIRECT_UPDATE_REJECTED',
);
}
const avatarPreset = avatarPresetFrom(body.avatarPreset);
const regionId = optionalString(body, 'regionId') || null;
const selectedSchoolId = optionalString(body, 'selectedSchoolId') || null;
const selectedMajorId = optionalString(body, 'selectedMajorId') || null;
@@ -248,26 +275,26 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
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,
tenant_id, user_id, avatar_preset, 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)
values ($1, $2, coalesce($4, 'male'), $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),
do update set avatar_preset = coalesce($15, public.student_profiles.avatar_preset),
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,
recent_activities = case when $16::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",
returning tenant_id as "tenantId", user_id as "userId", avatar_preset as "avatarPreset", 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"
@@ -276,7 +303,7 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
tenantId,
userId,
name,
avatarUrl,
avatarPreset,
regionId,
selectedSchoolId,
selectedMajorId,
@@ -287,6 +314,7 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
Object.hasOwn(body, 'stats'),
Object.hasOwn(body, 'progress'),
Object.hasOwn(body, 'moduleSelections'),
avatarPreset,
Object.hasOwn(body, 'recentActivities'),
],
);