From 6370e392cc8dd44cf1850e8328a3df74ad53702b Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sat, 30 May 2026 08:03:14 +0200 Subject: [PATCH] feat(reports): partner and platform analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partner reports — health cohorts, revenue-by-plan, top customers, signup/churn cohorts, plus saved custom reports (create/list/delete). Operator platform-wide reports (MRR, revenue by plan, top tenants, growth). Replaces the reports fixtures in both apps. --- apps/operator/pages/reports.vue | 153 ++++++++- .../server/api/reports/platform.get.ts | 4 + .../partner/NewCustomReportModal.vue | 21 +- apps/portal/pages/partner/reports.vue | 317 +++++++++++++----- apps/portal/server/api/partner/reports.get.ts | 20 ++ .../server/api/partner/reports/saved.get.ts | 20 ++ .../server/api/partner/reports/saved.post.ts | 24 ++ .../api/partner/reports/saved/[id].delete.ts | 23 ++ .../src/me/dto/create-report.dto.ts | 15 + .../src/me/partner-reports.service.ts | 69 ++++ .../platform-api/src/schemas/report.schema.ts | 34 ++ .../src/users/platform-reports.controller.ts | 16 + .../platform-api/src/users/users.module.ts | 3 +- 13 files changed, 633 insertions(+), 86 deletions(-) create mode 100644 apps/operator/server/api/reports/platform.get.ts create mode 100644 apps/portal/server/api/partner/reports.get.ts create mode 100644 apps/portal/server/api/partner/reports/saved.get.ts create mode 100644 apps/portal/server/api/partner/reports/saved.post.ts create mode 100644 apps/portal/server/api/partner/reports/saved/[id].delete.ts create mode 100644 services/platform-api/src/me/dto/create-report.dto.ts create mode 100644 services/platform-api/src/me/partner-reports.service.ts create mode 100644 services/platform-api/src/schemas/report.schema.ts create mode 100644 services/platform-api/src/users/platform-reports.controller.ts diff --git a/apps/operator/pages/reports.vue b/apps/operator/pages/reports.vue index 2346fef..cd87d32 100644 --- a/apps/operator/pages/reports.vue +++ b/apps/operator/pages/reports.vue @@ -1,10 +1,149 @@ - + + + diff --git a/apps/operator/server/api/reports/platform.get.ts b/apps/operator/server/api/reports/platform.get.ts new file mode 100644 index 0000000..69d1355 --- /dev/null +++ b/apps/operator/server/api/reports/platform.get.ts @@ -0,0 +1,4 @@ +import { platformApi } from '~~/server/utils/platform-api' + +// Platform-wide analytics for the operator reports page. +export default defineEventHandler(async (event) => platformApi(event, '/reports/platform')) diff --git a/apps/portal/components/partner/NewCustomReportModal.vue b/apps/portal/components/partner/NewCustomReportModal.vue index 5f04aa5..7ea3682 100644 --- a/apps/portal/components/partner/NewCustomReportModal.vue +++ b/apps/portal/components/partner/NewCustomReportModal.vue @@ -4,7 +4,22 @@ // recipients + format + live summary. defineProps<{ open: boolean }>() -const emit = defineEmits<{ close: []; created: [name: string] }>() +const emit = defineEmits<{ + close: [] + created: [ + payload: { + name: string + description: string + metrics: string[] + filterPlan: string + filterStatus: string + groupBy: string + schedule: string + recipients: string[] + format: string + }, + ] +}>() const METRICS = [ { id: 'mrr', label: 'MRR', group: 'Revenue' }, @@ -42,7 +57,7 @@ const grouped = computed(() => { const out: Record = {} for (const m of METRICS) { out[m.group] = out[m.group] || [] - out[m.group].push(m) + out[m.group]!.push(m) } return out }) @@ -184,7 +199,7 @@ function toggle(id: string) { Create report diff --git a/apps/portal/pages/partner/reports.vue b/apps/portal/pages/partner/reports.vue index 67cf7ba..75605c6 100644 --- a/apps/portal/pages/partner/reports.vue +++ b/apps/portal/pages/partner/reports.vue @@ -8,21 +8,77 @@ -import { customers, partnerMrrSparkline } from '~/data/customers' -import type { CustomerOrg } from '~/data/customers' +// Decorative MRR sparkline shape only — historical MRR isn't stored yet (a +// daily-snapshot job lands later). The live numbers below are all real. +import { partnerMrrSparkline } from '~/data/customers' +import type { CustomerOrg, CustomerStatus } from '~/types/partner' import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue' const toast = useToast() +// ── Real data sources ───────────────────────────────────────────────────── +const { tenants } = usePartnerTenants() +const { mrrByTenant } = usePartnerMrr() + +interface ReportsData { + health: { healthy: number; watch: number; atRisk: number; total: number; avgScore: number } + revenueByPlan: Array<{ plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }> + topCustomers: Array<{ tenantId: string; tenantName: string; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; custom: boolean }> + churnCohorts: Array<{ month: string; total: number; retained: number; retentionPct: number }> + totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }> + marginPct: number +} +const { data: reports } = useFetch('/api/partner/reports', { + key: 'partner-reports', + default: () => ({ + health: { healthy: 0, watch: 0, atRisk: 0, total: 0, avgScore: 0 }, + revenueByPlan: [], + topCustomers: [], + churnCohorts: [], + totals: [], + marginPct: 0, + }), +}) + +interface SavedReport { + _id: string + name: string + kind: string + description?: string + definition?: Record + createdByEmail?: string + createdAt?: string +} +const { data: savedRaw, refresh: refreshSaved } = useFetch( + '/api/partner/reports/saved', + { key: 'partner-reports-saved', default: () => [] }, +) + +const PLAN_INFO: Record<'mvp' | 'pro' | 'enterprise', { slug: CustomerOrg['plan']; label: CustomerOrg['planLabel'] }> = { + mvp: { slug: 'starter', label: 'Starter' }, + pro: { slug: 'business', label: 'Business' }, + enterprise: { slug: 'enterprise', label: 'Enterprise' }, +} +const PLAN_COLOR: Record<'mvp' | 'pro' | 'enterprise', string> = { + enterprise: 'var(--text)', + pro: 'var(--info)', + mvp: 'var(--text-mute)', +} +function mapStatus(s: 'active' | 'pending' | 'suspended' | 'deleted'): CustomerStatus { + if (s === 'active') return 'healthy' + if (s === 'pending') return 'trial' + return 'suspended' +} + const tab = ref<'health' | 'revenue' | 'churn' | 'custom'>('health') const period = ref<'30d' | '90d' | '12mo' | 'ytd'>('90d') -const tabs = [ +const tabs = computed(() => [ { value: 'health', label: 'Customer health' }, { value: 'revenue', label: 'Revenue' }, { value: 'churn', label: 'Churn' }, - { value: 'custom', label: 'Custom reports', count: 3 }, -] + { value: 'custom', label: 'Custom reports', count: savedRaw.value?.length ?? 0 }, +]) const periodOpts = [ { value: '30d', label: '30 days' }, @@ -35,25 +91,46 @@ const exportOpen = ref(false) const newReportOpen = ref(false) // HEALTH ───────────────────────────────────────────────────────────────────── -// Health scoring exactly mirrors platform-partner-depth.jsx:73-80. -const scored = computed(() => customers.map((c) => { - let score = 100 - if (c.status === 'past_due') score -= 50 - else if (c.status === 'attention') score -= 30 - else if (c.status === 'trial') score -= 10 - if (c.seats.used / c.seats.total > 0.85) score -= 10 - return { ...c, score } -})) +// Per-customer rows from real tenants (server-computed healthScore), shaped as +// CustomerOrg so the table + task panel consume them unchanged. +const scored = computed>(() => + (tenants.value ?? []) + .filter((t) => t.status !== 'deleted') + .map((t) => { + const info = PLAN_INFO[t.plan ?? 'pro'] + const sub = mrrByTenant.value.get(t._id) + const score = t.healthScore ?? 100 + return { + id: t._id, + name: t.name, + domain: t.domains?.[0] ?? `${t.slug}.dezky.com`, + plan: info.slug, + planLabel: info.label, + seats: { used: t.userCount ?? 0, total: t.seats ?? 0 }, + health: score, + score, + status: mapStatus(t.status), + mrrDkk: sub ? Math.round(sub.monthlyMinor / 100) : 0, + brandColor: t.brandColor || '#3F6BFF', + industry: t.industry ?? '—', + createdOn: t.createdAt ?? '', + since: t.createdAt ?? '', + } + }), +) +// Cohort counts + average from the server reports endpoint (single source of +// truth for the health buckets). const cohort = computed(() => ({ - healthy: scored.value.filter((c) => c.score >= 75).length, - watch: scored.value.filter((c) => c.score >= 50 && c.score < 75).length, - risk: scored.value.filter((c) => c.score < 50).length, + healthy: reports.value?.health.healthy ?? 0, + watch: reports.value?.health.watch ?? 0, + risk: reports.value?.health.atRisk ?? 0, })) +const avgHealth = computed(() => reports.value?.health.avgScore ?? 0) function healthColor(h: number) { - if (h >= 75) return 'var(--ok)' - if (h >= 50) return 'var(--warn)' + if (h >= 80) return 'var(--ok)' + if (h >= 60) return 'var(--warn)' return 'var(--bad)' } @@ -68,35 +145,77 @@ function miniTrend(seed: number) { } // REVENUE ──────────────────────────────────────────────────────────────────── -const totalMrr = computed(() => customers.reduce((s, c) => s + c.mrrDkk, 0)) +// Totals summed across currencies into one headline figure (internal view — +// per-currency totals are in reports.totals). Margin/ARR/ARPU derive from MRR. +const totalMrr = computed(() => + Math.round((reports.value?.totals ?? []).reduce((s, t) => s + t.monthlyMinor, 0) / 100), +) +const custCount = computed(() => reports.value?.health.total ?? 0) +const marginMrr = computed(() => Math.round((totalMrr.value * (reports.value?.marginPct ?? 0)) / 100)) +const arr = computed(() => totalMrr.value * 12) +const arpu = computed(() => (custCount.value ? Math.round(totalMrr.value / custCount.value) : 0)) -// Top 5 by MRR -const topByMrr = computed(() => [...customers].sort((a, b) => b.mrrDkk - a.mrrDkk).slice(0, 5)) +const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' } +const revenueMix = computed(() => { + const byPlan = new Map<'mvp' | 'pro' | 'enterprise', number>() + for (const r of reports.value?.revenueByPlan ?? []) byPlan.set(r.plan, (byPlan.get(r.plan) ?? 0) + r.monthlyMinor) + const grand = [...byPlan.values()].reduce((a, b) => a + b, 0) || 1 + return [...byPlan.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([plan, minor]) => ({ + n: PLAN_LABEL[plan], + v: Math.round(minor / 100), + p: Math.round((minor / grand) * 100), + c: PLAN_COLOR[plan], + })) +}) -// By-plan revenue mix · platform-partner-depth.jsx:176-180 -const revenueMix = [ - { n: 'Enterprise', v: 42900, p: 77, c: 'var(--text)' }, - { n: 'Business', v: 11340, p: 20, c: 'var(--info)' }, - { n: 'Starter', v: 1510, p: 3, c: 'var(--text-mute)' }, -] +const topByMrr = computed(() => { + const colorOf = (id: string) => (tenants.value ?? []).find((t) => t._id === id)?.brandColor || '#3F6BFF' + return (reports.value?.topCustomers ?? []).slice(0, 5).map((r) => ({ + id: r.tenantId, + name: r.tenantName, + brandColor: colorOf(r.tenantId), + mrrDkk: Math.round(r.monthlyMinor / 100), + })) +}) -// CHURN cohort heatmap · platform-partner-depth.jsx:237-243 -const cohorts: Array<[string, number, Array]> = [ - ['Nov 2024', 1, [100, 100, 100, 100, 100, 100]], - ['Aug 2025', 1, [100, 100, 100, 100, 100, '—']], - ['Sep 2025', 1, [100, 100, 100, 100, 100, '—']], - ['Feb 2026', 3, [100, 100, 100, '—', '—', '—']], - ['Mar 2026', 2, [100, 100, '—', '—', '—', '—']], - ['May 2026', 1, [100, '—', '—', '—', '—', '—']], -] -const cohortHeaders = ['M+0', 'M+1', 'M+2', 'M+3', 'M+6', 'M+12'] +// CHURN — signup-month cohorts with (approximate) current retention. Real +// month-over-month retention needs cancellation dates (Phase 3 billing). +const churnRows = computed(() => + (reports.value?.churnCohorts ?? []).map((c) => ({ + label: new Date(`${c.month}-01`).toLocaleDateString('da-DK', { month: 'short', year: 'numeric' }), + total: c.total, + retained: c.retained, + retentionPct: c.retentionPct, + })), +) +const avgRetention = computed(() => { + const rows = churnRows.value + return rows.length ? Math.round(rows.reduce((s, r) => s + r.retentionPct, 0) / rows.length) : 0 +}) -// CUSTOM REPORTS · platform-partner-depth.jsx:280-283 -const savedReports = ref([ - { id: 'r1', name: 'Quarterly board · Q1 2026', owner: 'Anne Baslund', schedule: 'Quarterly · 1st', last: '03 Apr 2026', recipients: 4, format: 'PDF' }, - { id: 'r2', name: 'Customer Health · weekly digest', owner: 'Anne Baslund', schedule: 'Mondays 09:00 CET', last: '13 May 2026', recipients: 2, format: 'PDF' }, - { id: 'r3', name: 'Margin breakdown by partner cut', owner: 'Mikkel Nørgaard', schedule: 'On-demand', last: '08 May 2026', recipients: 1, format: 'CSV' }, -]) +// CUSTOM REPORTS — real saved definitions from /api/partner/reports/saved. +function fmtDate(iso?: string) { + return iso + ? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' }) + : '—' +} +const savedReports = computed(() => + (savedRaw.value ?? []).map((r) => { + const def = r.definition ?? {} + const recips = (def as { recipients?: unknown[] }).recipients + return { + id: r._id, + name: r.name, + owner: r.createdByEmail || '—', + schedule: String((def as { schedule?: string }).schedule ?? 'On-demand'), + last: fmtDate(r.createdAt), + recipients: Array.isArray(recips) ? recips.length : 0, + format: String((def as { format?: string }).format ?? 'PDF').toUpperCase(), + } + }), +) const running = ref(null) const reportMenuFor = ref(null) @@ -134,13 +253,58 @@ function reportActions(r: typeof savedReports.value[number]) { const confirmDeleteReport = computed(() => savedReports.value.find((r) => r.id === confirmDeleteId.value)) -function deleteReport() { +async function deleteReport() { const r = savedReports.value.find((x) => x.id === confirmDeleteId.value) - if (r) { - savedReports.value = savedReports.value.filter((x) => x.id !== confirmDeleteId.value) - toast.bad('Report deleted', r.name) + if (!r) { + confirmDeleteId.value = null + return + } + try { + await $fetch(`/api/partner/reports/saved/${r.id}`, { method: 'DELETE' }) + toast.bad('Report deleted', r.name) + confirmDeleteId.value = null + await Promise.all([refreshSaved(), refreshNuxtData('partner-reports-saved')]) + } catch (e: unknown) { + const err = e as { data?: { message?: string }; statusMessage?: string } + toast.bad('Delete failed', err.data?.message || err.statusMessage || 'Could not delete report') + } +} + +async function onCreated(payload: { + name: string + description: string + metrics: string[] + filterPlan: string + filterStatus: string + groupBy: string + schedule: string + recipients: string[] + format: string +}) { + try { + await $fetch('/api/partner/reports/saved', { + method: 'POST', + body: { + name: payload.name, + kind: 'custom', + description: payload.description, + definition: { + metrics: payload.metrics, + filterPlan: payload.filterPlan, + filterStatus: payload.filterStatus, + groupBy: payload.groupBy, + schedule: payload.schedule, + recipients: payload.recipients, + format: payload.format, + }, + }, + }) + toast.ok('Report created', payload.name) + await Promise.all([refreshSaved(), refreshNuxtData('partner-reports-saved')]) + } catch (e: unknown) { + const err = e as { data?: { message?: string }; statusMessage?: string } + toast.bad('Create failed', err.data?.message || err.statusMessage || 'Could not create report') } - confirmDeleteId.value = null } function closeMenu() { reportMenuFor.value = null } @@ -191,16 +355,16 @@ onMounted(() => {
- + - + - + - +
@@ -265,16 +429,16 @@ onMounted(() => {
- + - + - + - +
@@ -288,8 +452,8 @@ onMounted(() => {
- Feb 14 · 38.180 DKK - May 14 · 55.750 DKK + 90-day trend · illustrative + {{ totalMrr.toLocaleString('da-DK') }} DKK / mo now
@@ -326,16 +490,16 @@ onMounted(() => {
- + - + - + - +
@@ -351,26 +515,29 @@ onMounted(() => { Cohort - Size - {{ h }} + Customers + Retained + Retention - - {{ c[0] }} - {{ c[1] }} - - + + {{ c.label }} + {{ c.total }} + {{ c.retained }} + {{ v }}% + >{{ c.retentionPct }}% + + // no signup cohorts yet +
@@ -502,7 +669,7 @@ onMounted(() => { diff --git a/apps/portal/server/api/partner/reports.get.ts b/apps/portal/server/api/partner/reports.get.ts new file mode 100644 index 0000000..81f362e --- /dev/null +++ b/apps/portal/server/api/partner/reports.get.ts @@ -0,0 +1,20 @@ +// Partner reports analytics. Forwards to platform-api GET /me/partner/reports. +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) + } + + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + try { + return await $fetch(`${base}/me/partner/reports`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + } catch (err: unknown) { + const e = err as { statusCode?: number; data?: unknown } + throw createError({ statusCode: e.statusCode ?? 500, data: e.data }) + } +}) diff --git a/apps/portal/server/api/partner/reports/saved.get.ts b/apps/portal/server/api/partner/reports/saved.get.ts new file mode 100644 index 0000000..d2fce7e --- /dev/null +++ b/apps/portal/server/api/partner/reports/saved.get.ts @@ -0,0 +1,20 @@ +// Saved partner reports. Forwards to platform-api GET /me/partner/reports/saved. +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) + } + + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + try { + return await $fetch(`${base}/me/partner/reports/saved`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + } catch (err: unknown) { + const e = err as { statusCode?: number; data?: unknown } + throw createError({ statusCode: e.statusCode ?? 500, data: e.data }) + } +}) diff --git a/apps/portal/server/api/partner/reports/saved.post.ts b/apps/portal/server/api/partner/reports/saved.post.ts new file mode 100644 index 0000000..450b5c9 --- /dev/null +++ b/apps/portal/server/api/partner/reports/saved.post.ts @@ -0,0 +1,24 @@ +// Create a saved partner report. Forwards to platform-api +// POST /me/partner/reports/saved. +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) + } + + const body = await readBody(event) + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + try { + return await $fetch(`${base}/me/partner/reports/saved`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }) + } catch (err: unknown) { + const e = err as { statusCode?: number; data?: unknown } + throw createError({ statusCode: e.statusCode ?? 500, data: e.data }) + } +}) diff --git a/apps/portal/server/api/partner/reports/saved/[id].delete.ts b/apps/portal/server/api/partner/reports/saved/[id].delete.ts new file mode 100644 index 0000000..382958e --- /dev/null +++ b/apps/portal/server/api/partner/reports/saved/[id].delete.ts @@ -0,0 +1,23 @@ +// Delete a saved partner report. Forwards to platform-api +// DELETE /me/partner/reports/saved/:id. +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) + } + + const id = getRouterParam(event, 'id') + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + try { + return await $fetch(`${base}/me/partner/reports/saved/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + }) + } catch (err: unknown) { + const e = err as { statusCode?: number; data?: unknown } + throw createError({ statusCode: e.statusCode ?? 500, data: e.data }) + } +}) diff --git a/services/platform-api/src/me/dto/create-report.dto.ts b/services/platform-api/src/me/dto/create-report.dto.ts new file mode 100644 index 0000000..10c62ce --- /dev/null +++ b/services/platform-api/src/me/dto/create-report.dto.ts @@ -0,0 +1,15 @@ +import { IsEnum, IsObject, IsOptional, IsString, MaxLength, MinLength } from 'class-validator' + +export class CreateReportDto { + @IsString() @MinLength(1) @MaxLength(160) + name!: string + + @IsOptional() @IsEnum(['health', 'revenue', 'churn', 'custom']) + kind?: 'health' | 'revenue' | 'churn' | 'custom' + + @IsOptional() @IsString() @MaxLength(500) + description?: string + + @IsOptional() @IsObject() + definition?: Record +} diff --git a/services/platform-api/src/me/partner-reports.service.ts b/services/platform-api/src/me/partner-reports.service.ts new file mode 100644 index 0000000..3a61416 --- /dev/null +++ b/services/platform-api/src/me/partner-reports.service.ts @@ -0,0 +1,69 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model, Types } from 'mongoose' +import { AuditService, type AuditActor } from '../audit/audit.service.js' +import { Report, ReportDocument } from '../schemas/report.schema.js' +import type { CreateReportDto } from './dto/create-report.dto.js' + +// Saved/custom reports CRUD, scoped to a partner. The live analytics are +// computed in UsersService.partnerReports — this only stores named configs. +@Injectable() +export class PartnerReportsService { + constructor( + @InjectModel(Report.name) private readonly model: Model, + private readonly audit: AuditService, + ) {} + + async list(partnerId: string | Types.ObjectId): Promise { + return this.model.find({ partnerId }).sort({ createdAt: -1 }).exec() + } + + async create( + partnerId: string | Types.ObjectId, + dto: CreateReportDto, + actor?: AuditActor, + ): Promise { + const report = await this.model.create({ + partnerId, + name: dto.name, + kind: dto.kind ?? 'custom', + description: dto.description, + definition: dto.definition ?? {}, + createdByEmail: actor?.email, + }) + void this.audit.record( + { + action: 'report.created', + resourceType: 'partner', + resourceId: String(report._id), + resourceName: report.name, + metadata: { kind: report.kind }, + }, + actor, + ) + return report + } + + async remove( + id: string, + partnerId: string | Types.ObjectId, + actor?: AuditActor, + ): Promise<{ removed: boolean }> { + const report = await this.model.findById(id).exec() + if (!report) throw new NotFoundException('Report not found') + if (String(report.partnerId) !== String(partnerId)) { + throw new ForbiddenException('Report is not in your portfolio') + } + await this.model.deleteOne({ _id: report._id }).exec() + void this.audit.record( + { + action: 'report.deleted', + resourceType: 'partner', + resourceId: id, + resourceName: report.name, + }, + actor, + ) + return { removed: true } + } +} diff --git a/services/platform-api/src/schemas/report.schema.ts b/services/platform-api/src/schemas/report.schema.ts new file mode 100644 index 0000000..bc968fe --- /dev/null +++ b/services/platform-api/src/schemas/report.schema.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument, Types } from 'mongoose' + +export type ReportDocument = HydratedDocument + +export type ReportKind = 'health' | 'revenue' | 'churn' | 'custom' + +// A partner's saved/custom report definition. The analytics themselves are +// computed live (UsersService.partnerReports); this only persists a named, +// reusable configuration the partner created from the New-report modal. +@Schema({ collection: 'reports', timestamps: true }) +export class Report { + @Prop({ type: Types.ObjectId, ref: 'Partner', required: true, index: true }) + partnerId!: Types.ObjectId + + @Prop({ required: true, trim: true }) + name!: string + + @Prop({ enum: ['health', 'revenue', 'churn', 'custom'], default: 'custom' }) + kind!: ReportKind + + @Prop({ trim: true }) + description?: string + + // Free-form saved configuration (metrics, filters, groupBy, schedule, + // recipients, format). UI-driven shape for v1. + @Prop({ type: Object, default: {} }) + definition!: Record + + @Prop({ trim: true }) + createdByEmail?: string +} + +export const ReportSchema = SchemaFactory.createForClass(Report) diff --git a/services/platform-api/src/users/platform-reports.controller.ts b/services/platform-api/src/users/platform-reports.controller.ts new file mode 100644 index 0000000..6994baa --- /dev/null +++ b/services/platform-api/src/users/platform-reports.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, UseGuards } from '@nestjs/common' +import { JwtAuthGuard } from '../auth/jwt-auth.guard.js' +import { OperatorGuard } from '../auth/operator.guard.js' +import { UsersService } from './users.service.js' + +// Platform-wide analytics for the operator reports page. Operator-only. +@Controller('reports') +@UseGuards(JwtAuthGuard, OperatorGuard) +export class PlatformReportsController { + constructor(private readonly users: UsersService) {} + + @Get('platform') + async platform() { + return this.users.platformReports() + } +} diff --git a/services/platform-api/src/users/users.module.ts b/services/platform-api/src/users/users.module.ts index 9c88f6e..604e67b 100644 --- a/services/platform-api/src/users/users.module.ts +++ b/services/platform-api/src/users/users.module.ts @@ -9,6 +9,7 @@ import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema import { Tenant, TenantSchema } from '../schemas/tenant.schema.js' import { User, UserSchema } from '../schemas/user.schema.js' import { TenantsModule } from '../tenants/tenants.module.js' +import { PlatformReportsController } from './platform-reports.controller.js' import { UsersController } from './users.controller.js' import { UsersService } from './users.service.js' @@ -33,7 +34,7 @@ import { UsersService } from './users.service.js' IntegrationsModule, TenantsModule, ], - controllers: [UsersController], + controllers: [UsersController, PlatformReportsController], providers: [UsersService], exports: [UsersService], })