forked from wangziqi/gongxue-base
feat: add feedback operations report
This commit is contained in:
@@ -675,7 +675,10 @@ function sanitizeOAuthProfilePayload(value: unknown): unknown {
|
||||
normalized.includes('refreshtoken') ||
|
||||
normalized.includes('sessionkey') ||
|
||||
normalized.includes('secret') ||
|
||||
normalized.includes('clientsecret')
|
||||
normalized.includes('clientsecret') ||
|
||||
normalized.includes('avatar') ||
|
||||
normalized.includes('headimg') ||
|
||||
normalized.includes('figureurl')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -764,16 +767,13 @@ async function callWechatWebUserInfo(input: {
|
||||
}
|
||||
|
||||
const nickname = stringField(raw, ['nickname', 'name']);
|
||||
const avatarUrl = stringField(raw, ['headimgurl', 'avatarUrl', 'avatar_url']);
|
||||
const unionId = stringField(raw, ['unionid', 'unionId']);
|
||||
return {
|
||||
nickname,
|
||||
avatarUrl,
|
||||
unionId,
|
||||
profile: {
|
||||
...jsonObject(sanitizeOAuthProfilePayload(raw)),
|
||||
nickname,
|
||||
avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -934,7 +934,6 @@ export async function wechatWebLoginRoute(ctx: RequestContext) {
|
||||
...userInfo.profile,
|
||||
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
|
||||
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
|
||||
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
|
||||
};
|
||||
|
||||
return transaction(async client => {
|
||||
@@ -1069,14 +1068,11 @@ async function callQqUserInfo(input: {
|
||||
}
|
||||
|
||||
const nickname = stringField(raw, ['nickname', 'name']);
|
||||
const avatarUrl = stringField(raw, ['figureurl_qq_2', 'figureurl_qq_1', 'figureurl_2', 'figureurl_1', 'avatarUrl']);
|
||||
return {
|
||||
nickname,
|
||||
avatarUrl,
|
||||
profile: {
|
||||
...jsonObject(sanitizeOAuthProfilePayload(raw)),
|
||||
nickname,
|
||||
avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1135,7 +1131,6 @@ export async function qqLoginRoute(ctx: RequestContext) {
|
||||
...userInfo.profile,
|
||||
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
|
||||
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
|
||||
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
|
||||
};
|
||||
|
||||
return transaction(async client => {
|
||||
|
||||
@@ -56,6 +56,36 @@ export function hashSmsCode(tenantId: string, phone: string, purpose: string, co
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function stripUnsupportedOAuthProfileFields(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(item => stripUnsupportedOAuthProfileFields(item));
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
|
||||
if (
|
||||
normalized.includes('accesstoken') ||
|
||||
normalized.includes('refreshtoken') ||
|
||||
normalized.includes('sessionkey') ||
|
||||
normalized.includes('secret') ||
|
||||
normalized.includes('clientsecret') ||
|
||||
normalized.includes('avatar') ||
|
||||
normalized.includes('headimg') ||
|
||||
normalized.includes('figureurl')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = stripUnsupportedOAuthProfileFields(child);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function publicOAuthProfile(profile: Record<string, unknown> | undefined) {
|
||||
const sanitized = stripUnsupportedOAuthProfileFields(profile || {});
|
||||
return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
|
||||
? sanitized as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export function createSessionToken() {
|
||||
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
|
||||
}
|
||||
@@ -300,7 +330,7 @@ export async function upsertOAuthUser(
|
||||
return { user: matchedUser, isNewUser: false };
|
||||
}
|
||||
|
||||
const profile = input.profile || {};
|
||||
const profile = publicOAuthProfile(input.profile);
|
||||
const nickname =
|
||||
typeof profile.nickname === 'string'
|
||||
? profile.nickname
|
||||
@@ -309,17 +339,11 @@ export async function upsertOAuthUser(
|
||||
: typeof profile.name === 'string'
|
||||
? profile.name
|
||||
: null;
|
||||
const avatarUrl =
|
||||
typeof profile.avatarUrl === 'string'
|
||||
? profile.avatarUrl
|
||||
: typeof profile.avatar_url === 'string'
|
||||
? profile.avatar_url
|
||||
: null;
|
||||
|
||||
const userResult = await client.query<PlatformUserSummary>(
|
||||
`
|
||||
insert into public.platform_users (username, phone, email, name, avatar_url, primary_role, raw_profile)
|
||||
values ($1, $2, $3::citext, $4, $5, 'student', $6::jsonb)
|
||||
insert into public.platform_users (username, phone, email, name, primary_role, raw_profile)
|
||||
values ($1, $2, $3::citext, $4, 'student', $5::jsonb)
|
||||
returning id, username, phone, name, avatar_url as "avatarUrl",
|
||||
primary_role as "primaryRole", created_at as "createdAt"
|
||||
`,
|
||||
@@ -328,7 +352,6 @@ export async function upsertOAuthUser(
|
||||
input.phone || null,
|
||||
input.email || null,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
JSON.stringify({
|
||||
source: input.provider,
|
||||
profile,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { tenantUserNotificationsRoute } from './notifications.js';
|
||||
import {
|
||||
tenantExamDatesRoute,
|
||||
tenantFeedbackEventsRoute,
|
||||
tenantFeedbackReportRoute,
|
||||
tenantFeedbacksRoute,
|
||||
updateTenantFeedbackStatusRoute,
|
||||
upsertTenantExamDateRoute,
|
||||
@@ -131,6 +132,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute],
|
||||
['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute],
|
||||
['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute],
|
||||
['GET', '/api/tenant-admin/feedbacks/report', tenantFeedbackReportRoute],
|
||||
['POST', '/api/tenant-admin/feedbacks/status', updateTenantFeedbackStatusRoute],
|
||||
['GET', '/api/tenant-admin/feedbacks/events', tenantFeedbackEventsRoute],
|
||||
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],
|
||||
|
||||
@@ -14,6 +14,11 @@ type JsonBody = Record<string, unknown>;
|
||||
|
||||
const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed'];
|
||||
const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||
const FEEDBACK_REPORT_RANGES: Record<string, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
};
|
||||
const REPORT_STATUS_LABELS: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
accepted: '已受理',
|
||||
@@ -35,10 +40,71 @@ function intValue(value: unknown, fallback: number) {
|
||||
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback = 0) {
|
||||
const numberValue = Number(value ?? fallback);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
}
|
||||
|
||||
function boolValue(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function boundedIntParam(ctx: RequestContext, name: string, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(ctx.url.searchParams.get(name) || fallback);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function shanghaiDateParts(date = new Date()) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value])) as {
|
||||
year: string;
|
||||
month: string;
|
||||
day: string;
|
||||
};
|
||||
}
|
||||
|
||||
function shanghaiDateKey(date = new Date()) {
|
||||
const parts = shanghaiDateParts(date);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
function addDaysKey(dateKey: string, days: number) {
|
||||
const date = new Date(`${dateKey}T00:00:00+08:00`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return shanghaiDateKey(date);
|
||||
}
|
||||
|
||||
function shanghaiDayStartIso(dateKey: string) {
|
||||
return new Date(`${dateKey}T00:00:00+08:00`).toISOString();
|
||||
}
|
||||
|
||||
function feedbackReportRange(value: string) {
|
||||
const timeRange = value || '30d';
|
||||
const days = FEEDBACK_REPORT_RANGES[timeRange];
|
||||
if (!days) throw new HttpError(400, 'Unsupported feedback report range', 'INVALID_FEEDBACK_REPORT_RANGE');
|
||||
const endDate = shanghaiDateKey();
|
||||
const startDate = addDaysKey(endDate, -(days - 1));
|
||||
return {
|
||||
timeRange,
|
||||
days,
|
||||
startDate,
|
||||
endDate,
|
||||
startAt: shanghaiDayStartIso(startDate),
|
||||
endAt: shanghaiDayStartIso(addDaysKey(endDate, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function ratio(numerator: number, denominator: number) {
|
||||
if (!denominator) return 0;
|
||||
return Number((numerator / denominator).toFixed(4));
|
||||
}
|
||||
|
||||
function feedbackNotificationSeverity(status: string) {
|
||||
if (status === 'resolved') return 'success';
|
||||
if (status === 'rejected') return 'warning';
|
||||
@@ -246,6 +312,307 @@ export async function tenantFeedbacksRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function tenantFeedbackReportRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'feedback:read');
|
||||
const range = feedbackReportRange(stringParam(ctx, 'timeRange'));
|
||||
const limit = boundedIntParam(ctx, 'limit', 20, 1, 100);
|
||||
const baseParams = [auth.tenantId, range.startAt, range.endAt];
|
||||
|
||||
const [
|
||||
summary,
|
||||
byStatus,
|
||||
byType,
|
||||
byCategory,
|
||||
byPriority,
|
||||
dailyTrend,
|
||||
topQuestions,
|
||||
topHandlers,
|
||||
recentUnhandled,
|
||||
] = await Promise.all([
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
with created_reports as (
|
||||
select *
|
||||
from public.reports
|
||||
where tenant_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and created_at < $3::timestamptz
|
||||
),
|
||||
handled_reports as (
|
||||
select *
|
||||
from public.reports
|
||||
where tenant_id = $1
|
||||
and handled_at >= $2::timestamptz
|
||||
and handled_at < $3::timestamptz
|
||||
and status <> 'pending'
|
||||
),
|
||||
feedback_rewards as (
|
||||
select e.*
|
||||
from public.user_score_events e
|
||||
join public.reports r on r.tenant_id = e.tenant_id and r.id = e.source_id
|
||||
where e.tenant_id = $1
|
||||
and e.created_at >= $2::timestamptz
|
||||
and e.created_at < $3::timestamptz
|
||||
and e.event_type = 'feedback_reward'
|
||||
and e.source_type = 'reports'
|
||||
)
|
||||
select
|
||||
(select count(*)::int from created_reports) as total,
|
||||
(select count(*)::int from created_reports where status = 'pending') as pending,
|
||||
(select count(*)::int from created_reports where status = 'accepted') as accepted,
|
||||
(select count(*)::int from created_reports where status = 'rejected') as rejected,
|
||||
(select count(*)::int from created_reports where status = 'resolved') as resolved,
|
||||
(select count(*)::int from created_reports where status = 'closed') as closed,
|
||||
(select count(*)::int from created_reports where priority in ('high', 'urgent')) as "highPriority",
|
||||
(select count(*)::int from public.reports where tenant_id = $1 and status in ('pending', 'accepted')) as "pendingBacklog",
|
||||
(select count(*)::int from handled_reports) as "handledInRange",
|
||||
(select count(distinct source_id)::int from feedback_rewards where source_id is not null) as "rewardedReports",
|
||||
(select coalesce(sum(points), 0)::int from feedback_rewards where points > 0) as "rewardPoints",
|
||||
(
|
||||
select coalesce(avg(extract(epoch from (handled_at - created_at)) / 3600), 0)::numeric
|
||||
from handled_reports
|
||||
where handled_at is not null
|
||||
and handled_at >= created_at
|
||||
) as "avgHandleHours"
|
||||
`,
|
||||
baseParams,
|
||||
),
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
select status,
|
||||
case status
|
||||
when 'pending' then '待处理'
|
||||
when 'accepted' then '已受理'
|
||||
when 'rejected' then '未采纳'
|
||||
when 'resolved' then '已解决'
|
||||
when 'closed' then '已关闭'
|
||||
else status
|
||||
end as label,
|
||||
count(*)::int as count,
|
||||
count(*) filter (where priority in ('high', 'urgent'))::int as "highPriority"
|
||||
from public.reports
|
||||
where tenant_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and created_at < $3::timestamptz
|
||||
group by status
|
||||
order by case status
|
||||
when 'pending' then 1
|
||||
when 'accepted' then 2
|
||||
when 'resolved' then 3
|
||||
when 'rejected' then 4
|
||||
when 'closed' then 5
|
||||
else 9
|
||||
end
|
||||
`,
|
||||
baseParams,
|
||||
),
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
select coalesce(type, 'other') as type,
|
||||
count(*)::int as count,
|
||||
max(created_at) as "latestAt"
|
||||
from public.reports
|
||||
where tenant_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and created_at < $3::timestamptz
|
||||
group by coalesce(type, 'other')
|
||||
order by count desc, "latestAt" desc
|
||||
`,
|
||||
baseParams,
|
||||
),
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
select coalesce(nullif(category, ''), 'uncategorized') as category,
|
||||
count(*)::int as count,
|
||||
max(created_at) as "latestAt"
|
||||
from public.reports
|
||||
where tenant_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and created_at < $3::timestamptz
|
||||
group by coalesce(nullif(category, ''), 'uncategorized')
|
||||
order by count desc, "latestAt" desc
|
||||
limit $4
|
||||
`,
|
||||
[...baseParams, limit],
|
||||
),
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
select priority,
|
||||
count(*)::int as count
|
||||
from public.reports
|
||||
where tenant_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and created_at < $3::timestamptz
|
||||
group by priority
|
||||
order by case priority
|
||||
when 'urgent' then 1
|
||||
when 'high' then 2
|
||||
when 'normal' then 3
|
||||
when 'low' then 4
|
||||
else 9
|
||||
end
|
||||
`,
|
||||
baseParams,
|
||||
),
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
with days as (
|
||||
select generate_series(
|
||||
$2::timestamptz,
|
||||
$3::timestamptz - interval '1 day',
|
||||
interval '1 day'
|
||||
) as day_start
|
||||
),
|
||||
created_daily as (
|
||||
select (created_at at time zone 'Asia/Shanghai')::date::text as date,
|
||||
count(*)::int as total,
|
||||
count(*) filter (where status = 'pending')::int as pending,
|
||||
count(*) filter (where status = 'resolved')::int as resolved,
|
||||
count(*) filter (where priority in ('high', 'urgent'))::int as "highPriority"
|
||||
from public.reports
|
||||
where tenant_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and created_at < $3::timestamptz
|
||||
group by (created_at at time zone 'Asia/Shanghai')::date
|
||||
),
|
||||
handled_daily as (
|
||||
select (handled_at at time zone 'Asia/Shanghai')::date::text as date,
|
||||
count(*)::int as handled
|
||||
from public.reports
|
||||
where tenant_id = $1
|
||||
and handled_at >= $2::timestamptz
|
||||
and handled_at < $3::timestamptz
|
||||
and status <> 'pending'
|
||||
group by (handled_at at time zone 'Asia/Shanghai')::date
|
||||
)
|
||||
select (d.day_start at time zone 'Asia/Shanghai')::date::text as date,
|
||||
coalesce(cd.total, 0)::int as total,
|
||||
coalesce(cd.pending, 0)::int as pending,
|
||||
coalesce(cd.resolved, 0)::int as resolved,
|
||||
coalesce(cd."highPriority", 0)::int as "highPriority",
|
||||
coalesce(hd.handled, 0)::int as handled
|
||||
from days d
|
||||
left join created_daily cd on cd.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
|
||||
left join handled_daily hd on hd.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
|
||||
order by date asc
|
||||
`,
|
||||
baseParams,
|
||||
),
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
select r.question_id as "questionId",
|
||||
q.type as "questionType",
|
||||
left(coalesce(nullif(q.type_label, ''), nullif(v.content, ''), q.legacy_id, r.question_id::text), 120) as "questionPreview",
|
||||
count(*)::int as "reportCount",
|
||||
count(*) filter (where r.priority in ('high', 'urgent'))::int as "highPriority",
|
||||
max(r.created_at) as "latestAt"
|
||||
from public.reports r
|
||||
join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where r.tenant_id = $1
|
||||
and r.question_id is not null
|
||||
and r.created_at >= $2::timestamptz
|
||||
and r.created_at < $3::timestamptz
|
||||
group by r.question_id, q.type, q.type_label, q.legacy_id, v.content
|
||||
order by "reportCount" desc, "highPriority" desc, "latestAt" desc
|
||||
limit $4
|
||||
`,
|
||||
[...baseParams, limit],
|
||||
),
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
select r.handled_by as "handlerUserId",
|
||||
handler.name as "handlerName",
|
||||
handler.phone as "handlerPhone",
|
||||
count(*)::int as "handledCount",
|
||||
count(*) filter (where r.status = 'resolved')::int as resolved,
|
||||
count(*) filter (where r.status = 'rejected')::int as rejected,
|
||||
count(*) filter (where r.status = 'closed')::int as closed,
|
||||
coalesce(avg(extract(epoch from (r.handled_at - r.created_at)) / 3600), 0)::numeric as "avgHandleHours",
|
||||
max(r.handled_at) as "latestHandledAt"
|
||||
from public.reports r
|
||||
left join public.platform_users handler on handler.id = r.handled_by
|
||||
where r.tenant_id = $1
|
||||
and r.handled_by is not null
|
||||
and r.handled_at >= $2::timestamptz
|
||||
and r.handled_at < $3::timestamptz
|
||||
and r.status <> 'pending'
|
||||
group by r.handled_by, handler.name, handler.phone
|
||||
order by "handledCount" desc, "latestHandledAt" desc
|
||||
limit $4
|
||||
`,
|
||||
[...baseParams, limit],
|
||||
),
|
||||
query<Record<string, unknown>>(
|
||||
`
|
||||
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.status, r.priority,
|
||||
extract(epoch from (now() - r.created_at)) / 3600 as "ageHours",
|
||||
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
|
||||
where r.tenant_id = $1
|
||||
and r.status in ('pending', 'accepted')
|
||||
order by case r.priority
|
||||
when 'urgent' then 1
|
||||
when 'high' then 2
|
||||
when 'normal' then 3
|
||||
else 4
|
||||
end, r.created_at asc
|
||||
limit $2
|
||||
`,
|
||||
[auth.tenantId, limit],
|
||||
),
|
||||
]);
|
||||
|
||||
const summaryRow = summary[0] || {};
|
||||
const total = intValue(summaryRow.total, 0);
|
||||
const resolved = intValue(summaryRow.resolved, 0);
|
||||
const closed = intValue(summaryRow.closed, 0);
|
||||
const rejected = intValue(summaryRow.rejected, 0);
|
||||
const handledInRange = intValue(summaryRow.handledInRange, 0);
|
||||
return {
|
||||
item: {
|
||||
range,
|
||||
summary: {
|
||||
total,
|
||||
newInRange: total,
|
||||
pending: intValue(summaryRow.pending, 0),
|
||||
accepted: intValue(summaryRow.accepted, 0),
|
||||
rejected,
|
||||
resolved,
|
||||
closed,
|
||||
highPriority: intValue(summaryRow.highPriority, 0),
|
||||
pendingBacklog: intValue(summaryRow.pendingBacklog, 0),
|
||||
handledInRange,
|
||||
rewardedReports: intValue(summaryRow.rewardedReports, 0),
|
||||
rewardPoints: intValue(summaryRow.rewardPoints, 0),
|
||||
resolutionRate: ratio(resolved + closed, total),
|
||||
handledRate: ratio(handledInRange, total),
|
||||
rejectionRate: ratio(rejected, total),
|
||||
avgHandleHours: Number(numberValue(summaryRow.avgHandleHours, 0).toFixed(2)),
|
||||
},
|
||||
byStatus,
|
||||
byType,
|
||||
byCategory,
|
||||
byPriority,
|
||||
dailyTrend,
|
||||
topQuestions,
|
||||
topHandlers: topHandlers.map(item => ({
|
||||
...item,
|
||||
avgHandleHours: Number(numberValue(item.avgHandleHours, 0).toFixed(2)),
|
||||
})),
|
||||
recentUnhandled: recentUnhandled.map(item => ({
|
||||
...item,
|
||||
ageHours: Number(numberValue(item.ageHours, 0).toFixed(2)),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function tenantFeedbackEventsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'feedback:read');
|
||||
|
||||
Reference in New Issue
Block a user