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