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:
Ronni Baslund
2026-05-30 08:03:07 +02:00
parent a51dc9a732
commit 89691626f4
33 changed files with 1753 additions and 198 deletions
@@ -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 { InjectModel } from '@nestjs/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
// already logged in and set it from the JWT); only seed on insert.
partnerId: partner._id,
authentikUserPk: existing.pk,
},
$setOnInsert: {
name: existing.name || dto.name,
@@ -379,6 +386,7 @@ export class UsersService {
email: dto.email,
name: dto.name,
partnerId: partner._id,
authentikUserPk: created.pk,
},
$setOnInsert: { role: 'member', active: true, tenantIds: [], platformAdmin: false },
},
@@ -420,6 +428,99 @@ export class UsersService {
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
// /partner/customers page (via /users/me/partner/tenants) and could be
// 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.
async listPartnerTenants(
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()
if (tenants.length === 0) return []
const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
@@ -456,14 +566,50 @@ export class UsersService {
const obj = t.toObject() as TenantDocument & {
userCount: number
newUserCount30d: number
healthScore: number
healthBand: 'healthy' | 'watch' | 'at-risk'
}
const c = countMap.get(String(t._id))
obj.userCount = c?.n ?? 0
obj.newUserCount30d = c?.new30d ?? 0
const health = this.tenantHealth(t, obj.userCount)
obj.healthScore = health.healthScore
obj.healthBand = health.healthBand
return obj
})
}
// Portfolio-health heuristic for a tenant, 0100, computed (never stored).
// Penalises non-active status, poor seat adoption, and failed/pending
// provisioning. Band: >=80 healthy, 6079 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
// tenant. Same shape as invitePartnerUser but adds the user to the
// tenant's Authentik group (created during provisioning) instead of
@@ -655,6 +801,176 @@ export class UsersService {
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
// 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.