forked from wangziqi/gongxue-base
feat: use preset avatars and disable leaderboard by default
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user