feat: manage tenant badges

This commit is contained in:
Codex
2026-06-29 07:04:53 +08:00
parent 4954ad3ca9
commit 78a26d1df2
19 changed files with 650 additions and 38 deletions
+58
View File
@@ -72,6 +72,7 @@ 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'];
export async function profileMeRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
@@ -430,6 +431,63 @@ export async function scoreEventsRoute(ctx: RequestContext) {
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);