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:
@@ -141,6 +141,40 @@ export class AuthentikClient {
|
||||
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
|
||||
// password + enroll MFA. Requires a "recovery flow" configured on the
|
||||
// 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 { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuditModule } from '../audit/audit.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 { Report, ReportSchema } from '../schemas/report.schema.js'
|
||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||
import { UsersModule } from '../users/users.module.js'
|
||||
import { PartnerBrandingService } from './partner-branding.service.js'
|
||||
import { PartnerMeController } from './partner-me.controller.js'
|
||||
import { PartnerReportsService } from './partner-reports.service.js'
|
||||
|
||||
// Self-service portal surface. Composes UsersService (partner-scoped reads,
|
||||
// invitePartnerUser, inviteTenantAdmin, partnerMrr, partnerActivity) and
|
||||
@@ -13,10 +22,17 @@ import { PartnerMeController } from './partner-me.controller.js'
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
UsersModule,
|
||||
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],
|
||||
providers: [PartnerBrandingService, PartnerReportsService],
|
||||
})
|
||||
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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
@@ -15,10 +19,18 @@ import { clientIp } from '../auth/client-ip.js'
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.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 { CreateTenantDto } from '../tenants/dto/create-tenant.dto.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 { 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
|
||||
// the caller's resolved User.partnerId — no slug in any URL, no operator
|
||||
@@ -34,6 +46,9 @@ export class PartnerMeController {
|
||||
constructor(
|
||||
private readonly users: UsersService,
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly partners: PartnersService,
|
||||
private readonly branding: PartnerBrandingService,
|
||||
private readonly reports: PartnerReportsService,
|
||||
private readonly actor: ActorService,
|
||||
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
|
||||
) {}
|
||||
@@ -47,7 +62,45 @@ export class PartnerMeController {
|
||||
if (!actor.partnerId) {
|
||||
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.
|
||||
@@ -105,6 +158,51 @@ export class PartnerMeController {
|
||||
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
|
||||
// currency since subs can be billed in DKK / EUR / USD independently.
|
||||
@Get('mrr')
|
||||
@@ -138,4 +236,102 @@ export class PartnerMeController {
|
||||
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 { Partner, PartnerDocument } from '../schemas/partner.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 { UpdatePartnerDto } from './dto/update-partner.dto.js'
|
||||
|
||||
@@ -27,6 +28,49 @@ export class PartnersService {
|
||||
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> {
|
||||
const exists = await this.partnerModel.exists({ slug: dto.slug })
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -37,6 +37,16 @@ export class Tenant {
|
||||
@Prop({ type: [String], default: [] })
|
||||
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.
|
||||
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
|
||||
partnerId?: Types.ObjectId
|
||||
|
||||
@@ -45,6 +45,17 @@ export class User {
|
||||
|
||||
@Prop()
|
||||
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)
|
||||
|
||||
@@ -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 { Model, Types } from 'mongoose'
|
||||
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 { Tenant, TenantDocument } from '../schemas/tenant.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 { UpdateTenantDto } from './dto/update-tenant.dto.js'
|
||||
import { ProvisioningService } from './provisioning.service.js'
|
||||
@@ -110,6 +117,82 @@ export class TenantsService {
|
||||
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> {
|
||||
// Build $set / $unset explicitly. Doing `findOneAndUpdate({slug}, dto, ...)`
|
||||
// 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 { 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