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

@@ -11,6 +11,7 @@ import {
announcementsRoute,
bannersRoute,
categoriesRoute,
examDatesRoute,
faqsRoute,
handbookChaptersRoute,
handbookEntriesRoute,
@@ -55,5 +56,6 @@ export const catalogRoutes: RouteDefinition[] = [
['GET', '/api/catalog/announcements', announcementsRoute],
['GET', '/api/catalog/products', productsRoute],
['GET', '/api/catalog/timelines', timelinesRoute],
['GET', '/api/catalog/exam-dates', examDatesRoute],
['GET', '/api/catalog/svip-plans', svipPlansRoute],
];

View File

@@ -2,6 +2,22 @@ import type { RequestContext } from '../../core/http.js';
import { query } from '../../core/db.js';
import { intParam, tenantIdFrom } from '../../core/request.js';
function dateOnly(value: unknown) {
if (!value) return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10);
return null;
}
function daysUntil(dateValue: unknown, today: Date) {
const dateText = dateOnly(dateValue);
if (!dateText) return null;
const target = new Date(`${dateText}T00:00:00.000Z`);
if (Number.isNaN(target.getTime())) return null;
const current = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
return Math.ceil((target.getTime() - current.getTime()) / 86_400_000);
}
export async function regionsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
@@ -566,6 +582,50 @@ export async function timelinesRoute(ctx: RequestContext) {
return { items };
}
export async function examDatesRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const limit = intParam(ctx, 'limit', 50, 200);
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length}::uuid or region_id is null)`);
}
if (schoolId) {
params.push(schoolId);
filters.push(`(school_id = $${params.length}::uuid or school_id is null)`);
}
params.push(limit);
const today = new Date();
const items = await query<{
examDate: string | null;
} & Record<string, unknown>>(
`
select id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId",
exam_name as "examName", exam_date::text as "examDate", exam_type as "examType",
description, metadata, sort_order as "order", 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: items.map(item => {
return {
...item,
daysLeft: daysUntil(item.examDate, today),
};
}),
};
}
export async function svipPlansRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');

View File

@@ -1,7 +1,20 @@
import type { RouteDefinition } from '../../core/router.js';
import { profileMeRoute, updateProfileMeRoute } from './routes.js';
import {
checkInRoute,
examCountdownRoute,
feedbacksRoute,
profileMeRoute,
scoreEventsRoute,
submitFeedbackRoute,
updateProfileMeRoute,
} from './routes.js';
export const profileRoutes: RouteDefinition[] = [
['GET', '/api/profile/me', profileMeRoute],
['PATCH', '/api/profile/me', updateProfileMeRoute],
['POST', '/api/profile/check-in', checkInRoute],
['GET', '/api/profile/score-events', scoreEventsRoute],
['GET', '/api/profile/feedbacks', feedbacksRoute],
['POST', '/api/profile/feedbacks', submitFeedbackRoute],
['GET', '/api/profile/exam-countdowns', examCountdownRoute],
];

View File

@@ -1,6 +1,6 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
type JsonMap = Record<string, unknown>;
@@ -39,6 +39,40 @@ function jsonArrayBodyValue(value: unknown) {
return JSON.stringify(Array.isArray(value) ? value : []);
}
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 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;
}
function toDateOnly(value: unknown) {
if (!value) return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10);
return null;
}
function daysBetween(dateValue: unknown, today: Date) {
const dateText = toDateOnly(dateValue);
if (!dateText) return null;
const target = new Date(`${dateText}T00:00:00.000Z`);
if (Number.isNaN(target.getTime())) return null;
const current = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
return Math.ceil((target.getTime() - current.getTime()) / 86_400_000);
}
const FEEDBACK_TYPES = ['question_error', 'content_error', 'video_error', 'asset_error', 'system_bug', 'suggestion', 'other'];
export async function profileMeRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
@@ -257,3 +291,283 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
return { item };
}
export async function checkInRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const item = await transaction(async client => {
const today = new Date().toISOString().slice(0, 10);
const existing = await client.query<{
lastCheckInDate: string | null;
score: number;
stats: Record<string, unknown>;
}>(
`
select sp.last_check_in_date as "lastCheckInDate", u.score, sp.stats
from public.student_profiles sp
join public.platform_users u on u.id = sp.user_id
where sp.tenant_id = $1 and sp.user_id = $2
limit 1
for update of sp, u
`,
[tenantId, userId],
);
const profile = existing.rows[0];
if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
if (profile.lastCheckInDate === today) {
return {
checkedIn: false,
alreadyCheckedIn: true,
pointsAdded: 0,
score: Number(profile.score || 0),
lastCheckInDate: today,
};
}
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const yesterdayText = yesterday.toISOString().slice(0, 10);
const stats = objectValue(profile.stats);
const previousStreak = Number(stats.checkInStreak || 0);
const streak = profile.lastCheckInDate === yesterdayText ? previousStreak + 1 : 1;
const pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6);
const balanceAfter = Number(profile.score || 0) + pointsAdded;
const nextStats = {
...stats,
checkInStreak: streak,
lastCheckInPoints: pointsAdded,
};
const ledger = await client.query(
`
insert into public.user_score_events (
tenant_id, user_id, event_type, points, balance_after,
source_type, idempotency_key, metadata
)
values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb)
on conflict (tenant_id, idempotency_key) do nothing
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
source_type as "sourceType", created_at as "createdAt"
`,
[
tenantId,
userId,
pointsAdded,
balanceAfter,
`check_in:${userId}:${today}`,
JSON.stringify({ checkInDate: today, streak }),
],
);
if (!ledger.rows[0]) {
return {
checkedIn: false,
alreadyCheckedIn: true,
pointsAdded: 0,
score: Number(profile.score || 0),
lastCheckInDate: today,
};
}
const updatedUser = await client.query<{ score: number }>(
`
update public.platform_users
set score = score + $2,
updated_at = now()
where id = $1
returning score
`,
[userId, pointsAdded],
);
await client.query(
`
update public.student_profiles
set last_check_in_date = $3::date,
stats = $4::jsonb,
updated_at = now()
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId, today, JSON.stringify(nextStats)],
);
return {
checkedIn: true,
alreadyCheckedIn: false,
pointsAdded,
streak,
score: updatedUser.rows[0]?.score || balanceAfter,
lastCheckInDate: today,
ledger: {
...ledger.rows[0],
balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter,
},
};
});
return { item };
}
export async function scoreEventsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query(
`
select id, event_type as "eventType", points, balance_after as "balanceAfter",
source_type as "sourceType", source_id as "sourceId",
metadata, created_at as "createdAt"
from public.user_score_events
where tenant_id = $1 and user_id = $2
order by created_at desc
limit $3
`,
[tenantId, userId, limit],
);
return { items };
}
export async function feedbacksRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const status = stringParam(ctx, 'status');
const params: unknown[] = [tenantId, userId];
const filters = ['r.tenant_id = $1', 'r.user_id = $2'];
if (status) {
params.push(status);
filters.push(`r.status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select r.id, r.question_id as "questionId", q.type as "questionType",
r.type, r.category, r.title, r.description, r.status, r.priority,
r.resolution, r.handled_by as "handledBy", r.handled_at as "handledAt",
r.attachments, r.metadata, 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
where ${filters.join(' and ')}
order by r.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function submitFeedbackRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const userId = await userIdFrom(ctx, body);
const questionId = nullableString(body.questionId);
const type = optionalChoice(body.type, FEEDBACK_TYPES, 'question_error');
const description = requiredString(body, 'description');
const attachments = Array.isArray(body.attachments) ? body.attachments : [];
const item = await transaction(async client => {
if (questionId) {
const question = await client.query<{ id: string }>(
'select id from public.questions where tenant_id = $1 and id = $2 and status <> \'archived\' limit 1',
[tenantId, questionId],
);
if (!question.rows[0]) throw new HttpError(404, 'Question not found for this tenant', 'QUESTION_NOT_FOUND');
}
const result = await client.query(
`
insert into public.reports (
tenant_id, question_id, user_id, type, category, title, description,
status, priority, contact, attachments, metadata
)
values ($1, $2::uuid, $3, $4, $5, $6, $7, 'pending', $8, $9, $10::jsonb, $11::jsonb)
returning id, question_id as "questionId", user_id as "userId", type, category,
title, description, status, priority, contact, attachments, metadata,
created_at as "createdAt", updated_at as "updatedAt"
`,
[
tenantId,
questionId,
userId,
type,
nullableString(body.category),
nullableString(body.title),
description,
optionalChoice(body.priority, ['low', 'normal', 'high', 'urgent'], 'normal'),
nullableString(body.contact),
JSON.stringify(attachments),
JSON.stringify(objectValue(body.metadata)),
],
);
await client.query(
`
insert into public.report_status_events (tenant_id, report_id, from_status, to_status, note, actor_user_id)
values ($1, $2, null, 'pending', 'student submitted feedback', $3)
`,
[tenantId, result.rows[0].id, userId],
);
return result.rows[0];
});
return { item };
}
export async function examCountdownRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 5, 20);
const profile = await queryOne<{
regionId: string | null;
selectedSchoolId: string | null;
}>(
`
select region_id as "regionId", selected_school_id as "selectedSchoolId"
from public.student_profiles
where tenant_id = $1 and user_id = $2
limit 1
`,
[tenantId, userId],
);
if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
const items = await query<{
id: string;
examName: string;
examDate: string | null;
} & Record<string, unknown>>(
`
select id, region_id as "regionId", school_id as "schoolId",
exam_name as "examName", exam_date::text as "examDate",
exam_type as "examType", description, metadata,
sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt"
from public.exam_dates
where tenant_id = $1
and is_active = true
and ($2::uuid is null or region_id is null or region_id = $2::uuid)
and ($3::uuid is null or school_id is null or school_id = $3::uuid)
order by exam_date asc nulls last, sort_order asc
limit $4
`,
[tenantId, profile.regionId, profile.selectedSchoolId, limit],
);
const today = new Date();
return {
items: items.map(item => ({
...item,
daysLeft: daysBetween(item.examDate, today),
})),
target: {
regionId: profile.regionId,
schoolId: profile.selectedSchoolId,
},
};
}

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