From 0bca1f00a9958735ebd820d974f93dea7c7ce14c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 03:52:09 +0800 Subject: [PATCH] feat: add tenant dashboard analytics --- apps/api/src/features/tenant-admin/auth.ts | 3 +- .../src/features/tenant-admin/dashboard.ts | 794 ++++++++++++++++++ apps/api/src/features/tenant-admin/index.ts | 2 + docs/refactor/backend-capability-status.md | 2 +- docs/refactor/legacy-feature-gap-matrix.md | 4 +- docs/refactor/next-development-todo.md | 7 +- docs/refactor/taro-frontend-integration.md | 60 +- scripts/api-integration-test.js | 44 + 8 files changed, 908 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/features/tenant-admin/dashboard.ts diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 6dbb8d37..2cc3226b 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -13,7 +13,7 @@ const TENANT_ADMIN_ROLES = new Set([ const ROLE_PERMISSION_DEFAULTS: Record = { tenant_owner: ['*'], tenant_admin: ['*'], - tenant_operator: ['content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'], + tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'], teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'], sales: ['codes:*', 'coupons:*', 'referral:*'], agent: ['codes:read', 'coupons:read', 'referral:self'], @@ -81,6 +81,7 @@ export function tenantPermissionCatalog() { return { permissions: [ { key: 'tenant:overview:read', label: '租户概览' }, + { key: 'dashboard:read', label: '数据看板查看' }, { key: 'tenant:branding:write', label: '品牌配置' }, { key: 'tenant:settings:write', label: '公开设置' }, { key: 'tenant:domains:read', label: '域名查看' }, diff --git a/apps/api/src/features/tenant-admin/dashboard.ts b/apps/api/src/features/tenant-admin/dashboard.ts new file mode 100644 index 00000000..91f8fb7d --- /dev/null +++ b/apps/api/src/features/tenant-admin/dashboard.ts @@ -0,0 +1,794 @@ +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, stringParam } from '../../core/request.js'; +import { query, queryOne } from '../../core/db.js'; +import { requireTenantAdmin, requireTenantPermission } from './auth.js'; + +const DASHBOARD_RANGES: Record = { + '7d': 7, + '30d': 30, + '90d': 90, +}; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const shanghaiDateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); + +type NumericRow = Record; + +function numberValue(value: unknown) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +function intValue(value: unknown) { + return Math.trunc(numberValue(value)); +} + +function moneyValue(value: unknown) { + return Math.trunc(numberValue(value)); +} + +function ratio(numerator: number, denominator: number) { + if (denominator <= 0) return 0; + return Number((numerator / denominator).toFixed(4)); +} + +function shanghaiDateKey(date: Date) { + const parts = Object.fromEntries( + shanghaiDateFormatter.formatToParts(date).map(part => [part.type, part.value]), + ); + 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 parseRange(value: string) { + const timeRange = value || '30d'; + const days = DASHBOARD_RANGES[timeRange]; + if (!days) throw new HttpError(400, 'Unsupported dashboard time range', 'INVALID_DASHBOARD_RANGE'); + + const endDate = shanghaiDateKey(new Date()); + const startDate = addDaysKey(endDate, -(days - 1)); + const endExclusiveDate = addDaysKey(endDate, 1); + return { + timeRange, + days, + startDate, + endDate, + startAt: shanghaiDayStartIso(startDate), + endAt: shanghaiDayStartIso(endExclusiveDate), + }; +} + +function emptyTrendSeries(startDate: string, days: number) { + return Array.from({ length: days }, (_, index) => ({ + date: addDaysKey(startDate, index), + newStudents: 0, + orderCount: 0, + paidOrderCount: 0, + revenueCents: 0, + answers: 0, + correctAnswers: 0, + activeStudents: 0, + })); +} + +function applyTrendRows( + trends: ReturnType, + rows: NumericRow[], + mapper: (target: ReturnType[number], row: NumericRow) => void, +) { + const trendMap = new Map(trends.map(item => [item.date, item])); + for (const row of rows) { + const target = trendMap.get(String(row.date || '')); + if (target) mapper(target, row); + } +} + +async function ensureRegionInTenant(tenantId: string, regionId: string | null) { + if (!regionId) return null; + if (!UUID_RE.test(regionId)) { + throw new HttpError(400, 'Invalid regionId', 'INVALID_REGION_ID'); + } + + const region = await queryOne<{ id: string; name: string }>( + ` + select id, name + from public.regions + where tenant_id = $1 and id = $2 and is_active = true + limit 1 + `, + [tenantId, regionId], + ); + if (!region) throw new HttpError(404, 'Region not found for this tenant', 'REGION_NOT_FOUND'); + return region; +} + +export async function tenantDashboardRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'dashboard:read'); + + const range = parseRange(stringParam(ctx, 'timeRange')); + const rawRegionId = stringParam(ctx, 'regionId') || null; + const region = await ensureRegionInTenant(auth.tenantId, rawRegionId); + const regionId = region?.id || null; + const limit = intParam(ctx, 'limit', 10, 50); + const baseParams = [auth.tenantId, range.startAt, range.endAt, regionId]; + const listParams = [...baseParams, limit]; + const scopedParams = [auth.tenantId, regionId]; + const scopedListParams = [auth.tenantId, regionId, limit]; + + const [ + studentTotals, + orderTotals, + contentTotals, + learningTotals, + codeTotals, + feedbackTotals, + studentTrend, + orderTrend, + revenueTrend, + answerTrend, + activeHoursRows, + questionDistributionRows, + subjectTopRows, + regionStatsRows, + planSalesRows, + recentActivitiesRows, + ] = await Promise.all([ + queryOne( + ` + select + count(distinct tm.user_id)::integer as "totalStudents", + count(distinct tm.user_id) filter ( + where tm.created_at >= $2::timestamptz and tm.created_at < $3::timestamptz + )::integer as "newStudents", + count(distinct sp.user_id) filter ( + where greatest(coalesce(sp.updated_at, '-infinity'::timestamptz), coalesce(u.last_seen_at, '-infinity'::timestamptz)) >= now() - interval '7 days' + )::integer as "activeStudents7d", + count(distinct e.user_id) filter (where e.entitlement_type = 'svip')::integer as "svipStudents", + count(distinct e.user_id) filter (where e.entitlement_type = 'video_quota')::integer as "videoMembers" + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id + left join public.entitlements e + on e.tenant_id = tm.tenant_id + and e.user_id = tm.user_id + and e.status = 'active' + and (e.expires_at is null or e.expires_at > now()) + where tm.tenant_id = $1 + and tm.role = 'student' + and tm.status = 'active' + and ($4::uuid is null or sp.region_id = $4::uuid) + `, + baseParams, + ), + queryOne( + ` + select + count(*)::integer as "totalOrders", + count(*) filter (where o.created_at >= $2::timestamptz and o.created_at < $3::timestamptz)::integer as "ordersInRange", + count(*) filter (where o.status = 'paid')::integer as "paidOrders", + count(*) filter ( + where o.status = 'paid' + and coalesce(o.paid_at, o.updated_at, o.created_at) >= $2::timestamptz + and coalesce(o.paid_at, o.updated_at, o.created_at) < $3::timestamptz + )::integer as "paidOrdersInRange", + count(*) filter (where o.status = 'pending')::integer as "pendingOrders", + count(*) filter (where o.status in ('failed', 'closed'))::integer as "failedOrders", + count(*) filter (where o.status = 'refunded')::integer as "refundedOrders", + coalesce(sum(o.amount_cents) filter (where o.status = 'paid'), 0)::bigint as "revenueCents", + coalesce(sum(o.amount_cents) filter ( + where o.status = 'paid' + and coalesce(o.paid_at, o.updated_at, o.created_at) >= $2::timestamptz + and coalesce(o.paid_at, o.updated_at, o.created_at) < $3::timestamptz + ), 0)::bigint as "revenueCentsInRange", + coalesce(avg(o.amount_cents) filter (where o.status = 'paid'), 0)::numeric as "averagePaidAmountCents" + from public.orders o + where o.tenant_id = $1 + and ($4::uuid is null or o.region_id = $4::uuid) + `, + baseParams, + ), + queryOne( + ` + select + (select count(*)::integer from public.regions r + where r.tenant_id = $1 and r.is_active = true and ($2::uuid is null or r.id = $2::uuid)) as regions, + (select count(*)::integer from public.subjects s + where s.tenant_id = $1 and s.is_active = true and ($2::uuid is null or s.region_id = $2::uuid)) as subjects, + (select count(*)::integer from public.content_entries ce + where ce.tenant_id = $1 and ce.is_active = true and ($2::uuid is null or ce.region_id = $2::uuid or ce.region_id is null)) as "contentEntries", + (select count(*)::integer from public.content_nodes cn + where cn.tenant_id = $1 and cn.is_active = true and ($2::uuid is null or cn.region_id = $2::uuid or cn.region_id is null)) as "contentNodes", + (select count(*)::integer from public.question_banks qb + where qb.tenant_id = $1 and qb.status = 'active' and ($2::uuid is null or qb.region_id = $2::uuid)) as "questionBanks", + (select count(*)::integer from public.question_collections qc + left join public.content_entries ce on ce.tenant_id = qc.tenant_id and ce.id = qc.entry_id + left join public.content_nodes cn on cn.tenant_id = qc.tenant_id and cn.id = qc.node_id + where qc.tenant_id = $1 and qc.status = 'active' + and ($2::uuid is null or coalesce(qc.region_id, cn.region_id, ce.region_id) = $2::uuid)) as "questionCollections", + (select count(*)::integer from public.practice_blueprints pb + left join public.content_entries ce on ce.tenant_id = pb.tenant_id and ce.id = pb.entry_id + left join public.content_nodes cn on cn.tenant_id = pb.tenant_id and cn.id = pb.node_id + where pb.tenant_id = $1 and pb.status = 'active' + and ($2::uuid is null or coalesce(pb.region_id, cn.region_id, ce.region_id) = $2::uuid)) as "practiceBlueprints", + (select count(distinct q.id)::integer + from public.questions q + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.content_entries ce on ce.tenant_id = q.tenant_id and ce.id = q.entry_id + where q.tenant_id = $1 and q.status = 'published' + and ($2::uuid is null or coalesce(qb.region_id, s.region_id, ce.region_id) = $2::uuid)) as questions, + (select count(distinct qv.id)::integer + from public.question_videos qv + join public.questions q on q.tenant_id = qv.tenant_id and q.id = qv.question_id + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + where qv.tenant_id = $1 + and ($2::uuid is null or coalesce(qb.region_id, s.region_id) = $2::uuid)) as "questionVideos", + (select count(*)::integer from public.content_assets ca + where ca.tenant_id = $1 and ca.status = 'active' + and ($2::uuid is null or ca.region_id = $2::uuid or ca.region_id is null)) as assets, + (select count(*)::integer from public.vocabulary_units vu + where vu.tenant_id = $1 and vu.is_active = true and ($2::uuid is null or vu.region_id = $2::uuid)) as "vocabularyUnits", + (select count(*)::integer + from public.vocabulary_words vw + left join public.vocabulary_units vu on vu.tenant_id = vw.tenant_id and vu.id = vw.unit_id + where vw.tenant_id = $1 and vw.is_active = true + and ($2::uuid is null or vu.region_id = $2::uuid)) as "vocabularyWords", + (select count(*)::integer from public.handbook_subjects hs + where hs.tenant_id = $1 and hs.is_active = true and ($2::uuid is null or hs.region_id = $2::uuid)) as "handbookSubjects", + (select count(*)::integer + from public.handbook_entries he + join public.handbook_chapters hc on hc.tenant_id = he.tenant_id and hc.id = he.chapter_id + join public.handbook_subjects hs on hs.tenant_id = he.tenant_id and hs.id = hc.subject_id + where he.tenant_id = $1 and he.is_active = true and hc.is_active = true and hs.is_active = true + and ($2::uuid is null or hs.region_id = $2::uuid)) as "handbookEntries" + `, + scopedParams, + ), + queryOne( + ` + with scoped_answers as ( + select ar.* + from public.answer_records ar + left join public.questions q on q.tenant_id = ar.tenant_id and q.id = ar.question_id + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.student_profiles sp on sp.tenant_id = ar.tenant_id and sp.user_id = ar.user_id + where ar.tenant_id = $1 + and ar.answered_at >= $2::timestamptz + and ar.answered_at < $3::timestamptz + and ($4::uuid is null or coalesce(qb.region_id, s.region_id, sp.region_id) = $4::uuid) + ), + scoped_reports as ( + select psr.* + from public.practice_session_reports psr + left join public.student_profiles sp on sp.tenant_id = psr.tenant_id and sp.user_id = psr.user_id + where psr.tenant_id = $1 + and psr.submitted_at >= $2::timestamptz + and psr.submitted_at < $3::timestamptz + and ($4::uuid is null or sp.region_id = $4::uuid) + ) + select + (select count(*)::integer from scoped_answers) as answers, + (select count(*)::integer from scoped_answers where is_correct is true) as "correctAnswers", + (select count(*)::integer from scoped_answers where is_correct is false) as "wrongAnswers", + (select count(distinct user_id)::integer from scoped_answers) as "activeStudents", + (select count(distinct practice_session_id)::integer from scoped_answers where practice_session_id is not null) as "activeSessions", + (select count(*)::integer from scoped_reports) as reports, + (select coalesce(avg(score), 0)::numeric from scoped_reports) as "averageScore", + (select coalesce(avg(accuracy), 0)::numeric from scoped_reports) as "averageAccuracy" + `, + baseParams, + ), + queryOne( + ` + with scoped_codes as ( + select ac.*, coalesce(ac.used_region_id, cb.region_id) as scope_region_id + from public.activation_codes ac + left join public.code_batches cb on cb.tenant_id = ac.tenant_id and cb.id = ac.batch_id + where ac.tenant_id = $1 + and ($4::uuid is null or coalesce(ac.used_region_id, cb.region_id) = $4::uuid) + ) + select + count(*)::integer as total, + count(*) filter (where is_used is true)::integer as used, + count(*) filter (where is_used is false)::integer as unused, + count(*) filter ( + where created_at >= $2::timestamptz and created_at < $3::timestamptz + )::integer as "generatedInRange", + count(*) filter ( + where is_used is true and used_at >= $2::timestamptz and used_at < $3::timestamptz + )::integer as "usedInRange", + coalesce(sum(unit_price_cents) filter (where is_used is true), 0)::bigint as "estimatedRevenueCents" + from scoped_codes + `, + baseParams, + ), + queryOne( + ` + with scoped_reports as ( + select r.* + 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.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.student_profiles sp on sp.tenant_id = r.tenant_id and sp.user_id = r.user_id + where r.tenant_id = $1 + and ($4::uuid is null or coalesce(qb.region_id, s.region_id, sp.region_id) = $4::uuid) + ) + select + count(*)::integer as total, + count(*) filter (where created_at >= $2::timestamptz and created_at < $3::timestamptz)::integer as "newInRange", + count(*) filter (where status = 'pending')::integer as pending, + count(*) filter (where status in ('accepted', 'resolved'))::integer as handled, + count(*) filter (where status in ('resolved', 'closed'))::integer as resolved, + count(*) filter (where priority in ('high', 'urgent'))::integer as "highPriority" + from scoped_reports + `, + baseParams, + ), + query( + ` + select to_char(timezone('Asia/Shanghai', tm.created_at), 'YYYY-MM-DD') as date, + count(distinct tm.user_id)::integer as "newStudents" + from public.tenant_memberships tm + left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id + where tm.tenant_id = $1 + and tm.role = 'student' + and tm.status = 'active' + and tm.created_at >= $2::timestamptz + and tm.created_at < $3::timestamptz + and ($4::uuid is null or sp.region_id = $4::uuid) + group by date + order by date asc + `, + baseParams, + ), + query( + ` + select to_char(timezone('Asia/Shanghai', o.created_at), 'YYYY-MM-DD') as date, + count(*)::integer as "orderCount" + from public.orders o + where o.tenant_id = $1 + and o.created_at >= $2::timestamptz + and o.created_at < $3::timestamptz + and ($4::uuid is null or o.region_id = $4::uuid) + group by date + order by date asc + `, + baseParams, + ), + query( + ` + select to_char(timezone('Asia/Shanghai', coalesce(o.paid_at, o.updated_at, o.created_at)), 'YYYY-MM-DD') as date, + count(*)::integer as "paidOrderCount", + coalesce(sum(o.amount_cents), 0)::bigint as "revenueCents" + from public.orders o + where o.tenant_id = $1 + and o.status = 'paid' + and coalesce(o.paid_at, o.updated_at, o.created_at) >= $2::timestamptz + and coalesce(o.paid_at, o.updated_at, o.created_at) < $3::timestamptz + and ($4::uuid is null or o.region_id = $4::uuid) + group by date + order by date asc + `, + baseParams, + ), + query( + ` + select to_char(timezone('Asia/Shanghai', ar.answered_at), 'YYYY-MM-DD') as date, + count(*)::integer as answers, + count(*) filter (where ar.is_correct is true)::integer as "correctAnswers", + count(distinct ar.user_id)::integer as "activeStudents" + from public.answer_records ar + left join public.questions q on q.tenant_id = ar.tenant_id and q.id = ar.question_id + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.student_profiles sp on sp.tenant_id = ar.tenant_id and sp.user_id = ar.user_id + where ar.tenant_id = $1 + and ar.answered_at >= $2::timestamptz + and ar.answered_at < $3::timestamptz + and ($4::uuid is null or coalesce(qb.region_id, s.region_id, sp.region_id) = $4::uuid) + group by date + order by date asc + `, + baseParams, + ), + query( + ` + select extract(hour from timezone('Asia/Shanghai', ar.answered_at))::integer as hour, + count(*)::integer as answers, + count(distinct ar.user_id)::integer as "activeStudents" + from public.answer_records ar + left join public.questions q on q.tenant_id = ar.tenant_id and q.id = ar.question_id + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.student_profiles sp on sp.tenant_id = ar.tenant_id and sp.user_id = ar.user_id + where ar.tenant_id = $1 + and ar.answered_at >= $2::timestamptz + and ar.answered_at < $3::timestamptz + and ($4::uuid is null or coalesce(qb.region_id, s.region_id, sp.region_id) = $4::uuid) + group by hour + order by hour asc + `, + baseParams, + ), + query( + ` + select coalesce(q.type, 'unknown') as type, + coalesce(max(q.type_label), q.type, '未知题型') as label, + count(*)::integer as "questionCount" + from public.questions q + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.content_entries ce on ce.tenant_id = q.tenant_id and ce.id = q.entry_id + where q.tenant_id = $1 + and q.status = 'published' + and ($2::uuid is null or coalesce(qb.region_id, s.region_id, ce.region_id) = $2::uuid) + group by q.type + order by "questionCount" desc, type asc + limit $3 + `, + scopedListParams, + ), + query( + ` + with question_counts as ( + select q.subject_id, count(*)::integer as question_count + from public.questions q + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + where q.tenant_id = $1 and q.status = 'published' + and ($4::uuid is null or qb.region_id = $4::uuid or q.subject_id in ( + select id from public.subjects where tenant_id = $1 and region_id = $4::uuid + )) + group by q.subject_id + ), + answer_counts as ( + select q.subject_id, + count(ar.id)::integer as answer_count, + count(ar.id) filter (where ar.is_correct is true)::integer as correct_count + from public.answer_records ar + join public.questions q on q.tenant_id = ar.tenant_id and q.id = ar.question_id + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + where ar.tenant_id = $1 + and ar.answered_at >= $2::timestamptz + and ar.answered_at < $3::timestamptz + and ($4::uuid is null or qb.region_id = $4::uuid or q.subject_id in ( + select id from public.subjects where tenant_id = $1 and region_id = $4::uuid + )) + group by q.subject_id + ) + select s.id, s.name, s.type, + coalesce(qc.question_count, 0)::integer as "questionCount", + coalesce(ac.answer_count, 0)::integer as "answerCount", + coalesce(ac.correct_count, 0)::integer as "correctCount" + from public.subjects s + left join question_counts qc on qc.subject_id = s.id + left join answer_counts ac on ac.subject_id = s.id + where s.tenant_id = $1 + and s.is_active = true + and ($4::uuid is null or s.region_id = $4::uuid) + order by coalesce(qc.question_count, 0) desc, coalesce(ac.answer_count, 0) desc, s.sort_order asc + limit $5 + `, + listParams, + ), + query( + ` + with region_base as ( + select r.id, r.name, r.code, r.sort_order + from public.regions r + where r.tenant_id = $1 + and r.is_active = true + and ($4::uuid is null or r.id = $4::uuid) + ), + students as ( + select sp.region_id, count(distinct sp.user_id)::integer as student_count + from public.student_profiles sp + join public.tenant_memberships tm + on tm.tenant_id = sp.tenant_id and tm.user_id = sp.user_id and tm.role = 'student' and tm.status = 'active' + where sp.tenant_id = $1 and sp.region_id is not null + group by sp.region_id + ), + questions as ( + select coalesce(qb.region_id, s.region_id) as region_id, count(distinct q.id)::integer as question_count + from public.questions q + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + where q.tenant_id = $1 and q.status = 'published' + group by coalesce(qb.region_id, s.region_id) + ), + answers as ( + select coalesce(qb.region_id, s.region_id, sp.region_id) as region_id, + count(ar.id)::integer as answer_count, + count(distinct ar.user_id)::integer as active_students + from public.answer_records ar + left join public.questions q on q.tenant_id = ar.tenant_id and q.id = ar.question_id + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.student_profiles sp on sp.tenant_id = ar.tenant_id and sp.user_id = ar.user_id + where ar.tenant_id = $1 + and ar.answered_at >= $2::timestamptz + and ar.answered_at < $3::timestamptz + group by coalesce(qb.region_id, s.region_id, sp.region_id) + ), + revenue as ( + select o.region_id, + count(*) filter (where o.status = 'paid')::integer as paid_orders, + coalesce(sum(o.amount_cents) filter (where o.status = 'paid'), 0)::bigint as revenue_cents + from public.orders o + where o.tenant_id = $1 + and o.region_id is not null + and coalesce(o.paid_at, o.updated_at, o.created_at) >= $2::timestamptz + and coalesce(o.paid_at, o.updated_at, o.created_at) < $3::timestamptz + group by o.region_id + ) + select rb.id, rb.name, rb.code, + coalesce(st.student_count, 0)::integer as "studentCount", + coalesce(q.question_count, 0)::integer as "questionCount", + coalesce(a.answer_count, 0)::integer as "answerCount", + coalesce(a.active_students, 0)::integer as "activeStudents", + coalesce(r.paid_orders, 0)::integer as "paidOrders", + coalesce(r.revenue_cents, 0)::bigint as "revenueCents" + from region_base rb + left join students st on st.region_id = rb.id + left join questions q on q.region_id = rb.id + left join answers a on a.region_id = rb.id + left join revenue r on r.region_id = rb.id + order by rb.sort_order asc, rb.name asc + limit $5 + `, + listParams, + ), + query( + ` + select o.plan_id as "planId", + coalesce(p.name, o.product_name, '未归类套餐') as name, + count(*)::integer as "paidOrders", + coalesce(sum(o.amount_cents), 0)::bigint as "revenueCents" + from public.orders o + left join public.svip_plans p on p.tenant_id = o.tenant_id and p.id = o.plan_id + where o.tenant_id = $1 + and o.status = 'paid' + and coalesce(o.paid_at, o.updated_at, o.created_at) >= $2::timestamptz + and coalesce(o.paid_at, o.updated_at, o.created_at) < $3::timestamptz + and ($4::uuid is null or o.region_id = $4::uuid) + group by o.plan_id, p.name, o.product_name + order by "revenueCents" desc, "paidOrders" desc + limit $5 + `, + listParams, + ), + query( + ` + select activity_type as "activityType", title, occurred_at as "occurredAt", details + from ( + select 'student_registered'::text as activity_type, + '新学生注册'::text as title, + tm.created_at as occurred_at, + jsonb_build_object('userId', tm.user_id, 'name', coalesce(u.name, u.username, '学生')) as details + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id + where tm.tenant_id = $1 + and tm.role = 'student' + and tm.status = 'active' + and tm.created_at >= $2::timestamptz + and tm.created_at < $3::timestamptz + and ($4::uuid is null or sp.region_id = $4::uuid) + union all + select 'order_paid'::text as activity_type, + coalesce(o.product_name, '订单支付') as title, + coalesce(o.paid_at, o.updated_at, o.created_at) as occurred_at, + jsonb_build_object('orderNo', o.order_no, 'status', o.status, 'amountCents', o.amount_cents) as details + from public.orders o + where o.tenant_id = $1 + and o.status = 'paid' + and coalesce(o.paid_at, o.updated_at, o.created_at) >= $2::timestamptz + and coalesce(o.paid_at, o.updated_at, o.created_at) < $3::timestamptz + and ($4::uuid is null or o.region_id = $4::uuid) + union all + select 'feedback_created'::text as activity_type, + coalesce(r.title, '学生反馈') as title, + r.created_at as occurred_at, + jsonb_build_object('reportId', r.id, 'status', r.status, 'type', r.type, 'priority', r.priority) as details + 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.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.student_profiles sp on sp.tenant_id = r.tenant_id and sp.user_id = r.user_id + where r.tenant_id = $1 + and r.created_at >= $2::timestamptz + and r.created_at < $3::timestamptz + and ($4::uuid is null or coalesce(qb.region_id, s.region_id, sp.region_id) = $4::uuid) + union all + select 'content_import'::text as activity_type, + coalesce(cij.source_name, cij.import_type || ' 导入') as title, + cij.created_at as occurred_at, + jsonb_build_object('jobId', cij.id, 'importType', cij.import_type, 'status', cij.status, 'totalCount', cij.total_count) as details + from public.content_import_jobs cij + where cij.tenant_id = $1 + and cij.created_at >= $2::timestamptz + and cij.created_at < $3::timestamptz + and ($4::uuid is null or cij.target_region_id = $4::uuid or cij.target_region_id is null) + ) activities + order by occurred_at desc + limit $5 + `, + listParams, + ), + ]); + + const students = studentTotals || {}; + const orders = orderTotals || {}; + const content = contentTotals || {}; + const learning = learningTotals || {}; + const codes = codeTotals || {}; + const feedback = feedbackTotals || {}; + + const answers = intValue(learning.answers); + const correctAnswers = intValue(learning.correctAnswers); + + const trends = emptyTrendSeries(range.startDate, range.days); + applyTrendRows(trends, studentTrend, (target, row) => { + target.newStudents = intValue(row.newStudents); + }); + applyTrendRows(trends, orderTrend, (target, row) => { + target.orderCount = intValue(row.orderCount); + }); + applyTrendRows(trends, revenueTrend, (target, row) => { + target.paidOrderCount = intValue(row.paidOrderCount); + target.revenueCents = moneyValue(row.revenueCents); + }); + applyTrendRows(trends, answerTrend, (target, row) => { + target.answers = intValue(row.answers); + target.correctAnswers = intValue(row.correctAnswers); + target.activeStudents = intValue(row.activeStudents); + }); + + const activeHourMap = new Map(activeHoursRows.map(row => [intValue(row.hour), row])); + const activeHours = Array.from({ length: 24 }, (_, hour) => { + const row = activeHourMap.get(hour) || {}; + return { + hour, + answers: intValue(row.answers), + activeStudents: intValue(row.activeStudents), + }; + }); + + return { + item: { + scope: { + tenantId: auth.tenantId, + regionId, + regionName: region?.name || null, + timeRange: range.timeRange, + days: range.days, + startDate: range.startDate, + endDate: range.endDate, + timezone: 'Asia/Shanghai', + }, + cards: { + students: { + total: intValue(students.totalStudents), + newInRange: intValue(students.newStudents), + active7d: intValue(students.activeStudents7d), + svip: intValue(students.svipStudents), + videoMembers: intValue(students.videoMembers), + }, + learning: { + answers, + correctAnswers, + wrongAnswers: intValue(learning.wrongAnswers), + accuracy: ratio(correctAnswers, answers), + activeStudents: intValue(learning.activeStudents), + activeSessions: intValue(learning.activeSessions), + reports: intValue(learning.reports), + averageScore: Number(numberValue(learning.averageScore).toFixed(2)), + averageAccuracy: Number(numberValue(learning.averageAccuracy).toFixed(4)), + }, + content: { + regions: intValue(content.regions), + subjects: intValue(content.subjects), + contentEntries: intValue(content.contentEntries), + contentNodes: intValue(content.contentNodes), + questionBanks: intValue(content.questionBanks), + questionCollections: intValue(content.questionCollections), + practiceBlueprints: intValue(content.practiceBlueprints), + questions: intValue(content.questions), + questionVideos: intValue(content.questionVideos), + assets: intValue(content.assets), + vocabularyUnits: intValue(content.vocabularyUnits), + vocabularyWords: intValue(content.vocabularyWords), + handbookSubjects: intValue(content.handbookSubjects), + handbookEntries: intValue(content.handbookEntries), + }, + activationCodes: { + total: intValue(codes.total), + used: intValue(codes.used), + unused: intValue(codes.unused), + generatedInRange: intValue(codes.generatedInRange), + usedInRange: intValue(codes.usedInRange), + estimatedRevenueCents: moneyValue(codes.estimatedRevenueCents), + usageRate: ratio(intValue(codes.used), intValue(codes.total)), + }, + feedback: { + total: intValue(feedback.total), + newInRange: intValue(feedback.newInRange), + pending: intValue(feedback.pending), + handled: intValue(feedback.handled), + resolved: intValue(feedback.resolved), + highPriority: intValue(feedback.highPriority), + }, + }, + paymentStats: { + totalOrders: intValue(orders.totalOrders), + ordersInRange: intValue(orders.ordersInRange), + paidOrders: intValue(orders.paidOrders), + paidOrdersInRange: intValue(orders.paidOrdersInRange), + pendingOrders: intValue(orders.pendingOrders), + failedOrders: intValue(orders.failedOrders), + refundedOrders: intValue(orders.refundedOrders), + revenueCents: moneyValue(orders.revenueCents), + revenueCentsInRange: moneyValue(orders.revenueCentsInRange), + averagePaidAmountCents: moneyValue(orders.averagePaidAmountCents), + }, + trends, + activeHours, + questionDistribution: questionDistributionRows.map(row => ({ + type: String(row.type || 'unknown'), + label: String(row.label || row.type || '未知题型'), + questionCount: intValue(row.questionCount), + })), + subjectTop: subjectTopRows.map(row => ({ + id: String(row.id), + name: String(row.name), + type: row.type ? String(row.type) : null, + questionCount: intValue(row.questionCount), + answerCount: intValue(row.answerCount), + correctCount: intValue(row.correctCount), + accuracy: ratio(intValue(row.correctCount), intValue(row.answerCount)), + })), + regionStats: regionStatsRows.map(row => ({ + id: String(row.id), + name: String(row.name), + code: row.code ? String(row.code) : null, + studentCount: intValue(row.studentCount), + questionCount: intValue(row.questionCount), + answerCount: intValue(row.answerCount), + activeStudents: intValue(row.activeStudents), + paidOrders: intValue(row.paidOrders), + revenueCents: moneyValue(row.revenueCents), + })), + planSales: planSalesRows.map(row => ({ + planId: row.planId ? String(row.planId) : null, + name: String(row.name || '未归类套餐'), + paidOrders: intValue(row.paidOrders), + revenueCents: moneyValue(row.revenueCents), + })), + recentActivities: recentActivitiesRows.map(row => ({ + activityType: String(row.activityType), + title: String(row.title || ''), + occurredAt: row.occurredAt instanceof Date ? row.occurredAt.toISOString() : String(row.occurredAt), + details: row.details || {}, + })), + generatedAt: new Date().toISOString(), + }, + }; +} diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 572723ab..d79b5fe6 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -17,6 +17,7 @@ import { upsertTenantClassRoute, upsertTenantStudentRoute, } from './classes.js'; +import { tenantDashboardRoute } from './dashboard.js'; import { tenantExamDatesRoute, tenantFeedbackEventsRoute, @@ -81,6 +82,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['PUT', '/api/tenant-admin/students/followups', upsertTenantStudentFollowupRoute], ['GET', '/api/tenant-admin/teachers', tenantTeachersRoute], ['GET', '/api/tenant-admin/overview', tenantOverviewRoute], + ['GET', '/api/tenant-admin/dashboard', tenantDashboardRoute], ['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute], ['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute], ['GET', '/api/tenant-admin/domains', tenantDomainsRoute], diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 2cae0bf8..6e70ecf3 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -126,7 +126,7 @@ | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 | | 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` | -| 数据看板聚合接口 | 待补齐 | 表基础已有,缺完整 dashboard API | +| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 | | 租户采纳公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习 | diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index afec58ff..114553cc 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -45,7 +45,7 @@ | 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已实现;批量 CRM 推送、补绑、学习督导自动化待补 | | 销售/代理管理 | 部分覆盖 | referral/team/stats 有;缺分佣比例、结算单、审核、导出 | | 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 | -| 数据看板 | 部分覆盖 | 表基础有;缺收益、注册、答题、活跃、套餐销量等聚合 API | +| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 | | 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 | | 品牌配置 | 已覆盖 | 需要前端做预览和主题发布体验 | | 自定义域名 | 已覆盖 | 生产需补 DNS 校验、证书状态、回源校验 | @@ -117,7 +117,7 @@ 1. 微信/支付宝支付和 webhook 幂等。 2. 对象存储 PDF 预览、视频深度防盗链、动态水印。 3. Excel/CSV、分数线、视频批量导入。 -4. 数据看板和销售/代理分佣结算。 +4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。 5. 公共题库版本同步、租户采纳后的更新策略和同步 worker。 ### P2:增强体验 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index fbea6aa9..4c11a027 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -22,6 +22,7 @@ - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 - 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session。 +- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。 - 本地验证:`npm run check:refactor` 已通过。 当前更适合进入前端联调前阅读的总览文档: @@ -98,9 +99,9 @@ - 继续补积分兑换、活动任务、连续签到奖励配置、处理通知和反馈聚合统计。 9. 数据看板 - - 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。 - - 套餐销量、运营动态、24h 活跃度、激活码使用情况。 - - 销售/代理转化、分佣结算、客资跟进效果。 + - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 + - 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。 + - 继续补销售/代理转化、分佣结算、客资跟进效果看板。 10. 学生运营管理 - 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 011dae3c..e94b8803 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -51,7 +51,7 @@ F:\project\参考\旧题库项目\src - 题库练习、答题、错题、收藏。 - 订单、支付、激活码、优惠券、权益。 - 私有 PDF、资料、视频、对象存储签名。 -- 租户后台、平台后台、内容导入、CRM、销售/代理。 +- 租户后台、平台后台、内容导入、CRM、销售/代理、数据看板。 前端应封装一个统一 API client,所有页面禁止直接散写 `Taro.request`。 @@ -173,6 +173,7 @@ tenant::theme | 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` | | 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns` | | 销售分享 | `/api/referral/resolve`、`track-event`、`bind` | +| 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d®ionId=...` | | 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` | | 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove`、`POST /api/tenant-admin/classes/members/bulk-assign` | | 租户学生 | `GET/PUT /api/tenant-admin/students`、`POST /api/tenant-admin/students/bulk-upsert`、`POST /api/tenant-admin/students/status` | @@ -338,6 +339,62 @@ tenant::theme 响应会包含 `items` 和 `currentUser`。即使当前用户未进入前 N 名,也应优先展示 `currentUser` 作为“我的排名”。后台后续会补日/周榜预聚合和防刷策略,前端只消费接口返回口径。 +### 租户数据看板 + +租户后台数据看板由后端统一聚合,前端不要直接读取订单、答题记录、学生列表后自行统计,避免权限越权、敏感信息外泄和各页面口径不一致。 + +请求: + +```http +GET /api/tenant-admin/dashboard?timeRange=30d®ionId=<可选地区ID>&limit=10 +``` + +可选参数: + +| 参数 | 可选值 | 说明 | +| --- | --- | --- | +| `timeRange` | `7d`、`30d`、`90d` | 统计区间,默认 `30d` | +| `regionId` | UUID | 可选地区筛选,后端会校验地区属于当前租户 | +| `limit` | 1-50 | 题型、科目、地区、套餐和运营动态的返回条数 | + +响应主要结构: + +```json +{ + "item": { + "scope": { + "tenantId": "...", + "regionId": "...", + "timeRange": "30d", + "timezone": "Asia/Shanghai" + }, + "cards": { + "students": {}, + "learning": {}, + "content": {}, + "activationCodes": {}, + "feedback": {} + }, + "paymentStats": {}, + "trends": [], + "activeHours": [], + "questionDistribution": [], + "subjectTop": [], + "regionStats": [], + "planSales": [], + "recentActivities": [] + } +} +``` + +前端处理规则: + +- 管理台菜单显示可按 `/api/tenant-admin/permissions` 的 `dashboard:read` 判断,但真正权限以后端返回为准。 +- `trends` 已补齐自然日桶,`activeHours` 固定 24 项,前端不需要补点。 +- `revenueCents`、`amountCents` 都是分,前端统一格式化成人民币展示,不要自行重算订单金额。 +- `recentActivities.details` 只包含可展示的低敏汇总信息,不包含手机号、支付密钥、对象存储 key 等敏感字段。 +- 大租户正式上线后会补预聚合 worker,前端不应依赖任何临时 SQL 口径或自己维护缓存口径。 + ### 背单词计划与复习上报 背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。 @@ -829,6 +886,7 @@ ACTIVATION_CODE_REGION_MISMATCH 租户后台可以先做 H5 管理台,也可以后续使用 Taro H5 复用部分组件。优先页面: - 概览:`/api/tenant-admin/overview` +- 数据看板:`/api/tenant-admin/dashboard`,展示收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 - 品牌/主题/域名/公开设置 - 支付账户/登录 provider/密钥引用 - 用户与成员权限 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index ff183cfe..b5e87e43 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -2599,6 +2599,42 @@ async function testTenantAdminOps() { }); assert.equal(overview.item?.id, MAIN_TENANT_ID, 'tenant admin overview should belong to main tenant'); + const dashboardDenied = await request('/api/tenant-admin/dashboard', { + expectStatus: 403, + }); + assert.equal(dashboardDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant dashboard'); + + const dashboard = await request('/api/tenant-admin/dashboard', { + userId: TENANT_ADMIN_USER_ID, + query: { timeRange: '7d', regionId: ids.region }, + }); + assert.equal(dashboard.item?.scope?.tenantId, MAIN_TENANT_ID, 'tenant dashboard should be tenant scoped'); + assert.equal(dashboard.item?.scope?.regionId, ids.region, 'tenant dashboard should support region filter'); + assert.equal(dashboard.item?.cards?.content?.questions >= 3, true, 'tenant dashboard should count published questions'); + assert.equal(dashboard.item?.cards?.learning?.answers >= 1, true, 'tenant dashboard should aggregate answers'); + assert.equal(dashboard.item?.trends?.length, 7, 'tenant dashboard should return complete 7-day trend buckets'); + assert.equal(dashboard.item?.activeHours?.length, 24, 'tenant dashboard should return 24 hourly activity buckets'); + assert.ok( + dashboard.item?.questionDistribution?.some(item => item.type === 'choice'), + 'tenant dashboard should include question type distribution', + ); + assert.equal(typeof dashboard.item?.paymentStats?.revenueCentsInRange, 'number', 'tenant dashboard should expose payment stats'); + assert.ok(Array.isArray(dashboard.item?.recentActivities), 'tenant dashboard should expose recent activities'); + + const invalidDashboardRange = await request('/api/tenant-admin/dashboard', { + userId: TENANT_ADMIN_USER_ID, + query: { timeRange: '365d' }, + expectStatus: 400, + }); + assert.equal(invalidDashboardRange.code, 'INVALID_DASHBOARD_RANGE', 'tenant dashboard should reject unsupported time ranges'); + + const partnerDashboardDenied = await request('/api/tenant-admin/dashboard', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + expectStatus: 403, + }); + assert.equal(partnerDashboardDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant dashboard must be tenant isolated'); + const branding = await request('/api/tenant-admin/branding', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', @@ -2928,8 +2964,10 @@ async function testTenantMemberPermissionsAndAudit() { userId: TENANT_ADMIN_USER_ID, }); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission'); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission'); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission'); assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups'); + assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults'); const roleTemplate = await request('/api/tenant-admin/role-templates', { @@ -3018,6 +3056,12 @@ async function testTenantMemberPermissionsAndAudit() { assert.equal(operatorPermissionMatrix.current?.roleTemplateCode, 'ops-marketing', 'current permission matrix should include role template'); assert.equal(operatorPermissionMatrix.current?.menuPermissions?.marketing, true, 'current permission matrix should expose menu permissions'); + const operatorDashboard = await request('/api/tenant-admin/dashboard', { + userId: TENANT_OPERATOR_USER_ID, + query: { timeRange: '7d' }, + }); + assert.equal(operatorDashboard.item?.scope?.tenantId, MAIN_TENANT_ID, 'tenant operator should read dashboard through default permission'); + const operatorBanner = await request('/api/tenant-admin/banners', { userId: TENANT_OPERATOR_USER_ID, method: 'PUT',