-
+
-
+
-
+
-
+
@@ -288,8 +452,8 @@ onMounted(() => {
@@ -326,16 +490,16 @@ onMounted(() => {
-
+
-
+
-
+
-
+
@@ -351,26 +515,29 @@ onMounted(() => {
| Cohort |
- Size |
- {{ h }} |
+ Customers |
+ Retained |
+ Retention |
-
- | {{ c[0] }} |
- {{ c[1] }} |
-
- —
+ |
+ | {{ c.label }} |
+ {{ c.total }} |
+ {{ c.retained }} |
+
{{ v }}%
+ >{{ c.retentionPct }}%
|
+
+ | // no signup cohorts yet |
+
@@ -502,7 +669,7 @@ onMounted(() => {
toast.ok('Report created', n)"
+ @created="onCreated"
/>
diff --git a/apps/portal/server/api/partner/reports.get.ts b/apps/portal/server/api/partner/reports.get.ts
new file mode 100644
index 0000000..81f362e
--- /dev/null
+++ b/apps/portal/server/api/partner/reports.get.ts
@@ -0,0 +1,20 @@
+// Partner reports analytics. Forwards to platform-api GET /me/partner/reports.
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/me/partner/reports`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/apps/portal/server/api/partner/reports/saved.get.ts b/apps/portal/server/api/partner/reports/saved.get.ts
new file mode 100644
index 0000000..d2fce7e
--- /dev/null
+++ b/apps/portal/server/api/partner/reports/saved.get.ts
@@ -0,0 +1,20 @@
+// Saved partner reports. Forwards to platform-api GET /me/partner/reports/saved.
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/me/partner/reports/saved`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/apps/portal/server/api/partner/reports/saved.post.ts b/apps/portal/server/api/partner/reports/saved.post.ts
new file mode 100644
index 0000000..450b5c9
--- /dev/null
+++ b/apps/portal/server/api/partner/reports/saved.post.ts
@@ -0,0 +1,24 @@
+// Create a saved partner report. Forwards to platform-api
+// POST /me/partner/reports/saved.
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const body = await readBody(event)
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/me/partner/reports/saved`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${accessToken}` },
+ body,
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/apps/portal/server/api/partner/reports/saved/[id].delete.ts b/apps/portal/server/api/partner/reports/saved/[id].delete.ts
new file mode 100644
index 0000000..382958e
--- /dev/null
+++ b/apps/portal/server/api/partner/reports/saved/[id].delete.ts
@@ -0,0 +1,23 @@
+// Delete a saved partner report. Forwards to platform-api
+// DELETE /me/partner/reports/saved/:id.
+import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event).catch(() => null)
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken
+ if (!accessToken) {
+ throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
+ }
+
+ const id = getRouterParam(event, 'id')
+ const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
+ try {
+ return await $fetch(`${base}/me/partner/reports/saved/${id}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ } catch (err: unknown) {
+ const e = err as { statusCode?: number; data?: unknown }
+ throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
+ }
+})
diff --git a/services/platform-api/src/me/dto/create-report.dto.ts b/services/platform-api/src/me/dto/create-report.dto.ts
new file mode 100644
index 0000000..10c62ce
--- /dev/null
+++ b/services/platform-api/src/me/dto/create-report.dto.ts
@@ -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
+}
diff --git a/services/platform-api/src/me/partner-reports.service.ts b/services/platform-api/src/me/partner-reports.service.ts
new file mode 100644
index 0000000..3a61416
--- /dev/null
+++ b/services/platform-api/src/me/partner-reports.service.ts
@@ -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,
+ private readonly audit: AuditService,
+ ) {}
+
+ async list(partnerId: string | Types.ObjectId): Promise {
+ return this.model.find({ partnerId }).sort({ createdAt: -1 }).exec()
+ }
+
+ async create(
+ partnerId: string | Types.ObjectId,
+ dto: CreateReportDto,
+ actor?: AuditActor,
+ ): Promise {
+ 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 }
+ }
+}
diff --git a/services/platform-api/src/schemas/report.schema.ts b/services/platform-api/src/schemas/report.schema.ts
new file mode 100644
index 0000000..bc968fe
--- /dev/null
+++ b/services/platform-api/src/schemas/report.schema.ts
@@ -0,0 +1,34 @@
+import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
+import { HydratedDocument, Types } from 'mongoose'
+
+export type ReportDocument = HydratedDocument
+
+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
+
+ @Prop({ trim: true })
+ createdByEmail?: string
+}
+
+export const ReportSchema = SchemaFactory.createForClass(Report)
diff --git a/services/platform-api/src/users/platform-reports.controller.ts b/services/platform-api/src/users/platform-reports.controller.ts
new file mode 100644
index 0000000..6994baa
--- /dev/null
+++ b/services/platform-api/src/users/platform-reports.controller.ts
@@ -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()
+ }
+}
diff --git a/services/platform-api/src/users/users.module.ts b/services/platform-api/src/users/users.module.ts
index 9c88f6e..604e67b 100644
--- a/services/platform-api/src/users/users.module.ts
+++ b/services/platform-api/src/users/users.module.ts
@@ -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],
})