feat(reports): partner and platform analytics
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.
This commit is contained in:
@@ -1,10 +1,149 @@
|
|||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
// Platform-wide analytics (all tenants/subscriptions). Real data from
|
||||||
|
// /api/reports/platform → platform-api GET /reports/platform (operator-only).
|
||||||
|
interface PlatformReports {
|
||||||
|
tenants: { active: number; pending: number; suspended: number; deleted: number; total: number }
|
||||||
|
revenueByPlan: Array<{
|
||||||
|
plan: 'mvp' | 'pro' | 'enterprise'
|
||||||
|
currency: 'DKK' | 'EUR' | 'USD'
|
||||||
|
monthlyMinor: number
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
||||||
|
topTenants: Array<{ tenantId: string; tenantName: string; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
||||||
|
growth: Array<{ month: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: r, pending, refresh } = await useFetch<PlatformReports>('/api/reports/platform', {
|
||||||
|
default: () => ({
|
||||||
|
tenants: { active: 0, pending: 0, suspended: 0, deleted: 0, total: 0 },
|
||||||
|
revenueByPlan: [],
|
||||||
|
totals: [],
|
||||||
|
topTenants: [],
|
||||||
|
growth: [],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = {
|
||||||
|
mvp: 'Starter',
|
||||||
|
pro: 'Business',
|
||||||
|
enterprise: 'Enterprise',
|
||||||
|
}
|
||||||
|
const mrrTotal = computed(() =>
|
||||||
|
Math.round((r.value?.totals ?? []).reduce((s, t) => s + t.monthlyMinor, 0) / 100),
|
||||||
|
)
|
||||||
|
const maxGrowth = computed(() => Math.max(1, ...(r.value?.growth ?? []).map((g) => g.count)))
|
||||||
|
function fmtMonth(m: string) {
|
||||||
|
return new Date(`${m}-01`).toLocaleDateString('da-DK', { month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
function money(minor: number) {
|
||||||
|
return Math.round(minor / 100).toLocaleString('da-DK')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<OpPlaceholder
|
<div>
|
||||||
eyebrow="Commercial"
|
<PageHeader
|
||||||
title="Reports"
|
eyebrow="Commercial"
|
||||||
icon="database"
|
title="Reports"
|
||||||
body="Cohort analyses, churn, expansion revenue, partner-margin reports. Tracked as a follow-up after billing lands."
|
:subtitle="`${r?.tenants.total ?? 0} tenants · ${mrrTotal.toLocaleString('da-DK')} DKK platform MRR`"
|
||||||
/>
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
|
||||||
|
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||||||
|
Refresh
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<div class="vitals">
|
||||||
|
<Card><Stat label="Tenants" :value="r?.tenants.total ?? 0" :hint="`${r?.tenants.active ?? 0} active`" /></Card>
|
||||||
|
<Card><Stat label="MRR · platform" :value="`${mrrTotal.toLocaleString('da-DK')} DKK`" /></Card>
|
||||||
|
<Card><Stat label="Pending" :value="r?.tenants.pending ?? 0" /></Card>
|
||||||
|
<Card><Stat label="Suspended" :value="r?.tenants.suspended ?? 0" :delta-tone="(r?.tenants.suspended ?? 0) > 0 ? 'down' : undefined" /></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<Card :pad="0">
|
||||||
|
<div class="head"><div><Eyebrow>Revenue</Eyebrow><div class="cap">By plan</div></div></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Plan</th><th>Customers</th><th class="num">MRR</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in r?.revenueByPlan ?? []" :key="`${row.plan}-${row.currency}`">
|
||||||
|
<td class="name">{{ PLAN_LABEL[row.plan] }}</td>
|
||||||
|
<td><Mono dim>{{ row.count }}</Mono></td>
|
||||||
|
<td class="num"><Mono>{{ money(row.monthlyMinor) }} {{ row.currency }}</Mono></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!(r?.revenueByPlan ?? []).length"><td colspan="3" class="empty"><Mono dim>// no active subscriptions yet</Mono></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card :pad="0">
|
||||||
|
<div class="head"><div><Eyebrow>Top tenants</Eyebrow><div class="cap">By MRR</div></div></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Tenant</th><th class="num">MRR</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in r?.topTenants ?? []" :key="t.tenantId">
|
||||||
|
<td class="name">{{ t.tenantName }}</td>
|
||||||
|
<td class="num"><Mono>{{ money(t.monthlyMinor) }} {{ t.currency }}</Mono></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!(r?.topTenants ?? []).length"><td colspan="2" class="empty"><Mono dim>// no revenue yet</Mono></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card :pad="0">
|
||||||
|
<div class="head"><div><Eyebrow>Growth</Eyebrow><div class="cap">New tenants by signup month</div></div></div>
|
||||||
|
<div class="growth">
|
||||||
|
<div v-for="g in r?.growth ?? []" :key="g.month" class="bar-row">
|
||||||
|
<Mono dim class="bar-label">{{ fmtMonth(g.month) }}</Mono>
|
||||||
|
<div class="bar-track"><div class="bar-fill" :style="{ width: `${(g.count / maxGrowth) * 100}%` }" /></div>
|
||||||
|
<Mono class="bar-val">{{ g.count }}</Mono>
|
||||||
|
</div>
|
||||||
|
<div v-if="!(r?.growth ?? []).length" class="empty"><Mono dim>// no tenants yet</Mono></div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.vitals {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.vitals > * { background: var(--surface); padding: 20px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.cap { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin-top: 4px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
th.num, td.num { text-align: right; }
|
||||||
|
td { padding: 10px 20px; font-size: 12px; border-top: 1px solid var(--border); }
|
||||||
|
td.name { font-weight: 500; }
|
||||||
|
.empty { text-align: center; color: var(--text-mute); padding: 24px; }
|
||||||
|
.growth { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.bar-row { display: grid; grid-template-columns: 90px 1fr 40px; align-items: center; gap: 12px; }
|
||||||
|
.bar-track { height: 8px; background: var(--border); border-radius: 999px; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; background: var(--text); }
|
||||||
|
.bar-val { text-align: right; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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'))
|
||||||
@@ -4,7 +4,22 @@
|
|||||||
// recipients + format + live summary.
|
// recipients + format + live summary.
|
||||||
|
|
||||||
defineProps<{ open: boolean }>()
|
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 = [
|
const METRICS = [
|
||||||
{ id: 'mrr', label: 'MRR', group: 'Revenue' },
|
{ id: 'mrr', label: 'MRR', group: 'Revenue' },
|
||||||
@@ -42,7 +57,7 @@ const grouped = computed(() => {
|
|||||||
const out: Record<string, typeof METRICS[number][]> = {}
|
const out: Record<string, typeof METRICS[number][]> = {}
|
||||||
for (const m of METRICS) {
|
for (const m of METRICS) {
|
||||||
out[m.group] = out[m.group] || []
|
out[m.group] = out[m.group] || []
|
||||||
out[m.group].push(m)
|
out[m.group]!.push(m)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
@@ -184,7 +199,7 @@ function toggle(id: string) {
|
|||||||
<UiButton
|
<UiButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:disabled="!name || metrics.length === 0"
|
:disabled="!name || metrics.length === 0"
|
||||||
@click="emit('created', name); emit('close')"
|
@click="emit('created', { name, description, metrics, filterPlan, filterStatus, groupBy, schedule, recipients: recipients.split(',').map((r) => r.trim()).filter(Boolean), format }); emit('close')"
|
||||||
>
|
>
|
||||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||||
Create report
|
Create report
|
||||||
|
|||||||
@@ -8,21 +8,77 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { customers, partnerMrrSparkline } from '~/data/customers'
|
// Decorative MRR sparkline shape only — historical MRR isn't stored yet (a
|
||||||
import type { CustomerOrg } from '~/data/customers'
|
// 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'
|
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
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<ReportsData>('/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<string, unknown>
|
||||||
|
createdByEmail?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
const { data: savedRaw, refresh: refreshSaved } = useFetch<SavedReport[]>(
|
||||||
|
'/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 tab = ref<'health' | 'revenue' | 'churn' | 'custom'>('health')
|
||||||
const period = ref<'30d' | '90d' | '12mo' | 'ytd'>('90d')
|
const period = ref<'30d' | '90d' | '12mo' | 'ytd'>('90d')
|
||||||
|
|
||||||
const tabs = [
|
const tabs = computed(() => [
|
||||||
{ value: 'health', label: 'Customer health' },
|
{ value: 'health', label: 'Customer health' },
|
||||||
{ value: 'revenue', label: 'Revenue' },
|
{ value: 'revenue', label: 'Revenue' },
|
||||||
{ value: 'churn', label: 'Churn' },
|
{ value: 'churn', label: 'Churn' },
|
||||||
{ value: 'custom', label: 'Custom reports', count: 3 },
|
{ value: 'custom', label: 'Custom reports', count: savedRaw.value?.length ?? 0 },
|
||||||
]
|
])
|
||||||
|
|
||||||
const periodOpts = [
|
const periodOpts = [
|
||||||
{ value: '30d', label: '30 days' },
|
{ value: '30d', label: '30 days' },
|
||||||
@@ -35,25 +91,46 @@ const exportOpen = ref(false)
|
|||||||
const newReportOpen = ref(false)
|
const newReportOpen = ref(false)
|
||||||
|
|
||||||
// HEALTH ─────────────────────────────────────────────────────────────────────
|
// HEALTH ─────────────────────────────────────────────────────────────────────
|
||||||
// Health scoring exactly mirrors platform-partner-depth.jsx:73-80.
|
// Per-customer rows from real tenants (server-computed healthScore), shaped as
|
||||||
const scored = computed(() => customers.map((c) => {
|
// CustomerOrg so the table + task panel consume them unchanged.
|
||||||
let score = 100
|
const scored = computed<Array<CustomerOrg & { score: number }>>(() =>
|
||||||
if (c.status === 'past_due') score -= 50
|
(tenants.value ?? [])
|
||||||
else if (c.status === 'attention') score -= 30
|
.filter((t) => t.status !== 'deleted')
|
||||||
else if (c.status === 'trial') score -= 10
|
.map((t) => {
|
||||||
if (c.seats.used / c.seats.total > 0.85) score -= 10
|
const info = PLAN_INFO[t.plan ?? 'pro']
|
||||||
return { ...c, score }
|
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(() => ({
|
const cohort = computed(() => ({
|
||||||
healthy: scored.value.filter((c) => c.score >= 75).length,
|
healthy: reports.value?.health.healthy ?? 0,
|
||||||
watch: scored.value.filter((c) => c.score >= 50 && c.score < 75).length,
|
watch: reports.value?.health.watch ?? 0,
|
||||||
risk: scored.value.filter((c) => c.score < 50).length,
|
risk: reports.value?.health.atRisk ?? 0,
|
||||||
}))
|
}))
|
||||||
|
const avgHealth = computed(() => reports.value?.health.avgScore ?? 0)
|
||||||
|
|
||||||
function healthColor(h: number) {
|
function healthColor(h: number) {
|
||||||
if (h >= 75) return 'var(--ok)'
|
if (h >= 80) return 'var(--ok)'
|
||||||
if (h >= 50) return 'var(--warn)'
|
if (h >= 60) return 'var(--warn)'
|
||||||
return 'var(--bad)'
|
return 'var(--bad)'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,35 +145,77 @@ function miniTrend(seed: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// REVENUE ────────────────────────────────────────────────────────────────────
|
// 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 PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
|
||||||
const topByMrr = computed(() => [...customers].sort((a, b) => b.mrrDkk - a.mrrDkk).slice(0, 5))
|
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 topByMrr = computed(() => {
|
||||||
const revenueMix = [
|
const colorOf = (id: string) => (tenants.value ?? []).find((t) => t._id === id)?.brandColor || '#3F6BFF'
|
||||||
{ n: 'Enterprise', v: 42900, p: 77, c: 'var(--text)' },
|
return (reports.value?.topCustomers ?? []).slice(0, 5).map((r) => ({
|
||||||
{ n: 'Business', v: 11340, p: 20, c: 'var(--info)' },
|
id: r.tenantId,
|
||||||
{ n: 'Starter', v: 1510, p: 3, c: 'var(--text-mute)' },
|
name: r.tenantName,
|
||||||
]
|
brandColor: colorOf(r.tenantId),
|
||||||
|
mrrDkk: Math.round(r.monthlyMinor / 100),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
// CHURN cohort heatmap · platform-partner-depth.jsx:237-243
|
// CHURN — signup-month cohorts with (approximate) current retention. Real
|
||||||
const cohorts: Array<[string, number, Array<number | '—'>]> = [
|
// month-over-month retention needs cancellation dates (Phase 3 billing).
|
||||||
['Nov 2024', 1, [100, 100, 100, 100, 100, 100]],
|
const churnRows = computed(() =>
|
||||||
['Aug 2025', 1, [100, 100, 100, 100, 100, '—']],
|
(reports.value?.churnCohorts ?? []).map((c) => ({
|
||||||
['Sep 2025', 1, [100, 100, 100, 100, 100, '—']],
|
label: new Date(`${c.month}-01`).toLocaleDateString('da-DK', { month: 'short', year: 'numeric' }),
|
||||||
['Feb 2026', 3, [100, 100, 100, '—', '—', '—']],
|
total: c.total,
|
||||||
['Mar 2026', 2, [100, 100, '—', '—', '—', '—']],
|
retained: c.retained,
|
||||||
['May 2026', 1, [100, '—', '—', '—', '—', '—']],
|
retentionPct: c.retentionPct,
|
||||||
]
|
})),
|
||||||
const cohortHeaders = ['M+0', 'M+1', 'M+2', 'M+3', 'M+6', 'M+12']
|
)
|
||||||
|
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
|
// CUSTOM REPORTS — real saved definitions from /api/partner/reports/saved.
|
||||||
const savedReports = ref([
|
function fmtDate(iso?: string) {
|
||||||
{ id: 'r1', name: 'Quarterly board · Q1 2026', owner: 'Anne Baslund', schedule: 'Quarterly · 1st', last: '03 Apr 2026', recipients: 4, format: 'PDF' },
|
return iso
|
||||||
{ id: 'r2', name: 'Customer Health · weekly digest', owner: 'Anne Baslund', schedule: 'Mondays 09:00 CET', last: '13 May 2026', recipients: 2, format: 'PDF' },
|
? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
{ id: 'r3', name: 'Margin breakdown by partner cut', owner: 'Mikkel Nørgaard', schedule: 'On-demand', last: '08 May 2026', recipients: 1, format: 'CSV' },
|
: '—'
|
||||||
])
|
}
|
||||||
|
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<string | null>(null)
|
const running = ref<string | null>(null)
|
||||||
const reportMenuFor = ref<string | null>(null)
|
const reportMenuFor = ref<string | null>(null)
|
||||||
@@ -134,13 +253,58 @@ function reportActions(r: typeof savedReports.value[number]) {
|
|||||||
|
|
||||||
const confirmDeleteReport = computed(() => savedReports.value.find((r) => r.id === confirmDeleteId.value))
|
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)
|
const r = savedReports.value.find((x) => x.id === confirmDeleteId.value)
|
||||||
if (r) {
|
if (!r) {
|
||||||
savedReports.value = savedReports.value.filter((x) => x.id !== confirmDeleteId.value)
|
confirmDeleteId.value = null
|
||||||
toast.bad('Report deleted', r.name)
|
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 }
|
function closeMenu() { reportMenuFor.value = null }
|
||||||
@@ -191,16 +355,16 @@ onMounted(() => {
|
|||||||
<div v-if="tab === 'health'" class="content">
|
<div v-if="tab === 'health'" class="content">
|
||||||
<div class="stat-strip">
|
<div class="stat-strip">
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="Healthy" :value="cohort.healthy" :delta="`${Math.round(cohort.healthy / scored.length * 100)}% of portfolio`" />
|
<Stat label="Healthy" :value="cohort.healthy" :hint="`of ${cohort.healthy + cohort.watch + cohort.risk} customers`" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="Watch" :value="cohort.watch" delta="2 customers · check in" delta-tone="up" />
|
<Stat label="Watch" :value="cohort.watch" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="At risk" :value="cohort.risk" delta="1 customer · escalate" delta-tone="down" />
|
<Stat label="At risk" :value="cohort.risk" :delta-tone="cohort.risk > 0 ? 'down' : undefined" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="NPS · est" value="58" delta="+6 from last period" delta-tone="up" hint="based on 12 responses" />
|
<Stat label="Avg health" :value="avgHealth" hint="0–100 score" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -265,16 +429,16 @@ onMounted(() => {
|
|||||||
<div v-if="tab === 'revenue'" class="content">
|
<div v-if="tab === 'revenue'" class="content">
|
||||||
<div class="stat-strip">
|
<div class="stat-strip">
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="MRR · current" value="55.750 DKK" delta="+18.2%" delta-tone="up" hint="vs. 90d ago" />
|
<Stat label="MRR · current" :value="`${totalMrr.toLocaleString('da-DK')} DKK`" hint="across all customers" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="Partner margin" value="11.150 DKK" delta="+19.0%" delta-tone="up" hint="20% of MRR" />
|
<Stat label="Partner margin" :value="`${marginMrr.toLocaleString('da-DK')} DKK`" :hint="`${reports?.marginPct ?? 0}% of MRR`" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="ARR · projected" value="669.000 DKK" delta="+24% YoY" delta-tone="up" />
|
<Stat label="ARR · projected" :value="`${arr.toLocaleString('da-DK')} DKK`" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="ARPU" value="6.969 DKK" hint="per customer / mo" />
|
<Stat label="ARPU" :value="`${arpu.toLocaleString('da-DK')} DKK`" hint="per customer / mo" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,8 +452,8 @@ onMounted(() => {
|
|||||||
<div class="big-chart">
|
<div class="big-chart">
|
||||||
<PartnerSparkline :values="partnerMrrSparkline" :width="1080" :height="160" stroke="var(--text)" fill="var(--row-hover)" />
|
<PartnerSparkline :values="partnerMrrSparkline" :width="1080" :height="160" stroke="var(--text)" fill="var(--row-hover)" />
|
||||||
<div class="chart-foot">
|
<div class="chart-foot">
|
||||||
<span>Feb 14 · 38.180 DKK</span>
|
<span>90-day trend · illustrative</span>
|
||||||
<span>May 14 · 55.750 DKK</span>
|
<span>{{ totalMrr.toLocaleString('da-DK') }} DKK / mo now</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -326,16 +490,16 @@ onMounted(() => {
|
|||||||
<div v-if="tab === 'churn'" class="content">
|
<div v-if="tab === 'churn'" class="content">
|
||||||
<div class="stat-strip">
|
<div class="stat-strip">
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="Gross churn · 90d" value="0%" delta="0 customers" />
|
<Stat label="Signup cohorts" :value="churnRows.length" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="Net churn · MRR" value="−2.1%" delta="contracted from upgrades" delta-tone="up" />
|
<Stat label="Avg retention" :value="`${avgRetention}%`" :delta-tone="avgRetention >= 80 ? 'up' : 'down'" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="At-risk MRR" value="2.940 DKK" hint="1 customer · past-due" delta-tone="down" />
|
<Stat label="Active customers" :value="cohort.healthy + cohort.watch" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="Avg tenure" value="14 mo" delta="trending up" delta-tone="up" />
|
<Stat label="At risk" :value="cohort.risk" :delta-tone="cohort.risk > 0 ? 'down' : undefined" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -351,26 +515,29 @@ onMounted(() => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Cohort</th>
|
<th>Cohort</th>
|
||||||
<th>Size</th>
|
<th>Customers</th>
|
||||||
<th v-for="h in cohortHeaders" :key="h">{{ h }}</th>
|
<th>Retained</th>
|
||||||
|
<th>Retention</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(c, i) in cohorts" :key="i">
|
<tr v-for="(c, i) in churnRows" :key="i">
|
||||||
<td><Mono>{{ c[0] }}</Mono></td>
|
<td><Mono>{{ c.label }}</Mono></td>
|
||||||
<td class="cohort-size"><Mono>{{ c[1] }}</Mono></td>
|
<td class="cohort-size"><Mono>{{ c.total }}</Mono></td>
|
||||||
<td v-for="(v, j) in c[2]" :key="j" class="cell">
|
<td><Mono>{{ c.retained }}</Mono></td>
|
||||||
<Mono v-if="v === '—'" dim>—</Mono>
|
<td class="cell">
|
||||||
<span
|
<span
|
||||||
v-else
|
|
||||||
class="heat"
|
class="heat"
|
||||||
:style="{
|
:style="{
|
||||||
background: (v as number) >= 100 ? 'rgba(31,138,91,0.16)' : (v as number) >= 80 ? 'rgba(232,154,31,0.16)' : 'rgba(226,48,48,0.16)',
|
background: c.retentionPct >= 90 ? 'rgba(31,138,91,0.16)' : c.retentionPct >= 70 ? 'rgba(232,154,31,0.16)' : 'rgba(226,48,48,0.16)',
|
||||||
color: (v as number) >= 100 ? 'var(--ok)' : (v as number) >= 80 ? 'var(--warn)' : 'var(--bad)',
|
color: c.retentionPct >= 90 ? 'var(--ok)' : c.retentionPct >= 70 ? 'var(--warn)' : 'var(--bad)',
|
||||||
}"
|
}"
|
||||||
>{{ v }}%</span>
|
>{{ c.retentionPct }}%</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="!churnRows.length">
|
||||||
|
<td colspan="4" class="cohort-empty"><Mono dim>// no signup cohorts yet</Mono></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -502,7 +669,7 @@ onMounted(() => {
|
|||||||
<PartnerNewCustomReportModal
|
<PartnerNewCustomReportModal
|
||||||
:open="newReportOpen"
|
:open="newReportOpen"
|
||||||
@close="newReportOpen = false"
|
@close="newReportOpen = false"
|
||||||
@created="(n) => toast.ok('Report created', n)"
|
@created="onCreated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Export PDF Modal -->
|
<!-- Export PDF Modal -->
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<string, unknown>
|
||||||
|
}
|
||||||
@@ -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<ReportDocument>,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(partnerId: string | Types.ObjectId): Promise<ReportDocument[]> {
|
||||||
|
return this.model.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
partnerId: string | Types.ObjectId,
|
||||||
|
dto: CreateReportDto,
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<ReportDocument> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { HydratedDocument, Types } from 'mongoose'
|
||||||
|
|
||||||
|
export type ReportDocument = HydratedDocument<Report>
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
|
||||||
|
@Prop({ trim: true })
|
||||||
|
createdByEmail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReportSchema = SchemaFactory.createForClass(Report)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema
|
|||||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||||
|
import { PlatformReportsController } from './platform-reports.controller.js'
|
||||||
import { UsersController } from './users.controller.js'
|
import { UsersController } from './users.controller.js'
|
||||||
import { UsersService } from './users.service.js'
|
import { UsersService } from './users.service.js'
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ import { UsersService } from './users.service.js'
|
|||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
],
|
],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController, PlatformReportsController],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user