forked from wangziqi/gongxue-base
feat: manage tenant badges
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user