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:
@@ -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, 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
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user