feat: add feedback checkins exam dates

This commit is contained in:
Codex
2026-06-29 01:48:38 +08:00
parent 726d090a8e
commit 8f92d0427e
16 changed files with 1232 additions and 33 deletions

View File

@@ -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: '学生查看' },

View File

@@ -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],

View 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 };
}