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