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

View File

@@ -21,6 +21,9 @@ const DISCOUNT_TYPES = ['percent', 'fixed'];
const TENANT_MEMBER_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student'];
const TENANT_MEMBER_STATUSES = ['active', 'invited', 'disabled'];
const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived'];
const BADGE_CATEGORIES = ['learning', 'practice', 'vocabulary', 'mock_exam', 'activity', 'feedback', 'sales', 'system', 'custom'];
const BADGE_UNLOCK_TYPES = ['manual', 'auto', 'score', 'check_in', 'practice_count', 'vocabulary_mastered', 'mock_exam_score', 'feedback_resolved', 'custom'];
const BADGE_OPERATORS = ['gte', 'lte', 'eq', 'gt', 'lt'];
function jsonBodyValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
@@ -34,6 +37,25 @@ function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function optionalUuidString(value: unknown, key: string) {
const candidate = nullableString(value);
if (!candidate) return null;
if (!UUID_PATTERN.test(candidate)) {
throw new HttpError(400, `${key} must be a UUID`, 'INVALID_UUID');
}
return candidate;
}
function requiredUuidString(body: JsonBody, key: string) {
const candidate = optionalUuidString(body[key], key);
if (!candidate) {
throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
}
return candidate;
}
function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
@@ -68,6 +90,15 @@ function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function optionalNumberValue(value: unknown) {
if (value === undefined || value === null || value === '') return null;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new HttpError(400, 'Numeric field is invalid', 'INVALID_NUMBER');
}
return parsed;
}
function assertPublicConfigHasNoSecrets(value: unknown, path = 'configPublic') {
if (!value || typeof value !== 'object') return;
@@ -1148,6 +1179,256 @@ export async function upsertAnnouncementRoute(ctx: RequestContext) {
return { item };
}
export async function badgesRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'badges:read');
const limit = intParam(ctx, 'limit', 100, 500);
const category = stringParam(ctx, 'category');
const includeInactive = ctx.url.searchParams.get('includeInactive') === 'true';
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (category) {
if (!BADGE_CATEGORIES.includes(category)) {
throw new HttpError(400, `Invalid badge category: ${category}`, 'INVALID_BADGE_CATEGORY');
}
params.push(category);
filters.push(`category = $${params.length}`);
}
if (!includeInactive) {
filters.push('is_active = true');
}
params.push(limit);
const items = await query(
`
select id, legacy_id as "legacyId", name, description, category,
icon_url as "iconUrl", level, unlock_type as "unlockType",
condition_field as "conditionField", condition_operator as "conditionOperator",
condition_value as "conditionValue", condition_extra as "conditionExtra",
metadata, sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.badges
where ${filters.join(' and ')}
order by sort_order asc, level asc nulls last, created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertBadgeRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'badges:write');
const body = await readJsonBody(ctx);
const category = optionalChoice(body.category, BADGE_CATEGORIES, 'custom');
const unlockType = optionalChoice(body.unlockType, BADGE_UNLOCK_TYPES, 'manual');
const conditionOperator = body.conditionOperator === undefined || body.conditionOperator === null || body.conditionOperator === ''
? null
: optionalChoice(body.conditionOperator, BADGE_OPERATORS, 'gte');
const item = await transaction(async client => {
const requestedId = optionalUuidString(body.id, 'id');
const legacyId = nullableString(body.legacyId);
const existing = await client.query<{ id: string }>(
`
select id
from public.badges
where tenant_id = $1
and (
($2::uuid is not null and id = $2::uuid)
or ($3::text is not null and legacy_id = $3)
)
order by case when $2::uuid is not null and id = $2::uuid then 0 else 1 end
limit 2
`,
[auth.tenantId, requestedId, legacyId],
);
if ((existing.rowCount || 0) > 1) {
throw new HttpError(409, 'Badge id and legacyId point to different records', 'BADGE_ID_CONFLICT');
}
if (requestedId && existing.rows[0]?.id && existing.rows[0].id !== requestedId) {
throw new HttpError(409, 'Badge legacyId already belongs to another record', 'BADGE_ID_CONFLICT');
}
const result = await client.query(
`
insert into public.badges (
id, tenant_id, legacy_id, name, description, category, icon_url,
level, unlock_type, condition_field, condition_operator,
condition_value, condition_extra, metadata, sort_order, is_active
)
values (
coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13::jsonb, $14::jsonb, $15, $16
)
on conflict (id)
do update set legacy_id = coalesce(excluded.legacy_id, public.badges.legacy_id),
name = excluded.name,
description = excluded.description,
category = excluded.category,
icon_url = excluded.icon_url,
level = excluded.level,
unlock_type = excluded.unlock_type,
condition_field = excluded.condition_field,
condition_operator = excluded.condition_operator,
condition_value = excluded.condition_value,
condition_extra = excluded.condition_extra,
metadata = excluded.metadata,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
where public.badges.tenant_id = excluded.tenant_id
returning id, legacy_id as "legacyId", name, description, category,
icon_url as "iconUrl", level, unlock_type as "unlockType",
condition_field as "conditionField", condition_operator as "conditionOperator",
condition_value as "conditionValue", condition_extra as "conditionExtra",
metadata, sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
existing.rows[0]?.id || requestedId,
legacyId,
requiredString(body, 'name'),
nullableString(body.description),
category,
nullableString(body.iconUrl),
body.level === undefined ? null : intValue(body.level, 0),
unlockType,
nullableString(body.conditionField),
conditionOperator,
optionalNumberValue(body.conditionValue),
jsonBodyValue(body.conditionExtra),
jsonBodyValue(body.metadata),
intValue(body.order, 0),
boolValue(body.isActive, true),
],
);
if (!result.rows[0]) throw new HttpError(404, 'Badge not found for this tenant', 'BADGE_NOT_FOUND');
await recordAudit(client, auth, 'tenant.badge.upserted', 'badges', result.rows[0].id, {
name: result.rows[0].name,
category,
unlockType,
});
return result.rows[0];
});
return { item };
}
export async function badgeGrantsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'badges:read');
const limit = intParam(ctx, 'limit', 100, 500);
const userId = optionalUuidString(stringParam(ctx, 'userId'), 'userId');
const badgeId = optionalUuidString(stringParam(ctx, 'badgeId'), 'badgeId');
const params: unknown[] = [auth.tenantId];
const filters = ['ub.tenant_id = $1'];
if (userId) {
params.push(userId);
filters.push(`ub.user_id = $${params.length}::uuid`);
}
if (badgeId) {
params.push(badgeId);
filters.push(`ub.badge_id = $${params.length}::uuid`);
}
params.push(limit);
const items = await query(
`
select ub.id, ub.legacy_id as "legacyId", ub.user_id as "userId",
u.name as "userName", u.phone as "userPhone", u.avatar_url as "userAvatarUrl",
ub.badge_id as "badgeId", b.name as "badgeName", b.category,
b.icon_url as "iconUrl", b.level, ub.granted_by as "grantedBy",
gu.name as "grantedByName", ub.note, ub.metadata,
ub.granted_at as "grantedAt", ub.created_at as "createdAt",
ub.updated_at as "updatedAt"
from public.user_badges ub
join public.badges b on b.tenant_id = ub.tenant_id and b.id = ub.badge_id
join public.platform_users u on u.id = ub.user_id
left join public.platform_users gu on gu.id = ub.granted_by
where ${filters.join(' and ')}
order by ub.granted_at desc nulls last, ub.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function grantBadgeRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'badges:grant');
const body = await readJsonBody(ctx);
const badgeId = requiredUuidString(body, 'badgeId');
const userId = requiredUuidString(body, 'userId');
const item = await transaction(async client => {
const badge = await client.query<{ id: string; is_active: boolean; name: string }>(
'select id, is_active, name from public.badges where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, badgeId],
);
if (!badge.rows[0]) throw new HttpError(404, 'Badge not found for this tenant', 'BADGE_NOT_FOUND');
if (!badge.rows[0].is_active) throw new HttpError(409, 'Cannot grant inactive badge', 'BADGE_INACTIVE');
const member = await client.query<{ id: string }>(
`
select tm.id
from public.tenant_memberships tm
where tm.tenant_id = $1
and tm.user_id = $2
and tm.status = 'active'
limit 1
`,
[auth.tenantId, userId],
);
if (!member.rows[0]) throw new HttpError(400, 'Badge target user is not an active member of this tenant', 'BADGE_TARGET_NOT_IN_TENANT');
const legacyId = nullableString(body.legacyId) || `badge:${badgeId}:user:${userId}`;
const result = await client.query(
`
insert into public.user_badges (
tenant_id, user_id, badge_id, granted_by, legacy_id,
note, metadata, granted_at
)
values ($1, $2, $3, $4, $5, $6, $7::jsonb, coalesce($8::timestamptz, now()))
on conflict (tenant_id, user_id, badge_id)
do update set note = coalesce(excluded.note, public.user_badges.note),
metadata = public.user_badges.metadata || excluded.metadata,
granted_by = coalesce(public.user_badges.granted_by, excluded.granted_by),
granted_at = coalesce(public.user_badges.granted_at, excluded.granted_at),
updated_at = now()
returning id, legacy_id as "legacyId", user_id as "userId",
badge_id as "badgeId", granted_by as "grantedBy",
note, metadata, granted_at as "grantedAt",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
userId,
badgeId,
auth.userId,
legacyId,
nullableString(body.note),
JSON.stringify({ source: 'tenant_admin', ...objectValue(body.metadata) }),
nullableString(body.grantedAt),
],
);
await recordAudit(client, auth, 'tenant.badge.granted', 'user_badges', result.rows[0].id, {
userId,
badgeId,
badgeName: badge.rows[0].name,
});
return result.rows[0];
});
return { item };
}
export async function codeBatchesRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'codes:read');