feat: partner enrichment, mutations, settings & branding + operator quick-wins
Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation. Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save. Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
This commit is contained in:
@@ -3,7 +3,6 @@ import type { Tenant } from '~/types/tenant'
|
|||||||
import type { Partner } from '~/types/partner'
|
import type { Partner } from '~/types/partner'
|
||||||
import type { PlatformUser } from '~/types/user'
|
import type { PlatformUser } from '~/types/user'
|
||||||
import type { AuditEvent } from '~/types/audit'
|
import type { AuditEvent } from '~/types/audit'
|
||||||
import { SERVICES, INCIDENT } from '~/data/fixtures'
|
|
||||||
|
|
||||||
const { data: tenants, pending: tp, refresh: rT } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
|
const { data: tenants, pending: tp, refresh: rT } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
|
||||||
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
|
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
|
||||||
@@ -13,19 +12,30 @@ const { data: auditEvents, refresh: rA } = await useFetch<AuditEvent[]>('/api/au
|
|||||||
query: { limit: 8 },
|
query: { limit: 8 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Real service health from platform-api probes (same source as the
|
||||||
|
// infrastructure page) — replaces the old SERVICES/INCIDENT fixtures.
|
||||||
|
interface ProbeResult {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: 'ok' | 'warn' | 'bad'
|
||||||
|
}
|
||||||
|
const { data: probes, refresh: rH } = await useFetch<ProbeResult[]>('/api/health/platform', {
|
||||||
|
default: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
function fmtClock(iso: string) {
|
function fmtClock(iso: string) {
|
||||||
return new Date(iso).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { open: openIncident } = useIncidentModal()
|
|
||||||
|
|
||||||
const pending = computed(() => tp.value || pp.value || up.value)
|
const pending = computed(() => tp.value || pp.value || up.value)
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
await Promise.all([rT(), rP(), rU(), rA()])
|
await Promise.all([rT(), rP(), rU(), rA(), rH()])
|
||||||
}
|
}
|
||||||
|
|
||||||
const degradedCount = computed(() => SERVICES.filter((s) => s.status !== 'ok').length)
|
const totalServices = computed(() => (probes.value ?? []).length)
|
||||||
|
const degradedServices = computed(() => (probes.value ?? []).filter((p) => p.status !== 'ok'))
|
||||||
|
const degradedCount = computed(() => degradedServices.value.length)
|
||||||
const incidentActive = computed(() => degradedCount.value > 0)
|
const incidentActive = computed(() => degradedCount.value > 0)
|
||||||
|
|
||||||
const stats = computed(() => ({
|
const stats = computed(() => ({
|
||||||
@@ -74,18 +84,18 @@ function fmtDate(d: string) {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="stage">
|
<div class="stage">
|
||||||
<button v-if="incidentActive" class="incident" type="button" @click="openIncident">
|
<NuxtLink v-if="incidentActive" class="incident" to="/infrastructure">
|
||||||
<span class="pill">
|
<span class="pill">
|
||||||
<span class="dot" />
|
<span class="dot" />
|
||||||
{{ INCIDENT.severity }} · ACTIVE
|
DEGRADED
|
||||||
</span>
|
</span>
|
||||||
<div class="incident-body">
|
<div class="incident-body">
|
||||||
<div class="incident-title">{{ INCIDENT.title }}</div>
|
<div class="incident-title">{{ degradedCount }} service{{ degradedCount === 1 ? '' : 's' }} reporting non-OK status</div>
|
||||||
<div class="incident-sub">Started {{ INCIDENT.started }} · {{ INCIDENT.duration }} duration · {{ INCIDENT.affected }}</div>
|
<div class="incident-sub">{{ degradedServices.map((s) => s.name).join(', ') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<Mono>IC: {{ INCIDENT.ic }}</Mono>
|
<Mono>view infrastructure</Mono>
|
||||||
<UiIcon name="chevRight" :size="14" />
|
<UiIcon name="chevRight" :size="14" />
|
||||||
</button>
|
</NuxtLink>
|
||||||
|
|
||||||
<div class="vitals">
|
<div class="vitals">
|
||||||
<NuxtLink to="/tenants" class="vital">
|
<NuxtLink to="/tenants" class="vital">
|
||||||
@@ -102,7 +112,7 @@ function fmtDate(d: string) {
|
|||||||
label="Services"
|
label="Services"
|
||||||
:value="incidentActive ? `${degradedCount} degraded` : 'all green'"
|
:value="incidentActive ? `${degradedCount} degraded` : 'all green'"
|
||||||
:delta-tone="incidentActive ? 'down' : 'up'"
|
:delta-tone="incidentActive ? 'down' : 'up'"
|
||||||
:hint="incidentActive ? 'P2 · authentik' : `${SERVICES.length} / ${SERVICES.length} healthy`"
|
:hint="`${totalServices - degradedCount} / ${totalServices} healthy`"
|
||||||
/>
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +259,7 @@ function fmtDate(d: string) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { INCIDENT, PLANNED_SERVICES } from '~/data/fixtures'
|
import { PLANNED_SERVICES } from '~/data/fixtures'
|
||||||
|
|
||||||
// Shape returned by /api/health/platform on platform-api.
|
// Shape returned by /api/health/platform on platform-api.
|
||||||
interface ProbeResult {
|
interface ProbeResult {
|
||||||
@@ -72,13 +72,12 @@ function label(p: ProbeResult) {
|
|||||||
<div v-if="incidentActive" class="incident">
|
<div v-if="incidentActive" class="incident">
|
||||||
<span class="pill">
|
<span class="pill">
|
||||||
<span class="dot" />
|
<span class="dot" />
|
||||||
{{ INCIDENT.severity }} · ACTIVE
|
DEGRADED
|
||||||
</span>
|
</span>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="title">{{ INCIDENT.title }}</div>
|
<div class="title">{{ degradedCount }} service(s) reporting non-ok status</div>
|
||||||
<div class="sub">{{ degradedCount }} service(s) reporting non-ok status · IC: {{ INCIDENT.ic }}</div>
|
<div class="sub">{{ (probes ?? []).filter((p) => p.status !== 'ok').map((p) => p.name).join(', ') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<UiButton variant="primary" disabled>Open incident</UiButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Eyebrow class="section-head">Live · {{ totalCount }} services</Eyebrow>
|
<Eyebrow class="section-head">Live · {{ totalCount }} services</Eyebrow>
|
||||||
|
|||||||
@@ -3,17 +3,54 @@
|
|||||||
// preview of how the partner topbar/header will look with the picked
|
// preview of how the partner topbar/header will look with the picked
|
||||||
// primary color + display name.
|
// primary color + display name.
|
||||||
|
|
||||||
defineProps<{ open: boolean }>()
|
export interface BrandIdentity {
|
||||||
const emit = defineEmits<{ close: [] }>()
|
displayName?: string
|
||||||
|
logoUrl?: string
|
||||||
|
markUrl?: string
|
||||||
|
faviconUrl?: string
|
||||||
|
primaryColor?: string
|
||||||
|
supportEmail?: string
|
||||||
|
supportPhone?: string
|
||||||
|
website?: string
|
||||||
|
replyTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
const name = ref('NordicMSP')
|
const props = defineProps<{ open: boolean; identity?: BrandIdentity }>()
|
||||||
|
const emit = defineEmits<{ close: []; save: [payload: BrandIdentity] }>()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
const color = ref('#3F6BFF')
|
const color = ref('#3F6BFF')
|
||||||
const supportEmail = ref('support@nordicmsp.dk')
|
const supportEmail = ref('')
|
||||||
const supportPhone = ref('+45 70 70 12 34')
|
const supportPhone = ref('')
|
||||||
const website = ref('nordicmsp.dk')
|
const website = ref('')
|
||||||
const replyTo = ref('no-reply@nordicmsp.dk')
|
const replyTo = ref('')
|
||||||
|
|
||||||
|
// Seed the form from the current identity each time the modal opens.
|
||||||
|
function seed() {
|
||||||
|
const i = props.identity ?? {}
|
||||||
|
name.value = i.displayName ?? ''
|
||||||
|
color.value = i.primaryColor ?? '#3F6BFF'
|
||||||
|
supportEmail.value = i.supportEmail ?? ''
|
||||||
|
supportPhone.value = i.supportPhone ?? ''
|
||||||
|
website.value = i.website ?? ''
|
||||||
|
replyTo.value = i.replyTo ?? ''
|
||||||
|
}
|
||||||
|
watch(() => props.open, (o) => { if (o) seed() }, { immediate: true })
|
||||||
|
|
||||||
const SWATCHES = ['#3F6BFF', '#0A2540', '#0066CC', '#5B8C5A', '#D4FF3A']
|
const SWATCHES = ['#3F6BFF', '#0A2540', '#0066CC', '#5B8C5A', '#D4FF3A']
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
// Emit only `save` — the parent closes the modal after the async save
|
||||||
|
// succeeds, so a failed save keeps the form open instead of losing edits.
|
||||||
|
emit('save', {
|
||||||
|
displayName: name.value,
|
||||||
|
primaryColor: color.value,
|
||||||
|
supportEmail: supportEmail.value,
|
||||||
|
supportPhone: supportPhone.value,
|
||||||
|
website: website.value,
|
||||||
|
replyTo: replyTo.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,7 +133,7 @@ const SWATCHES = ['#3F6BFF', '#0A2540', '#0066CC', '#5B8C5A', '#D4FF3A']
|
|||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||||
<UiButton variant="primary" @click="emit('close')">
|
<UiButton variant="primary" @click="save">
|
||||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||||
Save identity
|
Save identity
|
||||||
</UiButton>
|
</UiButton>
|
||||||
|
|||||||
@@ -6,7 +6,20 @@
|
|||||||
import { customers } from '~/data/customers'
|
import { customers } from '~/data/customers'
|
||||||
|
|
||||||
defineProps<{ open: boolean }>()
|
defineProps<{ open: boolean }>()
|
||||||
const emit = defineEmits<{ close: []; sent: [payload: { email: string; role: string }] }>()
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
sent: [
|
||||||
|
payload: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
role: string
|
||||||
|
access: 'all' | 'specific' | 'none'
|
||||||
|
specific: string[]
|
||||||
|
requireMfa: boolean
|
||||||
|
message: string
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}>()
|
||||||
|
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
@@ -151,8 +164,8 @@ function planBadgeTone(p: string) {
|
|||||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||||
<UiButton
|
<UiButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:disabled="!email"
|
:disabled="!email || !name"
|
||||||
@click="emit('sent', { email, role }); emit('close')"
|
@click="emit('sent', { name, email, role, access, specific, requireMfa, message }); emit('close')"
|
||||||
>
|
>
|
||||||
<template #leading><UiIcon name="mail" :size="14" /></template>
|
<template #leading><UiIcon name="mail" :size="14" /></template>
|
||||||
Send invitation
|
Send invitation
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface TeamMember {
|
|||||||
email: string
|
email: string
|
||||||
role: string
|
role: string
|
||||||
access: 'all' | 'specific' | 'none' | string
|
access: 'all' | 'specific' | 'none' | string
|
||||||
|
// Number of customers when access is scoped; null when access is 'all'.
|
||||||
|
accessCount?: number | null
|
||||||
mfa: string
|
mfa: string
|
||||||
lastSeen: string
|
lastSeen: string
|
||||||
isOwner?: boolean
|
isOwner?: boolean
|
||||||
|
|||||||
@@ -8,41 +8,109 @@
|
|||||||
|
|
||||||
|
|
||||||
import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue'
|
import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue'
|
||||||
|
import type { BrandIdentity } from '~/components/partner/EditIdentityModal.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const identityOpen = ref(false)
|
const identityOpen = ref(false)
|
||||||
const editing = ref<EmailTemplate | null>(null)
|
const editing = ref<EmailTemplate | null>(null)
|
||||||
|
|
||||||
// Customer defaults · partner-screens.jsx line 872-878
|
interface CustomerDefault {
|
||||||
const defaults = ref([
|
label: string
|
||||||
{ l: 'Accent color', d: 'Cobalt #3F6BFF', on: true },
|
detail: string
|
||||||
{ l: 'Product name pattern', d: '"{Customer} Workspace" e.g. Acme Workspace', on: true },
|
on: boolean
|
||||||
{ l: 'Custom subdomain', d: 'workspace.{customer-domain}', on: true },
|
}
|
||||||
{ l: 'Login screen', d: 'NordicMSP co-brand + customer logo', on: true },
|
interface PartnerBranding {
|
||||||
{ l: 'Email templates', d: '5 templates · NordicMSP voice', on: true },
|
identity: BrandIdentity
|
||||||
{ l: 'Allow customer override', d: 'Business plans and above', on: true },
|
customerDefaults: CustomerDefault[]
|
||||||
{ l: 'Lock typography', d: 'Inter Tight + JetBrains Mono · brand-locked', on: false },
|
emailTemplates: EmailTemplate[]
|
||||||
])
|
}
|
||||||
|
|
||||||
// Source mustache literals. Constructed in JS to avoid Vue parser eating
|
const { data: branding, refresh } = await useFetch<PartnerBranding>('/api/partner/branding', {
|
||||||
// nested {{ }} (see CRITICAL note in task brief).
|
key: 'partner-branding',
|
||||||
|
default: () => ({ identity: {}, customerDefaults: [], emailTemplates: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Source mustache literals. Constructed in JS to avoid the Vue parser eating
|
||||||
|
// nested {{ }} (see the Vue nested-mustache note).
|
||||||
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
|
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
|
||||||
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
|
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
|
||||||
const TAG_PLAN = '{' + '{plan.name}' + '}'
|
const TAG_PLAN = '{' + '{plan.name}' + '}'
|
||||||
|
|
||||||
const templates = ref<EmailTemplate[]>([
|
// Product defaults shown the first time a partner visits (before they save).
|
||||||
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE} — managed by NordicMSP`, body: '', edited: '5 days ago' },
|
const DEFAULT_TOGGLES: CustomerDefault[] = [
|
||||||
{ id: 'invitation', name: 'User invitation', subject: `You’ve been invited to ${TAG_WORKSPACE}`, body: '', edited: '3 days ago' },
|
{ label: 'Accent color', detail: 'Apply your brand accent to customer workspaces', on: true },
|
||||||
|
{ label: 'Product name pattern', detail: '"{Customer} Workspace"', on: true },
|
||||||
|
{ label: 'Custom subdomain', detail: 'workspace.{customer-domain}', on: true },
|
||||||
|
{ label: 'Login screen', detail: 'Partner co-brand + customer logo', on: true },
|
||||||
|
{ label: 'Email templates', detail: 'Your branded templates', on: true },
|
||||||
|
{ label: 'Allow customer override', detail: 'Business plans and above', on: true },
|
||||||
|
{ label: 'Lock typography', detail: 'Brand-locked fonts', on: false },
|
||||||
|
]
|
||||||
|
const DEFAULT_TEMPLATES: EmailTemplate[] = [
|
||||||
|
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE}`, body: '', edited: 'default' },
|
||||||
|
{ id: 'invitation', name: 'User invitation', subject: `You’ve been invited to ${TAG_WORKSPACE}`, body: '', edited: 'default' },
|
||||||
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
|
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
|
||||||
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
|
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
|
||||||
{ id: 'invoice', name: 'Invoice notification', subject: `Your NordicMSP invoice ${TAG_INVOICE}`, body: '', edited: '2 weeks ago' },
|
{ id: 'invoice', name: 'Invoice notification', subject: `Invoice ${TAG_INVOICE}`, body: '', edited: 'default' },
|
||||||
])
|
]
|
||||||
|
|
||||||
function saveTemplate(t: EmailTemplate) {
|
// Editable working copies seeded from the fetched branding (falling back to the
|
||||||
|
// product defaults so the page is never blank on first visit).
|
||||||
|
const identity = ref<BrandIdentity>({})
|
||||||
|
const defaults = ref<CustomerDefault[]>([])
|
||||||
|
const templates = ref<EmailTemplate[]>([])
|
||||||
|
function clone<T>(v: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(v)) as T
|
||||||
|
}
|
||||||
|
function syncBranding() {
|
||||||
|
const b = branding.value
|
||||||
|
identity.value = { ...(b?.identity ?? {}) }
|
||||||
|
defaults.value = b?.customerDefaults?.length ? clone(b.customerDefaults) : clone(DEFAULT_TOGGLES)
|
||||||
|
templates.value = b?.emailTemplates?.length ? clone(b.emailTemplates) : clone(DEFAULT_TEMPLATES)
|
||||||
|
}
|
||||||
|
syncBranding()
|
||||||
|
watch(branding, syncBranding)
|
||||||
|
|
||||||
|
async function putBranding(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/partner/branding', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
identity: identity.value,
|
||||||
|
customerDefaults: defaults.value,
|
||||||
|
emailTemplates: templates.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Promise.all([refresh(), refreshNuxtData('partner-branding')])
|
||||||
|
return true
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||||
|
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save branding')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDefault(i: number) {
|
||||||
|
const row = defaults.value[i]
|
||||||
|
if (!row) return
|
||||||
|
row.on = !row.on
|
||||||
|
if (await putBranding()) toast.ok('Saved', 'Customer defaults updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTemplate(t: EmailTemplate) {
|
||||||
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
|
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
|
||||||
editing.value = null
|
editing.value = null
|
||||||
toast.ok('Template saved', t.name)
|
if (await putBranding()) toast.ok('Template saved', t.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveIdentity(payload: BrandIdentity) {
|
||||||
|
identity.value = { ...identity.value, ...payload }
|
||||||
|
// Close only on success so a failed save keeps the modal open (with its toast).
|
||||||
|
if (await putBranding()) {
|
||||||
|
identityOpen.value = false
|
||||||
|
toast.ok('Saved', 'Brand identity updated')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -67,24 +135,24 @@ function saveTemplate(t: EmailTemplate) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="id-grid">
|
<div class="id-grid">
|
||||||
<dl class="def">
|
<dl class="def">
|
||||||
<div><dt>Display name</dt><dd>NordicMSP</dd></div>
|
<div><dt>Display name</dt><dd>{{ identity.displayName || '—' }}</dd></div>
|
||||||
<div><dt>Logo</dt><dd>nordic-logo.svg · 4:1 horizontal</dd></div>
|
<div><dt>Logo</dt><dd>{{ identity.logoUrl || 'not set' }}</dd></div>
|
||||||
<div><dt>Mark</dt><dd>nordic-mark.svg · 1:1</dd></div>
|
<div><dt>Mark</dt><dd>{{ identity.markUrl || 'not set' }}</dd></div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Primary color</dt>
|
<dt>Primary color</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
<div class="color-swatch" style="background:#3F6BFF" />
|
<div class="color-swatch" :style="{ background: identity.primaryColor || '#3F6BFF' }" />
|
||||||
<Mono>#3F6BFF</Mono>
|
<Mono>{{ identity.primaryColor || '#3F6BFF' }}</Mono>
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<dl class="def">
|
<dl class="def">
|
||||||
<div><dt>Support email</dt><dd>support@nordicmsp.dk</dd></div>
|
<div><dt>Support email</dt><dd>{{ identity.supportEmail || '—' }}</dd></div>
|
||||||
<div><dt>Support phone</dt><dd>+45 70 70 12 34</dd></div>
|
<div><dt>Support phone</dt><dd>{{ identity.supportPhone || '—' }}</dd></div>
|
||||||
<div><dt>Website</dt><dd>nordicmsp.dk</dd></div>
|
<div><dt>Website</dt><dd>{{ identity.website || '—' }}</dd></div>
|
||||||
<div><dt>Reply-to</dt><dd>no-reply@nordicmsp.dk</dd></div>
|
<div><dt>Reply-to</dt><dd>{{ identity.replyTo || '—' }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -101,15 +169,15 @@ function saveTemplate(t: EmailTemplate) {
|
|||||||
<div class="defaults-list">
|
<div class="defaults-list">
|
||||||
<div
|
<div
|
||||||
v-for="(row, i) in defaults"
|
v-for="(row, i) in defaults"
|
||||||
:key="row.l"
|
:key="row.label"
|
||||||
class="def-row"
|
class="def-row"
|
||||||
:class="{ last: i === defaults.length - 1 }"
|
:class="{ last: i === defaults.length - 1 }"
|
||||||
>
|
>
|
||||||
<div class="dr-meta">
|
<div class="dr-meta">
|
||||||
<div class="dr-label">{{ row.l }}</div>
|
<div class="dr-label">{{ row.label }}</div>
|
||||||
<div class="dr-detail">{{ row.d }}</div>
|
<div class="dr-detail">{{ row.detail }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="switch" :class="{ on: row.on }" @click="row.on = !row.on">
|
<button class="switch" :class="{ on: row.on }" @click="toggleDefault(i)">
|
||||||
<span class="thumb" />
|
<span class="thumb" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,12 +209,17 @@ function saveTemplate(t: EmailTemplate) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PartnerEditIdentityModal :open="identityOpen" @close="identityOpen = false" />
|
<PartnerEditIdentityModal
|
||||||
|
:open="identityOpen"
|
||||||
|
:identity="identity"
|
||||||
|
@close="identityOpen = false"
|
||||||
|
@save="saveIdentity"
|
||||||
|
/>
|
||||||
|
|
||||||
<PartnerEmailTemplateEditor
|
<PartnerEmailTemplateEditor
|
||||||
:template="editing"
|
:template="editing"
|
||||||
brand-color="#3F6BFF"
|
:brand-color="identity.primaryColor || '#3F6BFF'"
|
||||||
brand-name="NordicMSP"
|
:brand-name="identity.displayName || 'Your brand'"
|
||||||
@close="editing = null"
|
@close="editing = null"
|
||||||
@save="saveTemplate"
|
@save="saveTemplate"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
import type { CustomerOrg, CustomerStatus } from '~/data/customers'
|
import type { CustomerOrg, CustomerStatus, PartnerTenantDoc } from '~/types/partner'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -19,53 +19,15 @@ const planFilter = ref<'all' | 'starter' | 'business' | 'enterprise'>('all')
|
|||||||
const wizardOpen = ref(false)
|
const wizardOpen = ref(false)
|
||||||
const entryCustomer = ref<CustomerOrg | null>(null)
|
const entryCustomer = ref<CustomerOrg | null>(null)
|
||||||
|
|
||||||
// Real tenants attached to this partner (via /api/partner/tenants → platform-api
|
// Real tenants attached to this partner, via the shared composable which owns
|
||||||
// /users/me/partner/tenants). The backend doesn't yet store health-score,
|
// the canonical PartnerTenantDoc and the 'partner-tenants' cache key (dashboard
|
||||||
// MRR, or industry, so those render as placeholders. Plan + seats now come
|
// + customers reuse one fetch). Health score, industry, and brand colour now
|
||||||
// from real fields.
|
// come from real fields on the response.
|
||||||
interface PartnerTenantDoc {
|
const { tenants: rawTenants, refresh: refreshTenants } = usePartnerTenants()
|
||||||
_id: string
|
|
||||||
slug: string
|
|
||||||
name: string
|
|
||||||
status: 'active' | 'pending' | 'suspended' | 'deleted'
|
|
||||||
plan?: 'mvp' | 'pro' | 'enterprise'
|
|
||||||
seats?: number
|
|
||||||
// Active User docs whose tenantIds include this tenant. Comes from
|
|
||||||
// /api/partner/tenants (server-side aggregation), so the column can show
|
|
||||||
// "used / total" without a second client round-trip.
|
|
||||||
userCount?: number
|
|
||||||
domains?: string[]
|
|
||||||
createdAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: rawTenants, refresh: refreshTenants } = await useFetch<PartnerTenantDoc[]>(
|
// Per-tenant MRR from the shared composable (same 'partner-mrr' cache key as the
|
||||||
'/api/partner/tenants',
|
// dashboard). mrrByTenant is the tenantId → breakdown-row lookup the table reads.
|
||||||
{ key: 'partner-tenants', default: () => [] },
|
const { mrrByTenant, refresh: refreshMrr } = usePartnerMrr()
|
||||||
)
|
|
||||||
|
|
||||||
// Per-tenant MRR comes from the same aggregation that powers the dashboard
|
|
||||||
// MRR card. We reuse the cached response (same key) instead of issuing a
|
|
||||||
// second fetch; the customers page just reads the breakdown to fill the MRR
|
|
||||||
// column per row.
|
|
||||||
interface MrrBreakdownRow {
|
|
||||||
tenantId: string
|
|
||||||
currency: 'DKK' | 'EUR' | 'USD'
|
|
||||||
monthlyMinor: number
|
|
||||||
custom: boolean
|
|
||||||
}
|
|
||||||
interface MrrResponse {
|
|
||||||
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
|
||||||
breakdown: MrrBreakdownRow[]
|
|
||||||
}
|
|
||||||
const { data: mrr, refresh: refreshMrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
|
|
||||||
key: 'partner-mrr',
|
|
||||||
default: () => ({ totals: [], breakdown: [] }),
|
|
||||||
})
|
|
||||||
const mrrByTenant = computed(() => {
|
|
||||||
const m = new Map<string, MrrBreakdownRow>()
|
|
||||||
for (const row of mrr.value?.breakdown ?? []) m.set(row.tenantId, row)
|
|
||||||
return m
|
|
||||||
})
|
|
||||||
|
|
||||||
function mapTenantStatus(s: PartnerTenantDoc['status']): CustomerStatus {
|
function mapTenantStatus(s: PartnerTenantDoc['status']): CustomerStatus {
|
||||||
// Tenant.status values overlap partially with the fixture's CustomerStatus.
|
// Tenant.status values overlap partially with the fixture's CustomerStatus.
|
||||||
@@ -92,6 +54,7 @@ const PLAN_INFO: Record<
|
|||||||
// the amount (the original fixture only had a DKK number, but real data
|
// the amount (the original fixture only had a DKK number, but real data
|
||||||
// can be in DKK/EUR/USD).
|
// can be in DKK/EUR/USD).
|
||||||
interface CustomerRow extends CustomerOrg {
|
interface CustomerRow extends CustomerOrg {
|
||||||
|
slug: string
|
||||||
mrrCurrency: 'DKK' | 'EUR' | 'USD'
|
mrrCurrency: 'DKK' | 'EUR' | 'USD'
|
||||||
mrrCustom: boolean
|
mrrCustom: boolean
|
||||||
}
|
}
|
||||||
@@ -102,6 +65,7 @@ const customers = computed<CustomerRow[]>(() =>
|
|||||||
const subMrr = mrrByTenant.value.get(t._id)
|
const subMrr = mrrByTenant.value.get(t._id)
|
||||||
return {
|
return {
|
||||||
id: t._id,
|
id: t._id,
|
||||||
|
slug: t.slug,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
|
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
|
||||||
plan: info.slug,
|
plan: info.slug,
|
||||||
@@ -109,15 +73,15 @@ const customers = computed<CustomerRow[]>(() =>
|
|||||||
// Real seat utilisation: count of active User docs attached to this
|
// Real seat utilisation: count of active User docs attached to this
|
||||||
// tenant vs the contractual seat total from provisioning.
|
// tenant vs the contractual seat total from provisioning.
|
||||||
seats: { used: t.userCount ?? 0, total: t.seats ?? 0 },
|
seats: { used: t.userCount ?? 0, total: t.seats ?? 0 },
|
||||||
health: 100,
|
health: t.healthScore ?? 100,
|
||||||
status: mapTenantStatus(t.status),
|
status: mapTenantStatus(t.status),
|
||||||
// MRR for this tenant, in major units. From /api/partner/mrr; 0 when
|
// MRR for this tenant, in major units. From /api/partner/mrr; 0 when
|
||||||
// the sub has no priced amount (Enterprise / pre-catalog tenants).
|
// the sub has no priced amount (Enterprise / pre-catalog tenants).
|
||||||
mrrDkk: subMrr ? Math.round(subMrr.monthlyMinor / 100) : 0,
|
mrrDkk: subMrr ? Math.round(subMrr.monthlyMinor / 100) : 0,
|
||||||
mrrCurrency: subMrr?.currency ?? 'DKK',
|
mrrCurrency: subMrr?.currency ?? 'DKK',
|
||||||
mrrCustom: subMrr?.custom ?? false,
|
mrrCustom: subMrr?.custom ?? false,
|
||||||
brandColor: '#D4FF3A',
|
brandColor: t.brandColor || '#D4FF3A',
|
||||||
industry: '—',
|
industry: t.industry ?? '—',
|
||||||
createdOn: t.createdAt ?? '',
|
createdOn: t.createdAt ?? '',
|
||||||
since: t.createdAt ?? '',
|
since: t.createdAt ?? '',
|
||||||
}
|
}
|
||||||
@@ -186,6 +150,69 @@ function confirmEnter(reason: string) {
|
|||||||
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
|
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
|
||||||
router.push('/admin')
|
router.push('/admin')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Edit / suspend a customer ─────────────────────────────────────────────
|
||||||
|
const editCustomer = ref<CustomerRow | null>(null)
|
||||||
|
const editForm = reactive({ name: '', industry: '', brandColor: '#D4FF3A', seats: 0 })
|
||||||
|
const savingEdit = ref(false)
|
||||||
|
|
||||||
|
function startEdit(c: CustomerRow) {
|
||||||
|
editCustomer.value = c
|
||||||
|
editForm.name = c.name
|
||||||
|
editForm.industry = c.industry === '—' ? '' : c.industry
|
||||||
|
editForm.brandColor = c.brandColor
|
||||||
|
editForm.seats = c.seats.total
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([
|
||||||
|
refreshTenants(),
|
||||||
|
refreshMrr(),
|
||||||
|
refreshNuxtData('partner-tenants'),
|
||||||
|
refreshNuxtData('partner-mrr'),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editCustomer.value) return
|
||||||
|
if (!editForm.name.trim()) {
|
||||||
|
toast.bad('Name required', 'Customer name cannot be empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
savingEdit.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/partner/tenants/${editCustomer.value.slug}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
name: editForm.name,
|
||||||
|
industry: editForm.industry,
|
||||||
|
brandColor: editForm.brandColor,
|
||||||
|
seats: editForm.seats,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toast.ok('Saved', `${editForm.name} updated`)
|
||||||
|
editCustomer.value = null
|
||||||
|
await refreshAll()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||||
|
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save customer')
|
||||||
|
} finally {
|
||||||
|
savingEdit.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSuspend(c: CustomerRow) {
|
||||||
|
const action = c.status === 'suspended' ? 'resume' : 'suspend'
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/partner/tenants/${c.slug}/${action}`, { method: 'POST' })
|
||||||
|
toast.ok(action === 'suspend' ? 'Suspended' : 'Resumed', c.name)
|
||||||
|
editCustomer.value = null
|
||||||
|
await refreshAll()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||||
|
toast.bad('Action failed', err.data?.message || err.statusMessage || 'Could not update customer')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -283,10 +310,13 @@ function confirmEnter(reason: string) {
|
|||||||
</td>
|
</td>
|
||||||
<td><Mono dim>{{ c.since }}</Mono></td>
|
<td><Mono dim>{{ c.since }}</Mono></td>
|
||||||
<td class="action-col" @click.stop>
|
<td class="action-col" @click.stop>
|
||||||
|
<div class="row-actions">
|
||||||
|
<UiButton size="sm" variant="ghost" @click="startEdit(c)">Edit</UiButton>
|
||||||
<UiButton size="sm" variant="secondary" @click="startEnter(c)">
|
<UiButton size="sm" variant="secondary" @click="startEnter(c)">
|
||||||
Manage
|
Manage
|
||||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||||
</UiButton>
|
</UiButton>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="filtered.length === 0">
|
<tr v-if="filtered.length === 0">
|
||||||
@@ -347,6 +377,33 @@ function confirmEnter(reason: string) {
|
|||||||
@close="entryCustomer = null"
|
@close="entryCustomer = null"
|
||||||
@confirm="confirmEnter"
|
@confirm="confirmEnter"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
:open="!!editCustomer"
|
||||||
|
eyebrow="Customer"
|
||||||
|
:title="`Edit ${editCustomer?.name ?? ''}`"
|
||||||
|
size="md"
|
||||||
|
@close="editCustomer = null"
|
||||||
|
>
|
||||||
|
<div class="edit-form">
|
||||||
|
<label class="field"><Eyebrow>Name</Eyebrow><input v-model="editForm.name" /></label>
|
||||||
|
<label class="field"><Eyebrow>Industry</Eyebrow><input v-model="editForm.industry" placeholder="e.g. Logistics" /></label>
|
||||||
|
<div class="row-2">
|
||||||
|
<label class="field"><Eyebrow>Brand color</Eyebrow><input v-model="editForm.brandColor" placeholder="#3F6BFF" /></label>
|
||||||
|
<label class="field"><Eyebrow>Seats</Eyebrow><input v-model.number="editForm.seats" type="number" min="0" /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<UiButton
|
||||||
|
v-if="editCustomer"
|
||||||
|
:variant="editCustomer.status === 'suspended' ? 'secondary' : 'danger'"
|
||||||
|
@click="toggleSuspend(editCustomer)"
|
||||||
|
>{{ editCustomer.status === 'suspended' ? 'Resume customer' : 'Suspend customer' }}</UiButton>
|
||||||
|
<div style="flex: 1" />
|
||||||
|
<UiButton variant="ghost" @click="editCustomer = null">Cancel</UiButton>
|
||||||
|
<UiButton variant="primary" :disabled="savingEdit" @click="saveEdit">{{ savingEdit ? 'Saving…' : 'Save changes' }}</UiButton>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -447,7 +504,22 @@ function confirmEnter(reason: string) {
|
|||||||
}
|
}
|
||||||
.dtable th.sortable :deep(svg) { opacity: 0.5; margin-left: 4px; vertical-align: middle; }
|
.dtable th.sortable :deep(svg) { opacity: 0.5; margin-left: 4px; vertical-align: middle; }
|
||||||
.dtable th.num, .dtable td.num { text-align: right; }
|
.dtable th.num, .dtable td.num { text-align: right; }
|
||||||
.dtable th.action-col, .dtable td.action-col { width: 120px; text-align: right; }
|
.dtable th.action-col, .dtable td.action-col { width: 170px; text-align: right; }
|
||||||
|
.row-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; }
|
||||||
|
|
||||||
|
.edit-form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.edit-form .field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.edit-form .row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.edit-form input {
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.edit-form input:focus { outline: none; border-color: var(--border-hi); }
|
||||||
|
|
||||||
.dtable td {
|
.dtable td {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { customers, partnerMrrSparkline, partner as fixturePartner } from '~/data/customers'
|
import { partnerMrrSparkline, partner as fixturePartner } from '~/data/customers'
|
||||||
import type { CustomerOrg } from '~/data/customers'
|
import type { CustomerOrg } from '~/types/partner'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -109,22 +109,50 @@ const sparkline = partnerMrrSparkline
|
|||||||
const sparkLast = sparkline[sparkline.length - 1]
|
const sparkLast = sparkline[sparkline.length - 1]
|
||||||
const sparkTrendPct = '18.2' // matches source label
|
const sparkTrendPct = '18.2' // matches source label
|
||||||
|
|
||||||
// Attention list · partner-screens.jsx line 207-212
|
// Attention list — derived from real tenant state (no fixtures). Surfaces
|
||||||
const alerts = [
|
// suspended customers, provisioning errors, seat pressure, and pending/trial
|
||||||
{ id: 'a-bygherre', tone: 'bad' as const, cust: 'Bygherre Cloud', msg: 'Invoice 21 days past due · 2.940 DKK', action: 'Review', custId: 'c-bygherre' },
|
// tenants. Each links to /partner/customers.
|
||||||
{ id: 'a-henriksen', tone: 'warn' as const, cust: 'Henriksen Revision', msg: 'SPF record missing on h-revision.dk', action: 'Fix DNS', custId: 'c-henriksen' },
|
interface DashAlert {
|
||||||
{ id: 'a-aalborg', tone: 'warn' as const, cust: 'Aalborg Logistik', msg: 'Approaching seat limit · 87/100 used', action: 'Upsell', custId: 'c-aalborg' },
|
id: string
|
||||||
{ id: 'a-norrebro', tone: 'info' as const, cust: 'Nørrebro Studio', msg: 'Trial ends in 7 days', action: 'Follow up', custId: 'c-norrebro' },
|
tone: 'bad' | 'warn' | 'info'
|
||||||
]
|
cust: string
|
||||||
|
msg: string
|
||||||
// Recent activity · partner-screens.jsx line 332-336
|
action: string
|
||||||
const activity = [
|
slug: string
|
||||||
{ when: '14:02', cust: 'Acme Workspace', who: 'Anne Baslund', action: 'invited 3 users', tone: 'info' as const },
|
}
|
||||||
{ when: '12:18', cust: 'Bygherre Cloud', who: 'system', action: 'invoice marked past-due', tone: 'bad' as const },
|
const derivedAlerts = computed<DashAlert[]>(() => {
|
||||||
{ when: '11:44', cust: 'Aalborg Logistik', who: 'Sofie Lindberg', action: 'upgraded to Enterprise', tone: 'ok' as const },
|
const out: DashAlert[] = []
|
||||||
{ when: '10:08', cust: 'Nørrebro Studio', who: 'NordicMSP', action: 'created new customer org', tone: 'info' as const },
|
for (const t of tenants.value ?? []) {
|
||||||
{ when: '09:34', cust: 'Henriksen Revision', who: 'system', action: 'DNS health alert · SPF', tone: 'warn' as const },
|
if (t.status === 'suspended') {
|
||||||
]
|
out.push({ id: `susp-${t._id}`, tone: 'bad', cust: t.name, msg: 'Customer suspended', action: 'Review', slug: t.slug })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const errored = Object.entries(t.provisioningStatus ?? {})
|
||||||
|
.filter(([, s]) => s === 'error')
|
||||||
|
.map(([k]) => k)
|
||||||
|
if (errored.length) {
|
||||||
|
out.push({ id: `prov-${t._id}`, tone: 'bad', cust: t.name, msg: `Provisioning error · ${errored.join(', ')}`, action: 'Reconcile', slug: t.slug })
|
||||||
|
}
|
||||||
|
const seats = t.seats ?? 0
|
||||||
|
const used = t.userCount ?? 0
|
||||||
|
if (seats > 0 && used / seats > 0.85) {
|
||||||
|
out.push({ id: `seat-${t._id}`, tone: 'warn', cust: t.name, msg: `Approaching seat limit · ${used}/${seats} used`, action: 'Upsell', slug: t.slug })
|
||||||
|
}
|
||||||
|
if (t.status === 'pending') {
|
||||||
|
out.push({ id: `pend-${t._id}`, tone: 'info', cust: t.name, msg: 'Awaiting provisioning', action: 'Follow up', slug: t.slug })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
const alertCounts = computed(() => ({
|
||||||
|
bad: derivedAlerts.value.filter((a) => a.tone === 'bad').length,
|
||||||
|
warn: derivedAlerts.value.filter((a) => a.tone === 'warn').length,
|
||||||
|
}))
|
||||||
|
const issuesHint = computed(() => {
|
||||||
|
const { bad, warn } = alertCounts.value
|
||||||
|
if (bad === 0 && warn === 0) return 'all clear'
|
||||||
|
return `${bad} critical · ${warn} warning`
|
||||||
|
})
|
||||||
|
|
||||||
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
|
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
@@ -148,12 +176,8 @@ function confirmEnter(reason: string) {
|
|||||||
router.push('/admin')
|
router.push('/admin')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAlert(a: typeof alerts[number]) {
|
function onAlert(_a: DashAlert) {
|
||||||
toast.ok(`${a.action}: ${a.cust}`, 'Workflow stub fired')
|
router.push('/partner/customers')
|
||||||
}
|
|
||||||
|
|
||||||
function activitySwatch(name: string) {
|
|
||||||
return customers.find((c) => c.name === name)?.brandColor || 'var(--text-mute)'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Real health + activity (replace fixture cards) ───────────────────────
|
// ── Real health + activity (replace fixture cards) ───────────────────────
|
||||||
@@ -295,7 +319,7 @@ function provisioned() {
|
|||||||
<Stat label="End users" :value="totalUsers" :delta="usersDelta" delta-tone="up" />
|
<Stat label="End users" :value="totalUsers" :delta="usersDelta" delta-tone="up" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<Stat label="Issues" :value="alerts.length" hint="1 critical · 2 warning" />
|
<Stat label="Issues" :value="derivedAlerts.length" :hint="issuesHint" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -336,9 +360,12 @@ function provisioned() {
|
|||||||
<div class="card-title">What needs your attention</div>
|
<div class="card-title">What needs your attention</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="attn-list">
|
<div v-if="derivedAlerts.length === 0" class="empty-state">
|
||||||
|
<Mono dim>// nothing needs attention right now</Mono>
|
||||||
|
</div>
|
||||||
|
<div v-else class="attn-list">
|
||||||
<div
|
<div
|
||||||
v-for="a in alerts"
|
v-for="a in derivedAlerts"
|
||||||
:key="a.id"
|
:key="a.id"
|
||||||
class="attn-row"
|
class="attn-row"
|
||||||
:style="{ borderLeftColor: `var(--${a.tone})` }"
|
:style="{ borderLeftColor: `var(--${a.tone})` }"
|
||||||
|
|||||||
@@ -18,36 +18,120 @@ const tabs = [
|
|||||||
{ value: 'notifications', label: 'Notifications' },
|
{ value: 'notifications', label: 'Notifications' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Contact info (editable but kept simple — strict port focuses on layout)
|
interface NotificationPref {
|
||||||
const contact = reactive({
|
event: string
|
||||||
legalName: 'NordicMSP ApS',
|
cadence: string
|
||||||
tradingName: 'NordicMSP',
|
channels: string[]
|
||||||
address: 'Vesterport 12, 1620 København V',
|
}
|
||||||
country: 'DK',
|
interface PartnerSettings {
|
||||||
primaryEmail: 'partners@nordicmsp.dk',
|
name?: string
|
||||||
primaryPhone: '+45 70 70 12 34',
|
domain?: string
|
||||||
supportHotline: '+45 70 70 12 35',
|
marginPct?: number
|
||||||
website: 'nordicmsp.dk',
|
contactInfo?: { primaryName?: string; primaryEmail?: string; billingEmail?: string }
|
||||||
|
billingInfo?: { companyName?: string; vatId?: string; country?: string; contactEmail?: string }
|
||||||
|
profile?: {
|
||||||
|
legalName?: string
|
||||||
|
tradingName?: string
|
||||||
|
address?: string
|
||||||
|
country?: string
|
||||||
|
primaryEmail?: string
|
||||||
|
primaryPhone?: string
|
||||||
|
supportHotline?: string
|
||||||
|
website?: string
|
||||||
|
}
|
||||||
|
notificationPrefs?: NotificationPref[]
|
||||||
|
agreement?: {
|
||||||
|
tier?: string
|
||||||
|
payoutCadence?: string
|
||||||
|
effectiveAt?: string
|
||||||
|
termMonths?: number
|
||||||
|
noticePeriodDays?: number
|
||||||
|
liabilityCap?: string
|
||||||
|
governingLaw?: string
|
||||||
|
signedBy?: string
|
||||||
|
}
|
||||||
|
documents?: Array<{ name: string; url?: string; kind?: string; size?: string; uploadedAt?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: settings, refresh } = await useFetch<PartnerSettings>('/api/partner/settings', {
|
||||||
|
key: 'partner-settings',
|
||||||
|
default: () => ({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Documents · platform-partner-depth.jsx:922-927
|
// Editable copy of the contact profile, seeded from the fetched settings
|
||||||
const docs = [
|
// (falling back to the partner's name/domain/contactInfo where the dedicated
|
||||||
{ n: 'Reseller agreement · v2025.11.pdf', size: '184 KB', date: '14 Nov 2025' },
|
// profile fields aren't set yet).
|
||||||
{ n: 'DPA · Data Processing Addendum.pdf', size: '92 KB', date: '14 Jan 2024' },
|
const contact = reactive({
|
||||||
{ n: 'Service Level Agreement.pdf', size: '64 KB', date: '14 Jan 2024' },
|
legalName: '',
|
||||||
{ n: 'Margin schedule · v2025.11.xlsx', size: '24 KB', date: '14 Nov 2025' },
|
tradingName: '',
|
||||||
]
|
address: '',
|
||||||
|
country: 'DK',
|
||||||
|
primaryEmail: '',
|
||||||
|
primaryPhone: '',
|
||||||
|
supportHotline: '',
|
||||||
|
website: '',
|
||||||
|
})
|
||||||
|
const savingContact = ref(false)
|
||||||
|
function syncContact() {
|
||||||
|
// Don't clobber the form mid-save — the post-save refetch would otherwise
|
||||||
|
// reset fields the user may have kept editing.
|
||||||
|
if (savingContact.value) return
|
||||||
|
const s = settings.value ?? {}
|
||||||
|
const p = s.profile ?? {}
|
||||||
|
contact.legalName = p.legalName ?? s.billingInfo?.companyName ?? s.name ?? ''
|
||||||
|
contact.tradingName = p.tradingName ?? s.name ?? ''
|
||||||
|
contact.address = p.address ?? ''
|
||||||
|
contact.country = p.country ?? s.billingInfo?.country ?? 'DK'
|
||||||
|
contact.primaryEmail = p.primaryEmail ?? s.contactInfo?.primaryEmail ?? ''
|
||||||
|
contact.primaryPhone = p.primaryPhone ?? ''
|
||||||
|
contact.supportHotline = p.supportHotline ?? ''
|
||||||
|
contact.website = p.website ?? s.domain ?? ''
|
||||||
|
}
|
||||||
|
syncContact()
|
||||||
|
watch(settings, syncContact)
|
||||||
|
|
||||||
// Notifications · platform-partner-depth.jsx:1013-1020
|
async function saveContact() {
|
||||||
const events = [
|
savingContact.value = true
|
||||||
{ event: 'New customer signed up', when: 'immediate', channels: 'email · chat' },
|
try {
|
||||||
{ event: 'Customer past-due invoice', when: 'immediate', channels: 'email · in-app' },
|
await $fetch('/api/partner/settings', { method: 'PATCH', body: { profile: { ...contact } } })
|
||||||
{ event: 'Customer approaching limit', when: 'daily', channels: 'email' },
|
toast.ok('Saved', 'Contact info updated')
|
||||||
{ event: 'Customer downgrade or churn', when: 'immediate', channels: 'email · chat · in-app' },
|
await Promise.all([refresh(), refreshNuxtData('partner-settings')])
|
||||||
{ event: 'Payout processed', when: 'immediate', channels: 'email' },
|
} catch (e: unknown) {
|
||||||
{ event: 'New ticket from a customer', when: 'immediate', channels: 'chat' },
|
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||||
{ event: 'Dezky agreement change', when: 'immediate', channels: 'email' },
|
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save contact info')
|
||||||
|
} finally {
|
||||||
|
savingContact.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const marginPct = computed(() => settings.value?.marginPct ?? 0)
|
||||||
|
|
||||||
|
// Documents — operator-managed; empty until uploaded.
|
||||||
|
const docs = computed(() =>
|
||||||
|
(settings.value?.documents ?? []).map((d) => ({
|
||||||
|
n: d.name,
|
||||||
|
size: d.size ?? '',
|
||||||
|
date: d.uploadedAt ? new Date(d.uploadedAt).toLocaleDateString('da-DK') : '',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifications — real prefs, falling back to the standard event set when the
|
||||||
|
// partner hasn't customised them yet.
|
||||||
|
const DEFAULT_NOTIFICATIONS: NotificationPref[] = [
|
||||||
|
{ event: 'New customer signed up', cadence: 'immediate', channels: ['email', 'chat'] },
|
||||||
|
{ event: 'Customer past-due invoice', cadence: 'immediate', channels: ['email', 'in-app'] },
|
||||||
|
{ event: 'Customer approaching limit', cadence: 'daily', channels: ['email'] },
|
||||||
|
{ event: 'Customer downgrade or churn', cadence: 'immediate', channels: ['email', 'chat', 'in-app'] },
|
||||||
|
{ event: 'Payout processed', cadence: 'immediate', channels: ['email'] },
|
||||||
|
{ event: 'New ticket from a customer', cadence: 'immediate', channels: ['chat'] },
|
||||||
|
{ event: 'Dezky agreement change', cadence: 'immediate', channels: ['email'] },
|
||||||
]
|
]
|
||||||
|
const events = computed(() =>
|
||||||
|
(settings.value?.notificationPrefs?.length
|
||||||
|
? settings.value.notificationPrefs
|
||||||
|
: DEFAULT_NOTIFICATIONS
|
||||||
|
).map((n) => ({ event: n.event, when: n.cadence, channels: n.channels.join(' · ') })),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -83,7 +167,7 @@ const events = [
|
|||||||
<div class="agree-grid">
|
<div class="agree-grid">
|
||||||
<dl class="def">
|
<dl class="def">
|
||||||
<div><dt>Tier</dt><dd>Tier 2 · Established</dd></div>
|
<div><dt>Tier</dt><dd>Tier 2 · Established</dd></div>
|
||||||
<div><dt>Default margin</dt><dd>20% on Starter & Business</dd></div>
|
<div><dt>Default margin</dt><dd>{{ marginPct }}% on all plans</dd></div>
|
||||||
<div><dt>Enterprise margin</dt><dd>Negotiated · 15% baseline</dd></div>
|
<div><dt>Enterprise margin</dt><dd>Negotiated · 15% baseline</dd></div>
|
||||||
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
|
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
|
||||||
<div><dt>Payout cadence</dt><dd>Monthly · 3rd business day</dd></div>
|
<div><dt>Payout cadence</dt><dd>Monthly · 3rd business day</dd></div>
|
||||||
@@ -104,6 +188,7 @@ const events = [
|
|||||||
<Eyebrow>Documents</Eyebrow>
|
<Eyebrow>Documents</Eyebrow>
|
||||||
<div class="card-title">Related files</div>
|
<div class="card-title">Related files</div>
|
||||||
<div class="doc-list">
|
<div class="doc-list">
|
||||||
|
<Mono v-if="docs.length === 0" dim>// no documents uploaded yet</Mono>
|
||||||
<button
|
<button
|
||||||
v-for="d in docs"
|
v-for="d in docs"
|
||||||
:key="d.n"
|
:key="d.n"
|
||||||
@@ -127,7 +212,7 @@ const events = [
|
|||||||
<Eyebrow>Business</Eyebrow>
|
<Eyebrow>Business</Eyebrow>
|
||||||
<div class="card-title">NordicMSP company info</div>
|
<div class="card-title">NordicMSP company info</div>
|
||||||
</div>
|
</div>
|
||||||
<UiButton size="sm" variant="ghost" @click="toast.ok('Saved', 'Contact info updated')">Edit</UiButton>
|
<UiButton size="sm" variant="primary" :disabled="savingContact" @click="saveContact">{{ savingContact ? 'Saving…' : 'Save changes' }}</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-grid">
|
<div class="contact-grid">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { customers } from '~/data/customers'
|
|
||||||
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
|
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -13,11 +12,9 @@ const toast = useToast()
|
|||||||
const inviteOpen = ref(false)
|
const inviteOpen = ref(false)
|
||||||
const openMember = ref<TeamMember | null>(null)
|
const openMember = ref<TeamMember | null>(null)
|
||||||
|
|
||||||
// Real partner team from platform-api (proxied via /api/partner/users).
|
// Real partner team from platform-api (proxied via /api/partner/users). The
|
||||||
// Falls back to an empty list while the request is in flight. Each row's
|
// enriched response adds mfaEnabled (live Authentik lookup), accessLevel, and
|
||||||
// access/mfa fields are placeholders until per-user access controls and
|
// accessCount per user.
|
||||||
// Authentik MFA introspection land — the underlying User doc only stores
|
|
||||||
// identity + tenantIds + partnerId today.
|
|
||||||
interface PartnerUserDoc {
|
interface PartnerUserDoc {
|
||||||
_id: string
|
_id: string
|
||||||
authentikSubjectId: string
|
authentikSubjectId: string
|
||||||
@@ -27,12 +24,19 @@ interface PartnerUserDoc {
|
|||||||
active: boolean
|
active: boolean
|
||||||
lastLoginAt?: string
|
lastLoginAt?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
mfaEnabled?: boolean | null
|
||||||
|
accessLevel?: 'all' | 'scoped'
|
||||||
|
accessCount?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: rawTeam } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
|
const { data: rawTeam, refresh } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
|
||||||
|
key: 'partner-users',
|
||||||
default: () => [],
|
default: () => [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Real customer count for the "all (N)" / "N of M" access labels.
|
||||||
|
const { tenants } = usePartnerTenants()
|
||||||
|
|
||||||
function lastSeenLabel(iso?: string): string {
|
function lastSeenLabel(iso?: string): string {
|
||||||
if (!iso) return 'never'
|
if (!iso) return 'never'
|
||||||
const ms = Date.now() - new Date(iso).getTime()
|
const ms = Date.now() - new Date(iso).getTime()
|
||||||
@@ -51,24 +55,45 @@ const members = computed<TeamMember[]>(() =>
|
|||||||
name: u.name,
|
name: u.name,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
role: u.role === 'admin' ? 'Partner admin' : u.role === 'owner' ? 'Owner' : 'Partner staff',
|
role: u.role === 'admin' ? 'Partner admin' : u.role === 'owner' ? 'Owner' : 'Partner staff',
|
||||||
access: 'all',
|
access: u.accessLevel === 'scoped' ? 'specific' : 'all',
|
||||||
mfa: '—',
|
accessCount: u.accessCount ?? null,
|
||||||
|
mfa: u.mfaEnabled === true ? 'enabled' : u.mfaEnabled === false ? 'disabled' : 'unknown',
|
||||||
lastSeen: lastSeenLabel(u.lastLoginAt),
|
lastSeen: lastSeenLabel(u.lastLoginAt),
|
||||||
isOwner: u.role === 'owner',
|
isOwner: u.role === 'owner',
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
function accessLabel(m: TeamMember) {
|
function accessLabel(m: TeamMember) {
|
||||||
if (m.access === 'all') return `all (${customers.length})`
|
const total = tenants.value?.length ?? 0
|
||||||
if (m.access === 'none') return 'no access'
|
if (m.access === 'none') return 'no access'
|
||||||
// Specific count for fixtures: Mikkel = 6, Oliver = 3
|
if (m.access === 'all') return `all (${total})`
|
||||||
if (m.email === 'mikkel@nordicmsp.dk') return `6 of ${customers.length}`
|
return `${m.accessCount ?? 0} of ${total}`
|
||||||
if (m.email === 'oliver@nordicmsp.dk') return `3 of ${customers.length}`
|
|
||||||
return `${customers.length - 5} of ${customers.length}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSent(payload: { email: string; role: string }) {
|
async function onSent(payload: { name: string; email: string; role: string }) {
|
||||||
toast.ok('Invitation sent', `${payload.role} invite to ${payload.email}`)
|
try {
|
||||||
|
await $fetch('/api/partner/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: payload.name, email: payload.email },
|
||||||
|
})
|
||||||
|
toast.ok('Invitation sent', `Invite sent to ${payload.email}`)
|
||||||
|
await Promise.all([refresh(), refreshNuxtData('partner-users')])
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||||
|
toast.bad('Invite failed', err.data?.message || err.statusMessage || 'Could not send invitation')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(m: TeamMember) {
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/partner/users/${m.id}`, { method: 'DELETE' })
|
||||||
|
toast.ok('Removed', `${m.name} removed from the team`)
|
||||||
|
openMember.value = null
|
||||||
|
await Promise.all([refresh(), refreshNuxtData('partner-users')])
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||||
|
toast.bad('Remove failed', err.data?.message || err.statusMessage || 'Could not remove teammate')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row actions popover · mirrors PartnerTeammateRowActions (lines 1431-1524).
|
// Row actions popover · mirrors PartnerTeammateRowActions (lines 1431-1524).
|
||||||
@@ -93,7 +118,7 @@ function actionsFor(m: TeamMember) {
|
|||||||
{ i: 'key', l: 'Reset password', fn: () => toast.info('Password reset', `Email sent to ${m.email}`) },
|
{ i: 'key', l: 'Reset password', fn: () => toast.info('Password reset', `Email sent to ${m.email}`) },
|
||||||
{ sep: true },
|
{ sep: true },
|
||||||
{ i: 'x', l: 'Suspend account', fn: () => toast.warn('Account suspended', m.name), disabled: m.isOwner },
|
{ i: 'x', l: 'Suspend account', fn: () => toast.warn('Account suspended', m.name), disabled: m.isOwner },
|
||||||
{ i: 'trash', l: 'Remove from team', danger: true, fn: () => toast.bad('Removal pending', `${m.name} will be removed`), disabled: m.isOwner },
|
{ i: 'trash', l: 'Remove from team', danger: true, fn: () => removeMember(m), disabled: m.isOwner },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +187,7 @@ onMounted(() => {
|
|||||||
<Badge :tone="m.role === 'Partner admin' ? 'invert' : 'neutral'">{{ m.role }}</Badge>
|
<Badge :tone="m.role === 'Partner admin' ? 'invert' : 'neutral'">{{ m.role }}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td><Mono>{{ accessLabel(m) }}</Mono></td>
|
<td><Mono>{{ accessLabel(m) }}</Mono></td>
|
||||||
<td><Badge tone="ok" dot>enabled</Badge></td>
|
<td><Badge :tone="m.mfa === 'enabled' ? 'ok' : m.mfa === 'disabled' ? 'warn' : 'neutral'" dot>{{ m.mfa }}</Badge></td>
|
||||||
<td><Mono dim>{{ m.lastSeen }}</Mono></td>
|
<td><Mono dim>{{ m.lastSeen }}</Mono></td>
|
||||||
<td class="action-col" @click.stop>
|
<td class="action-col" @click.stop>
|
||||||
<button class="kebab" @click="openMenu(m, $event)">
|
<button class="kebab" @click="openMenu(m, $event)">
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Partner whitelabel branding. Forwards to platform-api GET /me/partner/branding.
|
||||||
|
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/branding`, {
|
||||||
|
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 @@
|
|||||||
|
// Replace partner whitelabel branding. Forwards to platform-api
|
||||||
|
// PUT /me/partner/branding.
|
||||||
|
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/branding`, {
|
||||||
|
method: 'PUT',
|
||||||
|
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,20 @@
|
|||||||
|
// Partner settings. Forwards to platform-api GET /me/partner/settings.
|
||||||
|
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/settings`, {
|
||||||
|
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 @@
|
|||||||
|
// Update partner settings (profile + notification prefs). Forwards to
|
||||||
|
// platform-api PATCH /me/partner/settings.
|
||||||
|
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/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
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,25 @@
|
|||||||
|
// Edit a partner-owned customer tenant. Forwards to platform-api
|
||||||
|
// PATCH /me/partner/tenants/:slug (ownership enforced server-side).
|
||||||
|
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 slug = getRouterParam(event, 'slug')
|
||||||
|
const body = await readBody(event)
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
try {
|
||||||
|
return await $fetch(`${base}/me/partner/tenants/${slug}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
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 @@
|
|||||||
|
// Resume a suspended partner-owned customer tenant. Forwards to platform-api
|
||||||
|
// POST /me/partner/tenants/:slug/resume (ownership enforced server-side).
|
||||||
|
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 slug = getRouterParam(event, 'slug')
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
try {
|
||||||
|
return await $fetch(`${base}/me/partner/tenants/${slug}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
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,23 @@
|
|||||||
|
// Suspend a partner-owned customer tenant. Forwards to platform-api
|
||||||
|
// POST /me/partner/tenants/:slug/suspend (ownership enforced server-side).
|
||||||
|
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 slug = getRouterParam(event, 'slug')
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
try {
|
||||||
|
return await $fetch(`${base}/me/partner/tenants/${slug}/suspend`, {
|
||||||
|
method: 'POST',
|
||||||
|
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 @@
|
|||||||
|
// Invite a teammate to the partner organization. Forwards to platform-api
|
||||||
|
// POST /me/partner/users (scoped to the caller's partner server-side).
|
||||||
|
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/users`, {
|
||||||
|
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 @@
|
|||||||
|
// Remove a teammate from the partner organization. Forwards to platform-api
|
||||||
|
// DELETE /me/partner/users/:subject (ownership + last-admin guard server-side).
|
||||||
|
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 subject = getRouterParam(event, 'subject')
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
try {
|
||||||
|
return await $fetch(`${base}/me/partner/users/${subject}`, {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -141,6 +141,40 @@ export class AuthentikClient {
|
|||||||
this.logger.log(`Added user ${userPk} to Authentik group ${groupId}`)
|
this.logger.log(`Added user ${userPk} to Authentik group ${groupId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove a user from a group by ID. Authentik 204s even if the user wasn't
|
||||||
|
// a member, so this is effectively idempotent.
|
||||||
|
async removeUserFromGroup(userPk: number, groupId: string): Promise<void> {
|
||||||
|
await this.request(`/core/groups/${groupId}/remove_user/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ pk: userPk }),
|
||||||
|
})
|
||||||
|
this.logger.log(`Removed user ${userPk} from Authentik group ${groupId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count a user's configured authenticators (TOTP / WebAuthn / static). Used
|
||||||
|
// to surface an "MFA enrolled" badge on the partner team list — callers treat
|
||||||
|
// a count > 0 as enrolled. Authentik has no single "all devices" admin route;
|
||||||
|
// it exposes one per device type, so we query the common three and sum. Each
|
||||||
|
// returns a paginated { results } envelope.
|
||||||
|
async countAuthenticators(userPk: number): Promise<number> {
|
||||||
|
const types = ['totp', 'webauthn', 'static']
|
||||||
|
const counts = await Promise.all(
|
||||||
|
types.map(async (t) => {
|
||||||
|
try {
|
||||||
|
const res = await this.request<{ results?: unknown[] }>(
|
||||||
|
`/authenticators/admin/${t}/?user=${userPk}`,
|
||||||
|
)
|
||||||
|
return Array.isArray(res?.results) ? res.results.length : 0
|
||||||
|
} catch {
|
||||||
|
// A device type not enabled on this Authentik instance returns 404 —
|
||||||
|
// don't let it zero out the types that do resolve.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return counts.reduce((a, b) => a + b, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a single-use recovery link the new user clicks to set their
|
// Generate a single-use recovery link the new user clicks to set their
|
||||||
// password + enroll MFA. Requires a "recovery flow" configured on the
|
// password + enroll MFA. Requires a "recovery flow" configured on the
|
||||||
// Authentik brand — if not set, returns undefined so callers can fall
|
// Authentik brand — if not set, returns undefined so callers can fall
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Type } from 'class-transformer'
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator'
|
||||||
|
|
||||||
|
class BrandIdentityDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(120) displayName?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) logoUrl?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) markUrl?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(500) faviconUrl?: string
|
||||||
|
@IsOptional() @IsString() @Matches(/^(#[0-9a-fA-F]{6})?$/, { message: 'primaryColor must be a #rrggbb hex or empty' })
|
||||||
|
primaryColor?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(254) supportEmail?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(40) supportPhone?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) website?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(254) replyTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomerDefaultDto {
|
||||||
|
@IsString() @MaxLength(120) label!: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(300) detail?: string
|
||||||
|
@IsBoolean() on!: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmailTemplateDto {
|
||||||
|
@IsString() @MaxLength(80) id!: string
|
||||||
|
@IsString() @MaxLength(160) name!: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(300) subject?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(20000) body?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(40) edited?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PartnerBrandingDto {
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => BrandIdentityDto)
|
||||||
|
identity?: BrandIdentityDto
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CustomerDefaultDto)
|
||||||
|
customerDefaults?: CustomerDefaultDto[]
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => EmailTemplateDto)
|
||||||
|
emailTemplates?: EmailTemplateDto[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Type } from 'class-transformer'
|
||||||
|
import { IsArray, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator'
|
||||||
|
|
||||||
|
// Partner-editable settings. Margin, agreement terms, and documents are
|
||||||
|
// operator-controlled and intentionally NOT accepted here — a partner can edit
|
||||||
|
// its own contact profile and notification preferences, nothing that changes
|
||||||
|
// the commercial relationship.
|
||||||
|
class PartnerProfileDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) legalName?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) tradingName?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(300) address?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(2) country?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(254) primaryEmail?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(40) primaryPhone?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(40) supportHotline?: string
|
||||||
|
@IsOptional() @IsString() @MaxLength(200) website?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationPrefDto {
|
||||||
|
@IsString() @MaxLength(120) event!: string
|
||||||
|
@IsString() @MaxLength(40) cadence!: string
|
||||||
|
@IsArray() @IsString({ each: true }) channels!: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PartnerSettingsDto {
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => PartnerProfileDto)
|
||||||
|
profile?: PartnerProfileDto
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => NotificationPrefDto)
|
||||||
|
notificationPrefs?: NotificationPrefDto[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsArray, IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min, MinLength } from 'class-validator'
|
||||||
|
|
||||||
|
// Partner self-service tenant edit. Deliberately narrower than the operator
|
||||||
|
// UpdateTenantDto: no `status` (use suspend/resume), no `plan` or `partnerId`
|
||||||
|
// (operator-only — a partner must not move a tenant between partners or change
|
||||||
|
// its plan tier without an operator). Everything here is safe for a partner to
|
||||||
|
// change on a customer they own.
|
||||||
|
export class PartnerUpdateTenantDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(2) @MaxLength(120)
|
||||||
|
name?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsString() @MaxLength(80)
|
||||||
|
industry?: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^(#[0-9a-fA-F]{6})?$/, { message: 'brandColor must be a #rrggbb hex or empty' })
|
||||||
|
brandColor?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @IsString({ each: true })
|
||||||
|
domains?: string[]
|
||||||
|
|
||||||
|
@IsOptional() @IsInt() @Min(0) @Max(10000)
|
||||||
|
seats?: number
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
import { MongooseModule } from '@nestjs/mongoose'
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { AuditModule } from '../audit/audit.module.js'
|
||||||
import { AuthModule } from '../auth/auth.module.js'
|
import { AuthModule } from '../auth/auth.module.js'
|
||||||
|
import { PartnersModule } from '../partners/partners.module.js'
|
||||||
|
import {
|
||||||
|
PartnerBranding,
|
||||||
|
PartnerBrandingSchema,
|
||||||
|
} from '../schemas/partner-branding.schema.js'
|
||||||
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
|
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
|
||||||
|
import { Report, ReportSchema } from '../schemas/report.schema.js'
|
||||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||||
import { UsersModule } from '../users/users.module.js'
|
import { UsersModule } from '../users/users.module.js'
|
||||||
|
import { PartnerBrandingService } from './partner-branding.service.js'
|
||||||
import { PartnerMeController } from './partner-me.controller.js'
|
import { PartnerMeController } from './partner-me.controller.js'
|
||||||
|
import { PartnerReportsService } from './partner-reports.service.js'
|
||||||
|
|
||||||
// Self-service portal surface. Composes UsersService (partner-scoped reads,
|
// Self-service portal surface. Composes UsersService (partner-scoped reads,
|
||||||
// invitePartnerUser, inviteTenantAdmin, partnerMrr, partnerActivity) and
|
// invitePartnerUser, inviteTenantAdmin, partnerMrr, partnerActivity) and
|
||||||
@@ -13,10 +22,17 @@ import { PartnerMeController } from './partner-me.controller.js'
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
AuditModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
MongooseModule.forFeature([{ name: Partner.name, schema: PartnerSchema }]),
|
PartnersModule,
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Partner.name, schema: PartnerSchema },
|
||||||
|
{ name: PartnerBranding.name, schema: PartnerBrandingSchema },
|
||||||
|
{ name: Report.name, schema: ReportSchema },
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
controllers: [PartnerMeController],
|
controllers: [PartnerMeController],
|
||||||
|
providers: [PartnerBrandingService, PartnerReportsService],
|
||||||
})
|
})
|
||||||
export class MeModule {}
|
export class MeModule {}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model, Types } from 'mongoose'
|
||||||
|
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||||
|
import {
|
||||||
|
PartnerBranding,
|
||||||
|
PartnerBrandingDocument,
|
||||||
|
} from '../schemas/partner-branding.schema.js'
|
||||||
|
import type { PartnerBrandingDto } from './dto/partner-branding.dto.js'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PartnerBrandingService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(PartnerBranding.name)
|
||||||
|
private readonly model: Model<PartnerBrandingDocument>,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Return the partner's branding doc, or a default-shaped (unsaved) doc so the
|
||||||
|
// portal always has a stable shape to render before anything is saved.
|
||||||
|
async get(
|
||||||
|
partnerId: string | Types.ObjectId,
|
||||||
|
): Promise<PartnerBrandingDocument | PartnerBranding> {
|
||||||
|
const existing = await this.model.findOne({ partnerId }).exec()
|
||||||
|
if (existing) return existing
|
||||||
|
// No saved branding yet — return a default-shaped plain object (no persisted
|
||||||
|
// _id) so the portal renders a stable shape without mistaking it for a
|
||||||
|
// saved document.
|
||||||
|
return {
|
||||||
|
partnerId: new Types.ObjectId(String(partnerId)),
|
||||||
|
identity: {},
|
||||||
|
customerDefaults: [],
|
||||||
|
emailTemplates: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full replace (upsert) of the partner's branding. The page edits the whole
|
||||||
|
// object and PUTs it back.
|
||||||
|
async put(
|
||||||
|
partnerId: string | Types.ObjectId,
|
||||||
|
dto: PartnerBrandingDto,
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<PartnerBrandingDocument> {
|
||||||
|
const updated = await this.model
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{ partnerId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
identity: dto.identity ?? {},
|
||||||
|
customerDefaults: dto.customerDefaults ?? [],
|
||||||
|
emailTemplates: dto.emailTemplates ?? [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ new: true, upsert: true, runValidators: true, setDefaultsOnInsert: true },
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
|
||||||
|
void this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'partner.branding_updated',
|
||||||
|
resourceType: 'partner',
|
||||||
|
resourceId: String(partnerId),
|
||||||
|
metadata: {
|
||||||
|
templates: dto.emailTemplates?.length ?? 0,
|
||||||
|
defaults: dto.customerDefaults?.length ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return updated!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -15,10 +19,18 @@ import { clientIp } from '../auth/client-ip.js'
|
|||||||
import { CurrentUser } from '../auth/current-user.decorator.js'
|
import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||||
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||||
|
import { PartnersService } from '../partners/partners.service.js'
|
||||||
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
|
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
|
||||||
import { CreateTenantDto } from '../tenants/dto/create-tenant.dto.js'
|
import { CreateTenantDto } from '../tenants/dto/create-tenant.dto.js'
|
||||||
import { TenantsService } from '../tenants/tenants.service.js'
|
import { TenantsService } from '../tenants/tenants.service.js'
|
||||||
|
import { InvitePartnerUserDto } from '../users/dto/invite-partner-user.dto.js'
|
||||||
import { UsersService } from '../users/users.service.js'
|
import { UsersService } from '../users/users.service.js'
|
||||||
|
import { CreateReportDto } from './dto/create-report.dto.js'
|
||||||
|
import { PartnerBrandingDto } from './dto/partner-branding.dto.js'
|
||||||
|
import { PartnerSettingsDto } from './dto/partner-settings.dto.js'
|
||||||
|
import { PartnerUpdateTenantDto } from './dto/partner-update-tenant.dto.js'
|
||||||
|
import { PartnerBrandingService } from './partner-branding.service.js'
|
||||||
|
import { PartnerReportsService } from './partner-reports.service.js'
|
||||||
|
|
||||||
// Self-service endpoints for the partner portal. Everything here scopes to
|
// Self-service endpoints for the partner portal. Everything here scopes to
|
||||||
// the caller's resolved User.partnerId — no slug in any URL, no operator
|
// the caller's resolved User.partnerId — no slug in any URL, no operator
|
||||||
@@ -34,6 +46,9 @@ export class PartnerMeController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly users: UsersService,
|
private readonly users: UsersService,
|
||||||
private readonly tenants: TenantsService,
|
private readonly tenants: TenantsService,
|
||||||
|
private readonly partners: PartnersService,
|
||||||
|
private readonly branding: PartnerBrandingService,
|
||||||
|
private readonly reports: PartnerReportsService,
|
||||||
private readonly actor: ActorService,
|
private readonly actor: ActorService,
|
||||||
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
|
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
|
||||||
) {}
|
) {}
|
||||||
@@ -47,7 +62,45 @@ export class PartnerMeController {
|
|||||||
if (!actor.partnerId) {
|
if (!actor.partnerId) {
|
||||||
throw new ForbiddenException('Not a partner-staff user')
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
}
|
}
|
||||||
return this.users.listPartnerUsers(actor.partnerId)
|
return this.users.listPartnerUsersEnriched(actor.partnerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-service partner-staff invite. Counterpart to the operator-only
|
||||||
|
// POST /partners/:slug/users — scoped to the caller's own partner.
|
||||||
|
@Post('users')
|
||||||
|
async inviteUser(
|
||||||
|
@Body() dto: InvitePartnerUserDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
const partner = await this.partnerModel.findById(actor.partnerId, { slug: 1 }).exec()
|
||||||
|
if (!partner) {
|
||||||
|
throw new ForbiddenException('Partner record missing')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.users.invitePartnerUser(dto, { _id: actor.partnerId, slug: partner.slug }, auditActor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a teammate from the partner organization.
|
||||||
|
@Delete('users/:subject')
|
||||||
|
async removeUser(
|
||||||
|
@Param('subject') subject: string,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
if (subject === jwt.sub) {
|
||||||
|
throw new ForbiddenException('You cannot remove your own account')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.users.removePartnerUser(subject, actor.partnerId, auditActor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenants (customers) attached to the partner.
|
// Tenants (customers) attached to the partner.
|
||||||
@@ -105,6 +158,51 @@ export class PartnerMeController {
|
|||||||
return { tenant, adminInvite }
|
return { tenant, adminInvite }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit a customer the partner owns (name, industry, brand colour, domains,
|
||||||
|
// seats). Ownership is enforced in TenantsService.partnerUpdate.
|
||||||
|
@Patch('tenants/:slug')
|
||||||
|
async updateTenant(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Body() dto: PartnerUpdateTenantDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.tenants.partnerUpdate(slug, actor.partnerId, dto, auditActor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tenants/:slug/suspend')
|
||||||
|
async suspendTenant(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.tenants.partnerSetStatus(slug, actor.partnerId, 'suspended', auditActor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tenants/:slug/resume')
|
||||||
|
async resumeTenant(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.tenants.partnerSetStatus(slug, actor.partnerId, 'active', auditActor)
|
||||||
|
}
|
||||||
|
|
||||||
// Monthly Recurring Revenue across the partner's customers — grouped by
|
// Monthly Recurring Revenue across the partner's customers — grouped by
|
||||||
// currency since subs can be billed in DKK / EUR / USD independently.
|
// currency since subs can be billed in DKK / EUR / USD independently.
|
||||||
@Get('mrr')
|
@Get('mrr')
|
||||||
@@ -138,4 +236,102 @@ export class PartnerMeController {
|
|||||||
before: before ? new Date(before) : undefined,
|
before: before ? new Date(before) : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partner settings — profile, notification prefs, agreement (read-only),
|
||||||
|
// documents (read-only), plus marginPct/contactInfo/billingInfo for display.
|
||||||
|
@Get('settings')
|
||||||
|
async getSettings(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
return this.partners.getSettings(actor.partnerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('settings')
|
||||||
|
async updateSettings(
|
||||||
|
@Body() dto: PartnerSettingsDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.partners.updateSettings(actor.partnerId, dto, auditActor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelabel branding — identity, customer defaults, email templates.
|
||||||
|
@Get('branding')
|
||||||
|
async getBranding(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
return this.branding.get(actor.partnerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('branding')
|
||||||
|
async putBranding(
|
||||||
|
@Body() dto: PartnerBrandingDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.branding.put(actor.partnerId, dto, auditActor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live analytics for the reports page (health cohorts, revenue by plan, top
|
||||||
|
// customers, signup/churn cohorts). Computed, not stored.
|
||||||
|
@Get('reports')
|
||||||
|
async getReports(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
return this.users.partnerReports(actor.partnerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saved/custom report definitions.
|
||||||
|
@Get('reports/saved')
|
||||||
|
async listSavedReports(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
return this.reports.list(actor.partnerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('reports/saved')
|
||||||
|
async createSavedReport(
|
||||||
|
@Body() dto: CreateReportDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.reports.create(actor.partnerId, dto, auditActor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('reports/saved/:id')
|
||||||
|
async deleteSavedReport(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
if (!actor.partnerId) {
|
||||||
|
throw new ForbiddenException('Not a partner-staff user')
|
||||||
|
}
|
||||||
|
const auditActor = { userId: String(actor._id), email: actor.email, ip: clientIp(req) }
|
||||||
|
return this.reports.remove(id, actor.partnerId, auditActor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Model, Types } from 'mongoose'
|
|||||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||||
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
|
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
|
||||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||||
|
import type { PartnerSettingsDto } from '../me/dto/partner-settings.dto.js'
|
||||||
import type { CreatePartnerDto } from './dto/create-partner.dto.js'
|
import type { CreatePartnerDto } from './dto/create-partner.dto.js'
|
||||||
import type { UpdatePartnerDto } from './dto/update-partner.dto.js'
|
import type { UpdatePartnerDto } from './dto/update-partner.dto.js'
|
||||||
|
|
||||||
@@ -27,6 +28,49 @@ export class PartnersService {
|
|||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// Partner-portal settings read — the whole partner doc; the portal projects
|
||||||
|
// the fields it needs (profile, notificationPrefs, agreement, documents,
|
||||||
|
// marginPct, contactInfo, billingInfo).
|
||||||
|
async getSettings(partnerId: string | Types.ObjectId): Promise<PartnerDocument> {
|
||||||
|
const partner = await this.partnerModel.findById(partnerId).exec()
|
||||||
|
if (!partner) throw new NotFoundException('Partner not found')
|
||||||
|
return partner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partner-editable settings update: profile + notification prefs only. Margin,
|
||||||
|
// agreement terms, and documents are operator-controlled and ignored here.
|
||||||
|
async updateSettings(
|
||||||
|
partnerId: string | Types.ObjectId,
|
||||||
|
dto: PartnerSettingsDto,
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<PartnerDocument> {
|
||||||
|
const set: Record<string, unknown> = {}
|
||||||
|
if (dto.profile) {
|
||||||
|
for (const [k, v] of Object.entries(dto.profile)) {
|
||||||
|
if (v !== undefined && v !== null) set[`profile.${k}`] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.notificationPrefs !== undefined) set.notificationPrefs = dto.notificationPrefs
|
||||||
|
|
||||||
|
const partner = await this.partnerModel
|
||||||
|
.findByIdAndUpdate(partnerId, { $set: set }, { new: true, runValidators: true })
|
||||||
|
.exec()
|
||||||
|
if (!partner) throw new NotFoundException('Partner not found')
|
||||||
|
|
||||||
|
void this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'partner.settings_updated',
|
||||||
|
resourceType: 'partner',
|
||||||
|
resourceId: String(partner._id),
|
||||||
|
resourceName: partner.name,
|
||||||
|
partnerSlug: partner.slug,
|
||||||
|
metadata: { changes: Object.keys(set) },
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return partner
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: CreatePartnerDto, actor?: AuditActor): Promise<PartnerDocument> {
|
async create(dto: CreatePartnerDto, actor?: AuditActor): Promise<PartnerDocument> {
|
||||||
const exists = await this.partnerModel.exists({ slug: dto.slug })
|
const exists = await this.partnerModel.exists({ slug: dto.slug })
|
||||||
if (exists) throw new ConflictException(`Partner "${dto.slug}" already exists`)
|
if (exists) throw new ConflictException(`Partner "${dto.slug}" already exists`)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { HydratedDocument, Types } from 'mongoose'
|
||||||
|
|
||||||
|
export type PartnerBrandingDocument = HydratedDocument<PartnerBranding>
|
||||||
|
|
||||||
|
// Whitelabel branding for a partner — one doc per partner. Kept in its own
|
||||||
|
// collection (not embedded on Partner) so the potentially-large email-template
|
||||||
|
// bodies stay off the partner hot path.
|
||||||
|
@Schema({ collection: 'partner_branding', timestamps: true })
|
||||||
|
export class PartnerBranding {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'Partner', required: true, unique: true, index: true })
|
||||||
|
partnerId!: Types.ObjectId
|
||||||
|
|
||||||
|
// The partner's own brand identity (shown in the partner console + on emails
|
||||||
|
// its team sends — not what customers see).
|
||||||
|
@Prop({
|
||||||
|
type: {
|
||||||
|
displayName: String,
|
||||||
|
logoUrl: String,
|
||||||
|
markUrl: String,
|
||||||
|
faviconUrl: String,
|
||||||
|
primaryColor: String,
|
||||||
|
supportEmail: String,
|
||||||
|
supportPhone: String,
|
||||||
|
website: String,
|
||||||
|
replyTo: String,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
})
|
||||||
|
identity!: {
|
||||||
|
displayName?: string
|
||||||
|
logoUrl?: string
|
||||||
|
markUrl?: string
|
||||||
|
faviconUrl?: string
|
||||||
|
primaryColor?: string
|
||||||
|
supportEmail?: string
|
||||||
|
supportPhone?: string
|
||||||
|
website?: string
|
||||||
|
replyTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults pushed to every customer the partner provisions (label/detail/on).
|
||||||
|
@Prop({ type: [{ label: String, detail: String, on: Boolean }], default: [] })
|
||||||
|
customerDefaults!: Array<{ label: string; detail: string; on: boolean }>
|
||||||
|
|
||||||
|
// Branded email templates (one per id). `body` may be large.
|
||||||
|
@Prop({
|
||||||
|
type: [{ id: String, name: String, subject: String, body: String, edited: String }],
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
|
emailTemplates!: Array<{ id: string; name: string; subject: string; body: string; edited: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PartnerBrandingSchema = SchemaFactory.createForClass(PartnerBranding)
|
||||||
@@ -63,6 +63,68 @@ export class Partner {
|
|||||||
country?: string
|
country?: string
|
||||||
contactEmail?: string
|
contactEmail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partner-editable business profile (settings → Contact info form).
|
||||||
|
@Prop({
|
||||||
|
type: {
|
||||||
|
legalName: String,
|
||||||
|
tradingName: String,
|
||||||
|
address: String,
|
||||||
|
country: String,
|
||||||
|
primaryEmail: String,
|
||||||
|
primaryPhone: String,
|
||||||
|
supportHotline: String,
|
||||||
|
website: String,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
})
|
||||||
|
profile!: {
|
||||||
|
legalName?: string
|
||||||
|
tradingName?: string
|
||||||
|
address?: string
|
||||||
|
country?: string
|
||||||
|
primaryEmail?: string
|
||||||
|
primaryPhone?: string
|
||||||
|
supportHotline?: string
|
||||||
|
website?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partner-level notification preferences — one row per event type. Partner-editable.
|
||||||
|
@Prop({ type: [{ event: String, cadence: String, channels: [String] }], default: [] })
|
||||||
|
notificationPrefs!: Array<{ event: string; cadence: string; channels: string[] }>
|
||||||
|
|
||||||
|
// Reseller-agreement terms. Operator-managed — read-only to the partner.
|
||||||
|
@Prop({
|
||||||
|
type: {
|
||||||
|
tier: String,
|
||||||
|
payoutCadence: String,
|
||||||
|
effectiveAt: Date,
|
||||||
|
termMonths: Number,
|
||||||
|
noticePeriodDays: Number,
|
||||||
|
liabilityCap: String,
|
||||||
|
governingLaw: String,
|
||||||
|
signedBy: String,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
})
|
||||||
|
agreement!: {
|
||||||
|
tier?: string
|
||||||
|
payoutCadence?: string
|
||||||
|
effectiveAt?: Date
|
||||||
|
termMonths?: number
|
||||||
|
noticePeriodDays?: number
|
||||||
|
liabilityCap?: string
|
||||||
|
governingLaw?: string
|
||||||
|
signedBy?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document references (URL + metadata; bytes live in object storage later).
|
||||||
|
// Operator-managed for v1.
|
||||||
|
@Prop({
|
||||||
|
type: [{ name: String, url: String, kind: String, size: String, uploadedAt: Date }],
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
|
documents!: Array<{ name: string; url?: string; kind?: string; size?: string; uploadedAt?: Date }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PartnerSchema = SchemaFactory.createForClass(Partner)
|
export const PartnerSchema = SchemaFactory.createForClass(Partner)
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ export class Tenant {
|
|||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [String], default: [] })
|
||||||
domains!: string[]
|
domains!: string[]
|
||||||
|
|
||||||
|
// Partner-editable customer metadata. Display-only — not used for
|
||||||
|
// provisioning. `industry` is free text; `brandColor` is a #rrggbb hex
|
||||||
|
// (validated at the DTO layer) rendered as the customer's swatch in the
|
||||||
|
// partner portal.
|
||||||
|
@Prop({ trim: true })
|
||||||
|
industry?: string
|
||||||
|
|
||||||
|
@Prop({ trim: true })
|
||||||
|
brandColor?: string
|
||||||
|
|
||||||
// Optional MSP/reseller this tenant belongs to. Sparse — direct tenants have none.
|
// Optional MSP/reseller this tenant belongs to. Sparse — direct tenants have none.
|
||||||
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
|
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
|
||||||
partnerId?: Types.ObjectId
|
partnerId?: Types.ObjectId
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ export class User {
|
|||||||
|
|
||||||
@Prop()
|
@Prop()
|
||||||
lastLoginAt?: Date
|
lastLoginAt?: Date
|
||||||
|
|
||||||
|
// Partner-staff only: explicit subset of the partner's tenants this user may
|
||||||
|
// access. Absent/empty = full portfolio ("all") — backward compatible with
|
||||||
|
// existing staff. Lets a partner scope e.g. a sales rep to specific customers.
|
||||||
|
@Prop({ type: [Types.ObjectId], ref: 'Tenant', default: undefined })
|
||||||
|
partnerTenantAccess?: Types.ObjectId[]
|
||||||
|
|
||||||
|
// Authentik internal numeric pk, captured at invite time so admin endpoints
|
||||||
|
// (MFA device list, group add/remove) work without an email lookup.
|
||||||
|
@Prop({ type: Number })
|
||||||
|
authentikUserPk?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserSchema = SchemaFactory.createForClass(User)
|
export const UserSchema = SchemaFactory.createForClass(User)
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
|
import {
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common'
|
||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model, Types } from 'mongoose'
|
import { Model, Types } from 'mongoose'
|
||||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||||
@@ -6,6 +12,7 @@ import { PricesService } from '../prices/prices.service.js'
|
|||||||
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||||
|
import type { PartnerUpdateTenantDto } from '../me/dto/partner-update-tenant.dto.js'
|
||||||
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||||
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||||
import { ProvisioningService } from './provisioning.service.js'
|
import { ProvisioningService } from './provisioning.service.js'
|
||||||
@@ -110,6 +117,82 @@ export class TenantsService {
|
|||||||
return tenant
|
return tenant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a tenant by slug and asserts it belongs to the given partner.
|
||||||
|
*
|
||||||
|
* Every partner-scoped mutation MUST funnel through this. The bare
|
||||||
|
* update/setStatus/softDelete methods are slug-keyed with no ownership
|
||||||
|
* check (operators may touch any tenant), so without this guard a partner
|
||||||
|
* could mutate any tenant simply by guessing its slug. Returns the loaded
|
||||||
|
* tenant so callers don't re-fetch.
|
||||||
|
*/
|
||||||
|
async assertPartnerOwnsTenant(
|
||||||
|
slug: string,
|
||||||
|
partnerId: string | Types.ObjectId,
|
||||||
|
): Promise<TenantDocument> {
|
||||||
|
const tenant = await this.findOneBySlug(slug)
|
||||||
|
if (!tenant.partnerId || String(tenant.partnerId) !== String(partnerId)) {
|
||||||
|
throw new ForbiddenException(`Tenant "${slug}" is not in your portfolio`)
|
||||||
|
}
|
||||||
|
return tenant
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partner-scoped tenant edit. Funnels through assertPartnerOwnsTenant so a
|
||||||
|
// partner can only touch customers in their own portfolio. A seat change is
|
||||||
|
// mirrored onto the tenant's Subscription so MRR stays correct.
|
||||||
|
async partnerUpdate(
|
||||||
|
slug: string,
|
||||||
|
partnerId: string | Types.ObjectId,
|
||||||
|
dto: PartnerUpdateTenantDto,
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<TenantDocument> {
|
||||||
|
const tenant = await this.assertPartnerOwnsTenant(slug, partnerId)
|
||||||
|
const set: Record<string, unknown> = {}
|
||||||
|
if (dto.name !== undefined) set.name = dto.name
|
||||||
|
if (dto.industry !== undefined) set.industry = dto.industry
|
||||||
|
if (dto.brandColor !== undefined) set.brandColor = dto.brandColor
|
||||||
|
if (dto.domains !== undefined) set.domains = dto.domains
|
||||||
|
if (dto.seats !== undefined) set.seats = dto.seats
|
||||||
|
if (Object.keys(set).length === 0) return tenant
|
||||||
|
|
||||||
|
// Filter by partnerId too (not just slug) so a concurrent operator
|
||||||
|
// re-assignment between the ownership assert and this update can't let the
|
||||||
|
// write land on a tenant the partner no longer owns — it returns null then.
|
||||||
|
const updated = await this.tenantModel
|
||||||
|
.findOneAndUpdate({ slug, partnerId }, { $set: set }, { new: true, runValidators: true })
|
||||||
|
.exec()
|
||||||
|
if (!updated) throw new ForbiddenException(`Tenant "${slug}" is not in your portfolio`)
|
||||||
|
|
||||||
|
if (dto.seats !== undefined) {
|
||||||
|
await this.subModel.updateOne({ tenantId: updated._id }, { $set: { seats: dto.seats } }).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'tenant.updated',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: String(updated._id),
|
||||||
|
resourceName: updated.name,
|
||||||
|
tenantSlug: updated.slug,
|
||||||
|
metadata: { changes: Object.keys(set), via: 'partner' },
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partner-scoped suspend/resume. Ownership-guarded, then delegates to the
|
||||||
|
// shared setStatus (which records the tenant.suspended / tenant.resumed audit).
|
||||||
|
async partnerSetStatus(
|
||||||
|
slug: string,
|
||||||
|
partnerId: string | Types.ObjectId,
|
||||||
|
status: 'active' | 'suspended',
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<TenantDocument> {
|
||||||
|
await this.assertPartnerOwnsTenant(slug, partnerId)
|
||||||
|
return this.setStatus(slug, status, actor)
|
||||||
|
}
|
||||||
|
|
||||||
async update(slug: string, dto: UpdateTenantDto, actor?: AuditActor): Promise<TenantDocument> {
|
async update(slug: string, dto: UpdateTenantDto, actor?: AuditActor): Promise<TenantDocument> {
|
||||||
// Build $set / $unset explicitly. Doing `findOneAndUpdate({slug}, dto, ...)`
|
// Build $set / $unset explicitly. Doing `findOneAndUpdate({slug}, dto, ...)`
|
||||||
// with a class-transformer instance leaks undefined slots into the update,
|
// with a class-transformer instance leaks undefined slots into the update,
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
|
import {
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common'
|
||||||
import { ConfigService } from '@nestjs/config'
|
import { ConfigService } from '@nestjs/config'
|
||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model, Types } from 'mongoose'
|
import { Model, Types } from 'mongoose'
|
||||||
@@ -326,6 +332,7 @@ export class UsersService {
|
|||||||
// Don't clobber the local name if we have one (e.g. they
|
// Don't clobber the local name if we have one (e.g. they
|
||||||
// already logged in and set it from the JWT); only seed on insert.
|
// already logged in and set it from the JWT); only seed on insert.
|
||||||
partnerId: partner._id,
|
partnerId: partner._id,
|
||||||
|
authentikUserPk: existing.pk,
|
||||||
},
|
},
|
||||||
$setOnInsert: {
|
$setOnInsert: {
|
||||||
name: existing.name || dto.name,
|
name: existing.name || dto.name,
|
||||||
@@ -379,6 +386,7 @@ export class UsersService {
|
|||||||
email: dto.email,
|
email: dto.email,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
partnerId: partner._id,
|
partnerId: partner._id,
|
||||||
|
authentikUserPk: created.pk,
|
||||||
},
|
},
|
||||||
$setOnInsert: { role: 'member', active: true, tenantIds: [], platformAdmin: false },
|
$setOnInsert: { role: 'member', active: true, tenantIds: [], platformAdmin: false },
|
||||||
},
|
},
|
||||||
@@ -420,6 +428,99 @@ export class UsersService {
|
|||||||
return this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
return this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partner-portal team list. Same set as listPartnerUsers, enriched with:
|
||||||
|
// - accessLevel/accessCount derived from partnerTenantAccess (absent = all)
|
||||||
|
// - mfaEnabled from a live Authentik authenticator-count lookup
|
||||||
|
// MFA lookups run in bounded parallel and degrade to null on any error so a
|
||||||
|
// flaky/unavailable Authentik never breaks the team list. Kept separate from
|
||||||
|
// listPartnerUsers (operator path) so that path stays a pure DB query with
|
||||||
|
// no external coupling.
|
||||||
|
async listPartnerUsersEnriched(partnerId: Types.ObjectId): Promise<
|
||||||
|
Array<
|
||||||
|
UserDocument & {
|
||||||
|
mfaEnabled: boolean | null
|
||||||
|
accessLevel: 'all' | 'scoped'
|
||||||
|
accessCount: number | null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
const users = await this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
||||||
|
if (users.length === 0) return []
|
||||||
|
const mfa = await Promise.all(
|
||||||
|
users.map(async (u) => {
|
||||||
|
if (!u.authentikUserPk) return null
|
||||||
|
try {
|
||||||
|
return (await this.authentik.countAuthenticators(u.authentikUserPk)) > 0
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return users.map((u, i) => {
|
||||||
|
const obj = u.toObject() as UserDocument & {
|
||||||
|
mfaEnabled: boolean | null
|
||||||
|
accessLevel: 'all' | 'scoped'
|
||||||
|
accessCount: number | null
|
||||||
|
}
|
||||||
|
const access = u.partnerTenantAccess
|
||||||
|
const scoped = !!access && access.length > 0
|
||||||
|
obj.accessLevel = scoped ? 'scoped' : 'all'
|
||||||
|
obj.accessCount = scoped ? access!.length : null
|
||||||
|
obj.mfaEnabled = mfa[i] ?? null
|
||||||
|
return obj
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a partner-staff user from the partner: unset partnerId +
|
||||||
|
// partnerTenantAccess and drop them from the dezky-partner-staff Authentik
|
||||||
|
// group. Ownership-guarded (must belong to the caller's partner) and refuses
|
||||||
|
// to strip the last partner admin/owner so a partner can't lock itself out.
|
||||||
|
async removePartnerUser(
|
||||||
|
subject: string,
|
||||||
|
partnerId: Types.ObjectId,
|
||||||
|
actor?: AuditActor,
|
||||||
|
): Promise<{ removed: boolean }> {
|
||||||
|
const user = await this.userModel.findOne({ authentikSubjectId: subject }).exec()
|
||||||
|
if (!user) throw new NotFoundException(`User ${subject} not found`)
|
||||||
|
if (!user.partnerId || String(user.partnerId) !== String(partnerId)) {
|
||||||
|
throw new ForbiddenException('User is not part of your partner organization')
|
||||||
|
}
|
||||||
|
if (user.role === 'owner' || user.role === 'admin') {
|
||||||
|
const admins = await this.userModel
|
||||||
|
.countDocuments({ partnerId, role: { $in: ['owner', 'admin'] } })
|
||||||
|
.exec()
|
||||||
|
if (admins <= 1) {
|
||||||
|
throw new ConflictException('Cannot remove the last partner admin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Best-effort Authentik group removal — never block the local detach on it.
|
||||||
|
try {
|
||||||
|
const groupPk = await this.resolvePartnerStaffGroupId()
|
||||||
|
const pk = user.authentikUserPk ?? (await this.authentik.findUserByEmail(user.email))?.pk
|
||||||
|
if (pk) await this.authentik.removeUserFromGroup(pk, groupPk)
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to remove ${user.email} from partner-staff group: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await this.userModel
|
||||||
|
.updateOne({ _id: user._id }, { $unset: { partnerId: '', partnerTenantAccess: '' } })
|
||||||
|
.exec()
|
||||||
|
void this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'partner.user_removed',
|
||||||
|
resourceType: 'user',
|
||||||
|
resourceId: subject,
|
||||||
|
resourceName: user.email,
|
||||||
|
metadata: { partnerId: String(partnerId) },
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return { removed: true }
|
||||||
|
}
|
||||||
|
|
||||||
// List tenants attached to a partner. Used by the partner-portal's
|
// List tenants attached to a partner. Used by the partner-portal's
|
||||||
// /partner/customers page (via /users/me/partner/tenants) and could be
|
// /partner/customers page (via /users/me/partner/tenants) and could be
|
||||||
// reused for operator surfaces that want partner-scoped tenant queries.
|
// reused for operator surfaces that want partner-scoped tenant queries.
|
||||||
@@ -427,7 +528,16 @@ export class UsersService {
|
|||||||
// column can render N/M without a second round-trip from the client.
|
// column can render N/M without a second round-trip from the client.
|
||||||
async listPartnerTenants(
|
async listPartnerTenants(
|
||||||
partnerId: Types.ObjectId,
|
partnerId: Types.ObjectId,
|
||||||
): Promise<Array<TenantDocument & { userCount: number; newUserCount30d: number }>> {
|
): Promise<
|
||||||
|
Array<
|
||||||
|
TenantDocument & {
|
||||||
|
userCount: number
|
||||||
|
newUserCount30d: number
|
||||||
|
healthScore: number
|
||||||
|
healthBand: 'healthy' | 'watch' | 'at-risk'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
> {
|
||||||
const tenants = await this.tenantModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
const tenants = await this.tenantModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
||||||
if (tenants.length === 0) return []
|
if (tenants.length === 0) return []
|
||||||
const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||||
@@ -456,14 +566,50 @@ export class UsersService {
|
|||||||
const obj = t.toObject() as TenantDocument & {
|
const obj = t.toObject() as TenantDocument & {
|
||||||
userCount: number
|
userCount: number
|
||||||
newUserCount30d: number
|
newUserCount30d: number
|
||||||
|
healthScore: number
|
||||||
|
healthBand: 'healthy' | 'watch' | 'at-risk'
|
||||||
}
|
}
|
||||||
const c = countMap.get(String(t._id))
|
const c = countMap.get(String(t._id))
|
||||||
obj.userCount = c?.n ?? 0
|
obj.userCount = c?.n ?? 0
|
||||||
obj.newUserCount30d = c?.new30d ?? 0
|
obj.newUserCount30d = c?.new30d ?? 0
|
||||||
|
const health = this.tenantHealth(t, obj.userCount)
|
||||||
|
obj.healthScore = health.healthScore
|
||||||
|
obj.healthBand = health.healthBand
|
||||||
return obj
|
return obj
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portfolio-health heuristic for a tenant, 0–100, computed (never stored).
|
||||||
|
// Penalises non-active status, poor seat adoption, and failed/pending
|
||||||
|
// provisioning. Band: >=80 healthy, 60–79 watch, <60 at-risk.
|
||||||
|
private tenantHealth(
|
||||||
|
t: TenantDocument,
|
||||||
|
userCount: number,
|
||||||
|
): { healthScore: number; healthBand: 'healthy' | 'watch' | 'at-risk' } {
|
||||||
|
let score = 100
|
||||||
|
if (t.status === 'pending') score -= 15
|
||||||
|
else if (t.status === 'suspended' || t.status === 'deleted') score -= 60
|
||||||
|
|
||||||
|
const seats = t.seats ?? 0
|
||||||
|
if (seats > 0) {
|
||||||
|
const u = userCount / seats
|
||||||
|
if (u < 0.25) score -= 25
|
||||||
|
else if (u < 0.5) score -= 10
|
||||||
|
else if (u > 1.0) score -= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
const ps = t.provisioningStatus
|
||||||
|
for (const s of [ps?.authentik, ps?.stalwart, ps?.ocis]) {
|
||||||
|
if (s === 'error') score -= 10
|
||||||
|
else if (s === 'pending') score -= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
score = Math.max(0, Math.min(100, score))
|
||||||
|
const healthBand: 'healthy' | 'watch' | 'at-risk' =
|
||||||
|
score >= 80 ? 'healthy' : score >= 60 ? 'watch' : 'at-risk'
|
||||||
|
return { healthScore: score, healthBand }
|
||||||
|
}
|
||||||
|
|
||||||
// Create (or attach) the first admin user for a freshly-provisioned
|
// Create (or attach) the first admin user for a freshly-provisioned
|
||||||
// tenant. Same shape as invitePartnerUser but adds the user to the
|
// tenant. Same shape as invitePartnerUser but adds the user to the
|
||||||
// tenant's Authentik group (created during provisioning) instead of
|
// tenant's Authentik group (created during provisioning) instead of
|
||||||
@@ -655,6 +801,176 @@ export class UsersService {
|
|||||||
return { totals, breakdown }
|
return { totals, breakdown }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Analytics for the partner reports page. Reuses listPartnerTenants (health)
|
||||||
|
// and partnerMrr (revenue) plus a signup-cohort pass over the tenants. Churn
|
||||||
|
// retention is APPROXIMATE for v1 — "retained" = currently active, since we
|
||||||
|
// don't track cancellation dates until billing (Phase 3) lands.
|
||||||
|
async partnerReports(partnerId: Types.ObjectId): Promise<{
|
||||||
|
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 [tenants, mrr, partner] = await Promise.all([
|
||||||
|
this.listPartnerTenants(partnerId),
|
||||||
|
this.partnerMrr(partnerId),
|
||||||
|
this.partnerModel.findById(partnerId, { marginPct: 1 }).exec(),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Health cohorts (exclude soft-deleted from the cohort).
|
||||||
|
const live = tenants.filter((t) => t.status !== 'deleted')
|
||||||
|
const health = { healthy: 0, watch: 0, atRisk: 0, total: live.length, avgScore: 0 }
|
||||||
|
let scoreSum = 0
|
||||||
|
for (const t of live) {
|
||||||
|
scoreSum += t.healthScore
|
||||||
|
if (t.healthBand === 'healthy') health.healthy++
|
||||||
|
else if (t.healthBand === 'watch') health.watch++
|
||||||
|
else health.atRisk++
|
||||||
|
}
|
||||||
|
health.avgScore = live.length ? Math.round(scoreSum / live.length) : 0
|
||||||
|
|
||||||
|
// Revenue grouped by plan × currency.
|
||||||
|
const planMap = new Map<
|
||||||
|
string,
|
||||||
|
{ plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }
|
||||||
|
>()
|
||||||
|
for (const row of mrr.breakdown) {
|
||||||
|
const key = `${row.plan}|${row.currency}`
|
||||||
|
const e = planMap.get(key) ?? { plan: row.plan, currency: row.currency, monthlyMinor: 0, count: 0 }
|
||||||
|
e.monthlyMinor += row.monthlyMinor
|
||||||
|
e.count++
|
||||||
|
planMap.set(key, e)
|
||||||
|
}
|
||||||
|
const revenueByPlan = [...planMap.values()].sort((a, b) => b.monthlyMinor - a.monthlyMinor)
|
||||||
|
|
||||||
|
// Top customers by MRR.
|
||||||
|
const topCustomers = [...mrr.breakdown]
|
||||||
|
.sort((a, b) => b.monthlyMinor - a.monthlyMinor)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((r) => ({
|
||||||
|
tenantId: r.tenantId,
|
||||||
|
tenantName: r.tenantName,
|
||||||
|
currency: r.currency,
|
||||||
|
monthlyMinor: r.monthlyMinor,
|
||||||
|
custom: r.custom,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Signup cohorts (approximate retention).
|
||||||
|
const cohortMap = new Map<string, { total: number; retained: number }>()
|
||||||
|
for (const t of tenants) {
|
||||||
|
const created = (t as { createdAt?: string | Date }).createdAt
|
||||||
|
if (!created) continue
|
||||||
|
const month = new Date(created).toISOString().slice(0, 7) // YYYY-MM
|
||||||
|
const e = cohortMap.get(month) ?? { total: 0, retained: 0 }
|
||||||
|
e.total++
|
||||||
|
if (t.status === 'active') e.retained++
|
||||||
|
cohortMap.set(month, e)
|
||||||
|
}
|
||||||
|
const churnCohorts = [...cohortMap.entries()]
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([month, v]) => ({
|
||||||
|
month,
|
||||||
|
total: v.total,
|
||||||
|
retained: v.retained,
|
||||||
|
retentionPct: v.total ? Math.round((v.retained / v.total) * 100) : 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
health,
|
||||||
|
revenueByPlan,
|
||||||
|
topCustomers,
|
||||||
|
churnCohorts,
|
||||||
|
totals: mrr.totals,
|
||||||
|
marginPct: partner?.marginPct ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-wide analytics for the operator reports page (all tenants/subs,
|
||||||
|
// not partner-scoped). Status distribution + revenue by plan + top tenants +
|
||||||
|
// signup growth.
|
||||||
|
async platformReports(): Promise<{
|
||||||
|
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 [tenants, subs] = await Promise.all([
|
||||||
|
this.tenantModel.find().exec(),
|
||||||
|
this.subModel.find({ status: 'active' }).exec(),
|
||||||
|
])
|
||||||
|
const tenantById = new Map(tenants.map((t) => [String(t._id), t]))
|
||||||
|
|
||||||
|
const statusCounts = { active: 0, pending: 0, suspended: 0, deleted: 0, total: tenants.length }
|
||||||
|
for (const t of tenants) statusCounts[t.status] += 1
|
||||||
|
|
||||||
|
const breakdown = subs.map((s) => {
|
||||||
|
const t = tenantById.get(String(s.tenantId))
|
||||||
|
return {
|
||||||
|
tenantId: String(s.tenantId),
|
||||||
|
tenantName: t?.name ?? String(s.tenantId),
|
||||||
|
plan: s.plan,
|
||||||
|
currency: s.currency,
|
||||||
|
monthlyMinor: normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const planMap = new Map<
|
||||||
|
string,
|
||||||
|
{ plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }
|
||||||
|
>()
|
||||||
|
const byCurrency = new Map<'DKK' | 'EUR' | 'USD', number>()
|
||||||
|
for (const r of breakdown) {
|
||||||
|
const key = `${r.plan}|${r.currency}`
|
||||||
|
const e = planMap.get(key) ?? { plan: r.plan, currency: r.currency, monthlyMinor: 0, count: 0 }
|
||||||
|
e.monthlyMinor += r.monthlyMinor
|
||||||
|
e.count++
|
||||||
|
planMap.set(key, e)
|
||||||
|
byCurrency.set(r.currency, (byCurrency.get(r.currency) ?? 0) + r.monthlyMinor)
|
||||||
|
}
|
||||||
|
const ORDER: Array<'DKK' | 'EUR' | 'USD'> = ['DKK', 'EUR', 'USD']
|
||||||
|
const totals = ORDER.filter((c) => byCurrency.has(c)).map((c) => ({ currency: c, monthlyMinor: byCurrency.get(c)! }))
|
||||||
|
|
||||||
|
const topTenants = [...breakdown].sort((a, b) => b.monthlyMinor - a.monthlyMinor).slice(0, 10)
|
||||||
|
|
||||||
|
const growthMap = new Map<string, number>()
|
||||||
|
for (const t of tenants) {
|
||||||
|
const created = (t as { createdAt?: string | Date }).createdAt
|
||||||
|
if (!created) continue
|
||||||
|
const month = new Date(created).toISOString().slice(0, 7)
|
||||||
|
growthMap.set(month, (growthMap.get(month) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const growth = [...growthMap.entries()]
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([month, count]) => ({ month, count }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenants: statusCounts,
|
||||||
|
revenueByPlan: [...planMap.values()].sort((a, b) => b.monthlyMinor - a.monthlyMinor),
|
||||||
|
totals,
|
||||||
|
topTenants,
|
||||||
|
growth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve + cache the dezky-platform-admins group ID. The group is created
|
// Resolve + cache the dezky-platform-admins group ID. The group is created
|
||||||
// by Authentik bootstrap so it's reliably present; ensureGroup is
|
// by Authentik bootstrap so it's reliably present; ensureGroup is
|
||||||
// idempotent so the worst case is a no-op extra API call on cold start.
|
// idempotent so the worst case is a no-op extra API call on cold start.
|
||||||
|
|||||||
Reference in New Issue
Block a user