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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user