feat(reports): partner and platform analytics

Partner reports — health cohorts, revenue-by-plan, top customers, signup/churn cohorts, plus saved custom reports (create/list/delete). Operator platform-wide reports (MRR, revenue by plan, top tenants, growth). Replaces the reports fixtures in both apps.
This commit is contained in:
Ronni Baslund
2026-05-30 08:03:14 +02:00
parent 89691626f4
commit 6370e392cc
13 changed files with 633 additions and 86 deletions
@@ -0,0 +1,15 @@
import { IsEnum, IsObject, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
export class CreateReportDto {
@IsString() @MinLength(1) @MaxLength(160)
name!: string
@IsOptional() @IsEnum(['health', 'revenue', 'churn', 'custom'])
kind?: 'health' | 'revenue' | 'churn' | 'custom'
@IsOptional() @IsString() @MaxLength(500)
description?: string
@IsOptional() @IsObject()
definition?: Record<string, unknown>
}
@@ -0,0 +1,69 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { Report, ReportDocument } from '../schemas/report.schema.js'
import type { CreateReportDto } from './dto/create-report.dto.js'
// Saved/custom reports CRUD, scoped to a partner. The live analytics are
// computed in UsersService.partnerReports — this only stores named configs.
@Injectable()
export class PartnerReportsService {
constructor(
@InjectModel(Report.name) private readonly model: Model<ReportDocument>,
private readonly audit: AuditService,
) {}
async list(partnerId: string | Types.ObjectId): Promise<ReportDocument[]> {
return this.model.find({ partnerId }).sort({ createdAt: -1 }).exec()
}
async create(
partnerId: string | Types.ObjectId,
dto: CreateReportDto,
actor?: AuditActor,
): Promise<ReportDocument> {
const report = await this.model.create({
partnerId,
name: dto.name,
kind: dto.kind ?? 'custom',
description: dto.description,
definition: dto.definition ?? {},
createdByEmail: actor?.email,
})
void this.audit.record(
{
action: 'report.created',
resourceType: 'partner',
resourceId: String(report._id),
resourceName: report.name,
metadata: { kind: report.kind },
},
actor,
)
return report
}
async remove(
id: string,
partnerId: string | Types.ObjectId,
actor?: AuditActor,
): Promise<{ removed: boolean }> {
const report = await this.model.findById(id).exec()
if (!report) throw new NotFoundException('Report not found')
if (String(report.partnerId) !== String(partnerId)) {
throw new ForbiddenException('Report is not in your portfolio')
}
await this.model.deleteOne({ _id: report._id }).exec()
void this.audit.record(
{
action: 'report.deleted',
resourceType: 'partner',
resourceId: id,
resourceName: report.name,
},
actor,
)
return { removed: true }
}
}
@@ -0,0 +1,34 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type ReportDocument = HydratedDocument<Report>
export type ReportKind = 'health' | 'revenue' | 'churn' | 'custom'
// A partner's saved/custom report definition. The analytics themselves are
// computed live (UsersService.partnerReports); this only persists a named,
// reusable configuration the partner created from the New-report modal.
@Schema({ collection: 'reports', timestamps: true })
export class Report {
@Prop({ type: Types.ObjectId, ref: 'Partner', required: true, index: true })
partnerId!: Types.ObjectId
@Prop({ required: true, trim: true })
name!: string
@Prop({ enum: ['health', 'revenue', 'churn', 'custom'], default: 'custom' })
kind!: ReportKind
@Prop({ trim: true })
description?: string
// Free-form saved configuration (metrics, filters, groupBy, schedule,
// recipients, format). UI-driven shape for v1.
@Prop({ type: Object, default: {} })
definition!: Record<string, unknown>
@Prop({ trim: true })
createdByEmail?: string
}
export const ReportSchema = SchemaFactory.createForClass(Report)
@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import { UsersService } from './users.service.js'
// Platform-wide analytics for the operator reports page. Operator-only.
@Controller('reports')
@UseGuards(JwtAuthGuard, OperatorGuard)
export class PlatformReportsController {
constructor(private readonly users: UsersService) {}
@Get('platform')
async platform() {
return this.users.platformReports()
}
}
@@ -9,6 +9,7 @@ import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { PlatformReportsController } from './platform-reports.controller.js'
import { UsersController } from './users.controller.js'
import { UsersService } from './users.service.js'
@@ -33,7 +34,7 @@ import { UsersService } from './users.service.js'
IntegrationsModule,
TenantsModule,
],
controllers: [UsersController],
controllers: [UsersController, PlatformReportsController],
providers: [UsersService],
exports: [UsersService],
})