forked from wangziqi/gongxue-base
feat: add feedback checkins exam dates
This commit is contained in:
@@ -102,6 +102,8 @@ export function tenantPermissionCatalog() {
|
||||
{ key: 'referral:write', label: '客资归属管理' },
|
||||
{ key: 'crm:read', label: 'CRM 队列查看' },
|
||||
{ key: 'crm:write', label: 'CRM 入队和重试' },
|
||||
{ key: 'feedback:read', label: '反馈查看' },
|
||||
{ key: 'feedback:write', label: '反馈处理' },
|
||||
{ key: 'classes:read', label: '班级查看' },
|
||||
{ key: 'classes:write', label: '班级管理' },
|
||||
{ key: 'students:read', label: '学生查看' },
|
||||
|
||||
@@ -17,6 +17,13 @@ import {
|
||||
upsertTenantClassRoute,
|
||||
upsertTenantStudentRoute,
|
||||
} from './classes.js';
|
||||
import {
|
||||
tenantExamDatesRoute,
|
||||
tenantFeedbackEventsRoute,
|
||||
tenantFeedbacksRoute,
|
||||
updateTenantFeedbackStatusRoute,
|
||||
upsertTenantExamDateRoute,
|
||||
} from './operations.js';
|
||||
import {
|
||||
activationCodesRoute,
|
||||
announcementsAdminRoute,
|
||||
@@ -90,6 +97,11 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['PUT', '/api/tenant-admin/faqs', upsertFaqRoute],
|
||||
['GET', '/api/tenant-admin/announcements', announcementsAdminRoute],
|
||||
['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute],
|
||||
['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute],
|
||||
['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute],
|
||||
['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute],
|
||||
['POST', '/api/tenant-admin/feedbacks/status', updateTenantFeedbackStatusRoute],
|
||||
['GET', '/api/tenant-admin/feedbacks/events', tenantFeedbackEventsRoute],
|
||||
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],
|
||||
['PUT', '/api/tenant-admin/code-batches', upsertCodeBatchRoute],
|
||||
['GET', '/api/tenant-admin/activation-codes', activationCodesRoute],
|
||||
|
||||
369
apps/api/src/features/tenant-admin/operations.ts
Normal file
369
apps/api/src/features/tenant-admin/operations.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, transaction } from '../../core/db.js';
|
||||
import {
|
||||
requireTenantAdmin,
|
||||
requireTenantPermission,
|
||||
type TenantAdminAuth,
|
||||
} from './auth.js';
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
|
||||
const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed'];
|
||||
const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function intValue(value: unknown, fallback: number) {
|
||||
const numberValue = Number(value ?? fallback);
|
||||
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
|
||||
}
|
||||
|
||||
function boolValue(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function recordAudit(
|
||||
client: pg.PoolClient,
|
||||
auth: TenantAdminAuth,
|
||||
action: string,
|
||||
targetType: string,
|
||||
targetId: string | null,
|
||||
details: Record<string, unknown> = {},
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
`,
|
||||
[auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)],
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureTenantReference(
|
||||
client: pg.PoolClient,
|
||||
tableName: 'regions' | 'schools',
|
||||
tenantId: string,
|
||||
id: string | null,
|
||||
errorCode: string,
|
||||
) {
|
||||
if (!id) return;
|
||||
const result = await client.query<{ id: string }>(
|
||||
`select id from public.${tableName} where tenant_id = $1 and id = $2 limit 1`,
|
||||
[tenantId, id],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(400, `${tableName} id is not in this tenant`, errorCode);
|
||||
}
|
||||
|
||||
export async function tenantExamDatesRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'marketing:read');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const isActive = ctx.url.searchParams.get('isActive');
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`school_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (isActive === 'true' || isActive === 'false') {
|
||||
params.push(isActive === 'true');
|
||||
filters.push(`is_active = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId",
|
||||
exam_name as "examName", exam_date as "examDate", exam_type as "examType",
|
||||
description, metadata, sort_order as "sortOrder", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.exam_dates
|
||||
where ${filters.join(' and ')}
|
||||
order by exam_date asc nulls last, sort_order asc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertTenantExamDateRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'marketing:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const regionId = nullableString(body.regionId);
|
||||
const schoolId = nullableString(body.schoolId);
|
||||
await ensureTenantReference(client, 'regions', auth.tenantId, regionId, 'REGION_NOT_FOUND');
|
||||
await ensureTenantReference(client, 'schools', auth.tenantId, schoolId, 'SCHOOL_NOT_FOUND');
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.exam_dates (
|
||||
id, tenant_id, region_id, school_id, legacy_id, exam_name, exam_date,
|
||||
exam_type, description, metadata, sort_order, is_active
|
||||
)
|
||||
values (
|
||||
coalesce($2::uuid, gen_random_uuid()), $1, $3::uuid, $4::uuid, $5, $6, $7::date,
|
||||
$8, $9, $10::jsonb, $11, $12
|
||||
)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
school_id = excluded.school_id,
|
||||
legacy_id = coalesce(excluded.legacy_id, public.exam_dates.legacy_id),
|
||||
exam_name = excluded.exam_name,
|
||||
exam_date = excluded.exam_date,
|
||||
exam_type = excluded.exam_type,
|
||||
description = excluded.description,
|
||||
metadata = excluded.metadata,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
where public.exam_dates.tenant_id = excluded.tenant_id
|
||||
returning id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId",
|
||||
exam_name as "examName", exam_date as "examDate", exam_type as "examType",
|
||||
description, metadata, sort_order as "sortOrder", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
nullableString(body.id),
|
||||
regionId,
|
||||
schoolId,
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'examName'),
|
||||
nullableString(body.examDate),
|
||||
nullableString(body.examType),
|
||||
nullableString(body.description),
|
||||
JSON.stringify(objectValue(body.metadata)),
|
||||
intValue(body.sortOrder, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Exam date not found for this tenant', 'EXAM_DATE_NOT_FOUND');
|
||||
await recordAudit(client, auth, 'tenant.exam_date.upserted', 'exam_dates', result.rows[0].id, {
|
||||
examName: result.rows[0].examName,
|
||||
examDate: result.rows[0].examDate,
|
||||
});
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantFeedbacksRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'feedback:read');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const type = stringParam(ctx, 'type');
|
||||
const questionId = stringParam(ctx, 'questionId');
|
||||
const userId = stringParam(ctx, 'userId');
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['r.tenant_id = $1'];
|
||||
if (status) {
|
||||
if (!REPORT_STATUSES.includes(status)) throw new HttpError(400, `Invalid report status: ${status}`, 'INVALID_REPORT_STATUS');
|
||||
params.push(status);
|
||||
filters.push(`r.status = $${params.length}`);
|
||||
}
|
||||
if (type) {
|
||||
params.push(type);
|
||||
filters.push(`r.type = $${params.length}`);
|
||||
}
|
||||
if (questionId) {
|
||||
params.push(questionId);
|
||||
filters.push(`r.question_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (userId) {
|
||||
params.push(userId);
|
||||
filters.push(`r.user_id = $${params.length}::uuid`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select r.id, r.question_id as "questionId", q.type as "questionType",
|
||||
r.user_id as "userId", u.name as "userName", u.phone as "userPhone",
|
||||
r.type, r.category, r.title, r.description, r.status, r.priority,
|
||||
r.contact, r.attachments, r.metadata, r.resolution,
|
||||
r.handled_by as "handledBy", handler.name as "handledByName",
|
||||
r.handled_at as "handledAt", r.created_at as "createdAt", r.updated_at as "updatedAt"
|
||||
from public.reports r
|
||||
left join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
|
||||
left join public.platform_users u on u.id = r.user_id
|
||||
left join public.platform_users handler on handler.id = r.handled_by
|
||||
where ${filters.join(' and ')}
|
||||
order by case r.priority
|
||||
when 'urgent' then 1
|
||||
when 'high' then 2
|
||||
when 'normal' then 3
|
||||
else 4
|
||||
end, r.created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function tenantFeedbackEventsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'feedback:read');
|
||||
const reportId = stringParam(ctx, 'reportId');
|
||||
if (!reportId) throw new HttpError(400, 'reportId is required', 'REPORT_ID_REQUIRED');
|
||||
const items = await query(
|
||||
`
|
||||
select e.id, e.report_id as "reportId", e.from_status as "fromStatus",
|
||||
e.to_status as "toStatus", e.note, e.actor_user_id as "actorUserId",
|
||||
actor.name as "actorName", e.metadata, e.created_at as "createdAt"
|
||||
from public.report_status_events e
|
||||
left join public.platform_users actor on actor.id = e.actor_user_id
|
||||
where e.tenant_id = $1 and e.report_id = $2
|
||||
order by e.created_at asc
|
||||
`,
|
||||
[auth.tenantId, reportId],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'feedback:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const reportId = requiredString(body, 'reportId');
|
||||
const nextStatus = optionalChoice(body.status, REPORT_STATUSES, 'accepted');
|
||||
const rewardPoints = Math.max(0, intValue(body.rewardPoints, 0));
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const current = await client.query<{ status: string; userId: string | null }>(
|
||||
`
|
||||
select status, user_id as "userId"
|
||||
from public.reports
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, reportId],
|
||||
);
|
||||
if (!current.rows[0]) throw new HttpError(404, 'Feedback report not found', 'REPORT_NOT_FOUND');
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
update public.reports
|
||||
set status = $3,
|
||||
priority = $4,
|
||||
resolution = coalesce($5, resolution),
|
||||
handled_by = $6,
|
||||
handled_at = now(),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id, question_id as "questionId", user_id as "userId", type,
|
||||
title, description, status, priority, resolution,
|
||||
handled_by as "handledBy", handled_at as "handledAt",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
reportId,
|
||||
nextStatus,
|
||||
optionalChoice(body.priority, REPORT_PRIORITIES, 'normal'),
|
||||
nullableString(body.resolution),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.report_status_events (
|
||||
tenant_id, report_id, from_status, to_status, note, actor_user_id, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
reportId,
|
||||
current.rows[0].status,
|
||||
nextStatus,
|
||||
nullableString(body.note),
|
||||
auth.userId,
|
||||
JSON.stringify(objectValue(body.metadata)),
|
||||
],
|
||||
);
|
||||
|
||||
let reward = null;
|
||||
if (rewardPoints > 0 && current.rows[0].userId) {
|
||||
const currentScore = await client.query<{ score: number }>(
|
||||
'select score from public.platform_users where id = $1 for update',
|
||||
[current.rows[0].userId],
|
||||
);
|
||||
const balanceAfter = Number(currentScore.rows[0]?.score || 0) + rewardPoints;
|
||||
const ledger = await client.query(
|
||||
`
|
||||
insert into public.user_score_events (
|
||||
tenant_id, user_id, event_type, points, balance_after,
|
||||
source_type, source_id, idempotency_key, metadata
|
||||
)
|
||||
values ($1, $2, 'feedback_reward', $3, $4, 'reports', $5, $6, $7::jsonb)
|
||||
on conflict (tenant_id, idempotency_key) do nothing
|
||||
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
|
||||
source_type as "sourceType", source_id as "sourceId", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
current.rows[0].userId,
|
||||
rewardPoints,
|
||||
balanceAfter,
|
||||
reportId,
|
||||
`feedback_reward:${reportId}`,
|
||||
JSON.stringify({ reportId, status: nextStatus }),
|
||||
],
|
||||
);
|
||||
if (ledger.rows[0]) {
|
||||
const updatedUser = await client.query<{ score: number }>(
|
||||
`
|
||||
update public.platform_users
|
||||
set score = score + $2,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning score
|
||||
`,
|
||||
[current.rows[0].userId, rewardPoints],
|
||||
);
|
||||
reward = { ...ledger.rows[0], balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter };
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(client, auth, 'tenant.feedback.status_updated', 'reports', reportId, {
|
||||
fromStatus: current.rows[0].status,
|
||||
toStatus: nextStatus,
|
||||
rewardPoints,
|
||||
});
|
||||
return { ...result.rows[0], reward };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
Reference in New Issue
Block a user