feat: portal redesign, pricing catalog, partner-staff invites

- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
This commit is contained in:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
+4
View File
@@ -6,7 +6,9 @@ import { AuthModule } from './auth/auth.module.js'
import { FlagsModule } from './flags/flags.module.js'
import { HealthModule } from './health/health.module.js'
import { IngestModule } from './ingest/ingest.module.js'
import { MeModule } from './me/me.module.js'
import { PartnersModule } from './partners/partners.module.js'
import { PricesModule } from './prices/prices.module.js'
import { SeedModule } from './seed/seed.module.js'
import { SubscriptionsModule } from './subscriptions/subscriptions.module.js'
import { TenantsModule } from './tenants/tenants.module.js'
@@ -24,7 +26,9 @@ import { UsersModule } from './users/users.module.js'
TenantsModule,
PartnersModule,
UsersModule,
MeModule,
SubscriptionsModule,
PricesModule,
FlagsModule,
IngestModule,
SeedModule,
@@ -190,6 +190,27 @@ export class AuditService {
return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
}
// Partner-scoped activity feed. Returns events where partnerSlug matches
// OR tenantSlug is one of the partner's tenants. Distinct shape from
// list() because the OR-across-many-slugs query doesn't fit the
// single-tenantSlug filter the operator UI uses.
async listForPartner(opts: {
partnerSlug: string
tenantSlugs: string[]
limit?: number
before?: Date
}): Promise<AuditEventDocument[]> {
const q: FilterQuery<AuditEventDocument> = {
$or: [
{ partnerSlug: opts.partnerSlug },
...(opts.tenantSlugs.length > 0 ? [{ tenantSlug: { $in: opts.tenantSlugs } }] : []),
],
}
if (opts.before) q.at = { $lt: opts.before }
const limit = clamp(opts.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT)
return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
}
// ── Chain integrity helpers ────────────────────────────────────────────
// Atomic monotonic counter. findOneAndUpdate with $inc and upsert returns
@@ -35,6 +35,12 @@ export class AuthentikClient {
const body = await res.text().catch(() => '')
throw new Error(`Authentik ${init.method ?? 'GET'} ${path}${res.status}: ${body.slice(0, 200)}`)
}
// 204 No Content (and other empty-body successes) crash res.json().
// Endpoints like /core/groups/:id/add_user/ return 204; callers with a
// void return type don't care about the payload, so hand back undefined.
if (res.status === 204 || res.headers.get('content-length') === '0') {
return undefined as T
}
return (await res.json()) as T
}
+22
View File
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from '../auth/auth.module.js'
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { UsersModule } from '../users/users.module.js'
import { PartnerMeController } from './partner-me.controller.js'
// Self-service portal surface. Composes UsersService (partner-scoped reads,
// invitePartnerUser, inviteTenantAdmin, partnerMrr, partnerActivity) and
// TenantsService (create) behind clean /me/partner/* URLs. No service of
// its own — the controllers are thin façades.
@Module({
imports: [
AuthModule,
UsersModule,
TenantsModule,
MongooseModule.forFeature([{ name: Partner.name, schema: PartnerSchema }]),
],
controllers: [PartnerMeController],
})
export class MeModule {}
@@ -0,0 +1,141 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import type { Model } from 'mongoose'
import { ActorService } from '../auth/actor.service.js'
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 { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { CreateTenantDto } from '../tenants/dto/create-tenant.dto.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { UsersService } from '../users/users.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
// guard. A portal-aud JWT is sufficient; partner-staff is enforced per
// handler via the actor.partnerId check.
//
// Identity endpoints (GET /users/me, etc.) intentionally stay on
// UsersController — those are about "who am I", whereas everything here is
// about "what does my partner own".
@Controller('me/partner')
@UseGuards(JwtAuthGuard)
export class PartnerMeController {
constructor(
private readonly users: UsersService,
private readonly tenants: TenantsService,
private readonly actor: ActorService,
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
) {}
// The OTHER people at the caller's partner organization. Distinct from
// GET /partners/:slug/users (operator only): scoped via the actor's
// User.partnerId so a portal-aud JWT works.
@Get('users')
async listUsers(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.users.listPartnerUsers(actor.partnerId)
}
// Tenants (customers) attached to the partner.
@Get('tenants')
async listTenants(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.users.listPartnerTenants(actor.partnerId)
}
// Self-service tenant create. Counterpart to operator POST /tenants.
// Forces partnerId from actor.partnerId so a partner can never create
// a tenant under a different partner, even if their payload says so.
// If adminName + adminEmail are present, fires inviteTenantAdmin after
// tenant provisioning. Admin failures don't roll back the tenant — the
// response includes an `adminInvite` field with credentials or an error.
@Post('tenants')
async createTenant(
@Body() dto: CreateTenantDto,
@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),
}
const safeDto = { ...dto, partnerId: actor.partnerId } as CreateTenantDto & {
partnerId: typeof actor.partnerId
}
const tenant = await this.tenants.create(safeDto, auditActor)
let adminInvite:
| { subject: string; userId: string; attached?: boolean; link?: string; tempPassword?: string }
| { error: string }
| undefined
if (dto.adminName && dto.adminEmail) {
try {
adminInvite = await this.users.inviteTenantAdmin(
{ _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
{ name: dto.adminName, email: dto.adminEmail },
auditActor,
)
} catch (err) {
adminInvite = { error: err instanceof Error ? err.message : String(err) }
}
}
return { tenant, adminInvite }
}
// Monthly Recurring Revenue across the partner's customers — grouped by
// currency since subs can be billed in DKK / EUR / USD independently.
@Get('mrr')
async mrr(@CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.partnerId) {
throw new ForbiddenException('Not a partner-staff user')
}
return this.users.partnerMrr(actor.partnerId)
}
// Recent audit events across the partner's portfolio. Used by the
// dashboard's Activity card and the /partner/audit page. Pagination via
// ?before=<iso> + ?limit=N.
@Get('activity')
async activity(
@CurrentUser() jwt: AuthentikJwtPayload,
@Query('limit') limit?: string,
@Query('before') before?: string,
) {
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')
}
return this.users.partnerActivity(actor.partnerId, partner.slug, {
limit: limit ? Number(limit) : undefined,
before: before ? new Date(before) : undefined,
})
}
}
@@ -17,6 +17,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { InvitePartnerUserDto } from '../users/dto/invite-partner-user.dto.js'
import { UsersService } from '../users/users.service.js'
import { CreatePartnerDto } from './dto/create-partner.dto.js'
import { UpdatePartnerDto } from './dto/update-partner.dto.js'
import { PartnersService } from './partners.service.js'
@@ -42,6 +44,7 @@ function auditActor(
export class PartnersController {
constructor(
private readonly partners: PartnersService,
private readonly users: UsersService,
private readonly actorService: ActorService,
) {}
@@ -93,4 +96,31 @@ export class PartnersController {
const user = await this.actorService.resolve(jwt)
await this.partners.terminate(slug, auditActor(user, req))
}
// Partner-staff team listing. Returns the User docs whose partnerId matches
// this partner. The /partners/:slug page's Team section calls this on load.
@Get(':slug/users')
async listUsers(@Param('slug') slug: string) {
const partner = await this.partners.findOneBySlug(slug)
return this.users.listPartnerUsers(partner._id)
}
// Invite a new partner-staff user. Resolves slug → partner, delegates to
// UsersService.invitePartnerUser which handles Authentik user creation,
// group assignment, local User pre-create, and audit recording.
@Post(':slug/users')
async inviteUser(
@Param('slug') slug: string,
@Body() dto: InvitePartnerUserDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actorService.resolve(jwt)
const partner = await this.partners.findOneBySlug(slug)
return this.users.invitePartnerUser(
dto,
{ _id: partner._id, slug: partner.slug },
auditActor(actor, req),
)
}
}
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common'
import { Module, forwardRef } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { UsersModule } from '../users/users.module.js'
import { PartnersController } from './partners.controller.js'
import { PartnersService } from './partners.service.js'
@@ -15,6 +16,10 @@ import { PartnersService } from './partners.service.js'
]),
AuthModule,
AuditModule,
// forwardRef defensively — if UsersModule ever grows a dependency back
// on PartnersModule (e.g. partner-portal scope guards that need
// PartnersService.findOneBySlug), we avoid the circular-dep refactor.
forwardRef(() => UsersModule),
],
controllers: [PartnersController],
providers: [PartnersService],
@@ -0,0 +1,24 @@
import { Type } from 'class-transformer'
import { IsBoolean, IsEnum, IsInt, IsOptional, Min, ValidateNested } from 'class-validator'
// Per-currency amount sub-object. Each currency is optional — a plan that
// isn't sold in EUR just leaves EUR undefined. Stored in minor units.
class AmountsDto {
@IsOptional() @IsInt() @Min(0) DKK?: number
@IsOptional() @IsInt() @Min(0) EUR?: number
@IsOptional() @IsInt() @Min(0) USD?: number
}
export class CreatePriceDto {
@IsEnum(['mvp', 'pro', 'enterprise'])
plan!: 'mvp' | 'pro' | 'enterprise'
@IsEnum(['monthly', 'quarterly', 'yearly'])
cycle!: 'monthly' | 'quarterly' | 'yearly'
@ValidateNested() @Type(() => AmountsDto)
amounts!: AmountsDto
@IsOptional() @IsBoolean()
active?: boolean
}
@@ -0,0 +1,20 @@
import { Type } from 'class-transformer'
import { IsBoolean, IsInt, IsOptional, Min, ValidateNested } from 'class-validator'
class AmountsDto {
@IsOptional() @IsInt() @Min(0) DKK?: number
@IsOptional() @IsInt() @Min(0) EUR?: number
@IsOptional() @IsInt() @Min(0) USD?: number
}
// All fields optional — PATCH a single currency, the active flag, or both.
// plan + cycle are the row identity and are not editable here (deactivate
// and create a new row if those need to change, which the partial unique
// index enforces).
export class UpdatePriceDto {
@IsOptional() @ValidateNested() @Type(() => AmountsDto)
amounts?: AmountsDto
@IsOptional() @IsBoolean()
active?: boolean
}
@@ -0,0 +1,38 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import { CreatePriceDto } from './dto/create-price.dto.js'
import { UpdatePriceDto } from './dto/update-price.dto.js'
import { PricesService } from './prices.service.js'
// All endpoints require an authenticated caller. Read is open to any
// authenticated JWT (partners + portal users see prices for display);
// write operations layer the OperatorGuard on top.
@Controller('prices')
@UseGuards(JwtAuthGuard)
export class PricesController {
constructor(private readonly prices: PricesService) {}
@Get()
list(@Query('includeInactive') includeInactive?: string) {
return this.prices.findAll(includeInactive === 'true')
}
@Post()
@UseGuards(OperatorGuard)
create(@Body() dto: CreatePriceDto) {
return this.prices.create(dto)
}
@Patch(':id')
@UseGuards(OperatorGuard)
update(@Param('id') id: string, @Body() dto: UpdatePriceDto) {
return this.prices.update(id, dto)
}
@Delete(':id')
@UseGuards(OperatorGuard)
deactivate(@Param('id') id: string) {
return this.prices.deactivate(id)
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from '../auth/auth.module.js'
import { Price, PriceSchema } from '../schemas/price.schema.js'
import { PricesController } from './prices.controller.js'
import { PricesService } from './prices.service.js'
@Module({
imports: [
MongooseModule.forFeature([{ name: Price.name, schema: PriceSchema }]),
AuthModule,
],
controllers: [PricesController],
providers: [PricesService],
exports: [PricesService],
})
export class PricesModule {}
@@ -0,0 +1,154 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { Price, PriceDocument, type PriceCycle, type PriceCurrency, type PricePlan } from '../schemas/price.schema.js'
import type { CreatePriceDto } from './dto/create-price.dto.js'
import type { UpdatePriceDto } from './dto/update-price.dto.js'
@Injectable()
export class PricesService {
private readonly logger = new Logger(PricesService.name)
constructor(@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>) {}
async findAll(includeInactive = false): Promise<PriceDocument[]> {
const filter = includeInactive ? {} : { active: true }
return this.priceModel.find(filter).sort({ plan: 1, cycle: 1 }).exec()
}
// Lookup used by tenant provisioning: which active row covers this
// (plan, cycle)? Single row per pair now — currency selection happens at
// amount-read time, not row-lookup time. Returns null when the row is
// missing entirely (e.g. Enterprise that the operator hasn't priced).
async findActive(plan: PricePlan, cycle: PriceCycle): Promise<PriceDocument | null> {
return this.priceModel.findOne({ plan, cycle, active: true }).exec()
}
// Extract a currency's amount from a Price row, returning undefined when
// that currency isn't priced. Centralizes the lookup so callers don't
// reach into the amounts object directly.
amountFor(price: PriceDocument, currency: PriceCurrency): number | undefined {
return price.amounts[currency]
}
async create(dto: CreatePriceDto): Promise<PriceDocument> {
try {
return await this.priceModel.create({ ...dto, active: dto.active ?? true })
} catch (err: unknown) {
const e = err as { code?: number; message?: string }
if (e.code === 11000) {
throw new ConflictException(
`Active price for ${dto.plan}/${dto.cycle} already exists — deactivate or PATCH it.`,
)
}
throw err
}
}
async update(id: string, dto: UpdatePriceDto): Promise<PriceDocument> {
// Treat amounts as a partial merge so PATCH { amounts: { EUR: 700 } }
// updates only EUR without clobbering DKK and USD.
const $set: Record<string, unknown> = {}
if (dto.amounts) {
if (dto.amounts.DKK !== undefined) $set['amounts.DKK'] = dto.amounts.DKK
if (dto.amounts.EUR !== undefined) $set['amounts.EUR'] = dto.amounts.EUR
if (dto.amounts.USD !== undefined) $set['amounts.USD'] = dto.amounts.USD
}
if (dto.active !== undefined) $set.active = dto.active
const price = await this.priceModel
.findByIdAndUpdate(id, { $set }, { new: true, runValidators: true })
.exec()
if (!price) throw new NotFoundException(`Price ${id} not found`)
return price
}
async deactivate(id: string): Promise<PriceDocument> {
return this.update(id, { active: false })
}
// Bootstrap. Runs in two phases:
// 1. Migrate any legacy rows (have top-level `currency` + `perSeatAmount`)
// into the new per-row currency-map shape. Preserves operator edits:
// if Starter/Monthly was edited to 48 DKK before, the new row keeps
// DKK=4800 and only fills in EUR/USD defaults.
// 2. Insert any missing default rows (Starter + Business × 3 cycles)
// with sensible DKK/EUR/USD round numbers.
// Both phases are idempotent — safe to run on every boot.
async ensureDefaults(): Promise<void> {
await this.migrateLegacy()
type Defaults = {
plan: PricePlan
cycle: PriceCycle
amounts: { DKK: number; EUR: number; USD: number }
}
const defaults: Defaults[] = [
// Round numbers in each currency, not FX-derived. Operator can edit.
{ plan: 'mvp', cycle: 'monthly', amounts: { DKK: 4900, EUR: 700, USD: 700 } },
{ plan: 'mvp', cycle: 'quarterly', amounts: { DKK: 13200, EUR: 1900, USD: 1900 } },
{ plan: 'mvp', cycle: 'yearly', amounts: { DKK: 48800, EUR: 7000, USD: 7000 } },
{ plan: 'pro', cycle: 'monthly', amounts: { DKK: 12900, EUR: 1800, USD: 1800 } },
{ plan: 'pro', cycle: 'quarterly', amounts: { DKK: 34800, EUR: 4900, USD: 4900 } },
{ plan: 'pro', cycle: 'yearly', amounts: { DKK: 128800, EUR: 17800, USD: 17800 } },
]
for (const row of defaults) {
const exists = await this.priceModel.findOne({ plan: row.plan, cycle: row.cycle, active: true })
if (!exists) {
await this.priceModel.create({ ...row, active: true }).catch(() => {
// Race / unique-index hit — another boot inserted it; ignore.
})
}
}
}
// Migration: legacy docs had { currency, perSeatAmount } and one row per
// (plan, cycle, currency). Collapse them: pick the active row per
// (plan, cycle), move perSeatAmount under amounts[currency], and drop
// the legacy fields. Inactive duplicates get deleted (their identity has
// been folded into the surviving row).
private async migrateLegacy(): Promise<void> {
const legacy = await this.priceModel
.find({ currency: { $exists: true }, perSeatAmount: { $exists: true } } as Record<string, unknown>)
.lean()
.exec()
if (legacy.length === 0) return
this.logger.log(`Migrating ${legacy.length} legacy Price row(s) to per-row currency map`)
// Group legacy rows by (plan, cycle). Keep the survivor with the most
// currencies represented (or just any active one) and merge amounts in.
const groups = new Map<string, typeof legacy>()
for (const row of legacy) {
const key = `${row.plan}|${row.cycle}`
const list = groups.get(key) ?? []
list.push(row)
groups.set(key, list)
}
for (const [key, rows] of groups) {
// Choose survivor: prefer an active row, then the lowest _id (stable).
const survivor = rows.find((r) => (r as { active?: boolean }).active) ?? rows[0]
const merged: { DKK?: number; EUR?: number; USD?: number } = {}
for (const r of rows) {
const c = (r as { currency?: PriceCurrency }).currency
const v = (r as { perSeatAmount?: number }).perSeatAmount
if (c && typeof v === 'number') merged[c] = v
}
await this.priceModel.updateOne(
{ _id: survivor._id },
{
$set: { amounts: merged, active: true },
$unset: { currency: '', perSeatAmount: '' },
},
)
// Delete the non-survivor rows (their amounts are now in the merged map).
const losers = rows.filter((r) => String(r._id) !== String(survivor._id)).map((r) => r._id)
if (losers.length > 0) {
await this.priceModel.deleteMany({ _id: { $in: losers } })
}
this.logger.log(`Migrated ${key}: amounts=${JSON.stringify(merged)} (dropped ${losers.length} duplicate row(s))`)
}
}
}
@@ -0,0 +1,55 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument } from 'mongoose'
export type PriceDocument = HydratedDocument<Price>
export type PriceCycle = 'monthly' | 'quarterly' | 'yearly'
export type PriceCurrency = 'DKK' | 'EUR' | 'USD'
export type PricePlan = 'mvp' | 'pro' | 'enterprise'
// One row per (plan, cycle). Each row holds an independent per-currency
// amount map so operators can set clean round numbers in every currency
// (49 DKK / 7 EUR / 7 USD) instead of FX-derived fractions. Currency without
// a value means "we don't sell this plan/cycle in that currency"; the wizard
// rejects provisioning if a chosen currency isn't priced.
//
// All amounts in MINOR units (øre / cents / cents). 4900 = 49.00, regardless
// of currency.
@Schema({ collection: 'prices', timestamps: true })
export class Price {
@Prop({ enum: ['mvp', 'pro', 'enterprise'], required: true, index: true })
plan!: PricePlan
@Prop({ enum: ['monthly', 'quarterly', 'yearly'], required: true, index: true })
cycle!: PriceCycle
@Prop({
type: {
DKK: { type: Number, min: 0 },
EUR: { type: Number, min: 0 },
USD: { type: Number, min: 0 },
},
required: true,
default: () => ({}),
})
amounts!: {
DKK?: number
EUR?: number
USD?: number
}
// Soft-active flag. When the operator changes a row's amounts we mutate
// the row in place; deactivation is for cases like end-of-life'd plans
// where we want to preserve subs' priceId references without making the
// row attach to NEW provisioning runs.
@Prop({ type: Boolean, default: true, index: true })
active!: boolean
}
export const PriceSchema = SchemaFactory.createForClass(Price)
// One active row per (plan, cycle). Partial filter keeps deactivated history
// rows from blocking the index.
PriceSchema.index(
{ plan: 1, cycle: 1 },
{ unique: true, partialFilterExpression: { active: true } },
)
@@ -19,6 +19,36 @@ export class Subscription {
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
plan!: 'mvp' | 'pro' | 'enterprise'
// Billing cycle. Carried alongside plan so MRR aggregation doesn't have
// to walk to the Price doc just to normalise to monthly.
@Prop({ enum: ['monthly', 'quarterly', 'yearly'], default: 'monthly' })
cycle!: 'monthly' | 'quarterly' | 'yearly'
// Currency the customer is billed in. Snapshotted from the wizard so a
// later catalog edit that adds/removes currencies doesn't change what
// this customer pays. MRR aggregation groups by this field.
@Prop({ enum: ['DKK', 'EUR', 'USD'], default: 'DKK', index: true })
currency!: 'DKK' | 'EUR' | 'USD'
// The catalog row this sub was provisioned against. Sparse — Enterprise
// (custom pricing) and pre-billing-pipeline tenants leave this empty,
// which MRR aggregation treats as "0 / custom".
@Prop({ type: Types.ObjectId, ref: 'Price', index: true, sparse: true })
priceId?: Types.ObjectId
// Snapshot of the per-seat amount in `currency` at provision time. Lets
// MRR aggregation compute without re-reading the Price doc, and keeps
// the historical bill stable when the operator edits the catalog later.
@Prop({ type: Number, min: 0, default: 0 })
perSeatAmount!: number
// Snapshot of the seat count at provision time. Updated when the
// operator/partner changes seats — we don't recompute from Tenant.seats
// on each MRR aggregation so a mid-cycle seat change is visible
// immediately without waiting for the next renewal.
@Prop({ type: Number, min: 0, default: 0 })
seats!: number
@Prop({
enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired'],
default: 'trialing',
@@ -25,6 +25,14 @@ export class Tenant {
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
plan!: TenantPlan
// Initial seat count from provisioning. Used for portfolio displays and
// (later) MRR calculations. The "used" count comes from User.tenantIds —
// not stored here to avoid a denormalized field that drifts on every
// user-add/remove. Default 0 so older docs without this field render
// as "0 / N" without throwing.
@Prop({ type: Number, min: 0, default: 0 })
seats!: number
// Custom domains attached to this tenant. First entry is the primary host.
@Prop({ type: [String], default: [] })
domains!: string[]
@@ -15,6 +15,15 @@ export class User {
@Prop({ type: [Types.ObjectId], ref: 'Tenant', default: [], index: true })
tenantIds!: Types.ObjectId[]
// Partner this user works FOR (staff/admin at a partner organization).
// Optional — most users (tenant members + platform admins) leave this null.
// When set, /partners/:slug/users lists this user and future partner-portal
// scope guards can verify the JWT-resolved user belongs to the requested
// partner. Per-partner role is implicit in being on the list for v1;
// refine to per-partner role enum later if partner-portal RBAC needs it.
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true })
partnerId?: Types.ObjectId
@Prop({ required: true, lowercase: true, trim: true, index: true })
email!: string
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { PricesModule } from '../prices/prices.module.js'
import { Flag, FlagSchema } from '../schemas/flag.schema.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
@@ -10,6 +11,7 @@ import { SeedService } from './seed.service.js'
@Module({
imports: [
IntegrationsModule,
PricesModule,
MongooseModule.forFeature([
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { PricesService } from '../prices/prices.service.js'
import { Flag, FlagDocument, type FlagState } from '../schemas/flag.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
@@ -62,6 +63,7 @@ export class SeedService implements OnApplicationBootstrap {
@InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>,
private readonly authentik: AuthentikClient,
private readonly config: ConfigService,
private readonly prices: PricesService,
) {}
async onApplicationBootstrap(): Promise<void> {
@@ -105,6 +107,11 @@ export class SeedService implements OnApplicationBootstrap {
// No user seeded here — UsersController.me() upserts akadmin on first call.
// Pricing catalog defaults. Idempotent — operator edits via /pricing
// (which marks old rows inactive + inserts new ones) are preserved.
await this.prices.ensureDefaults()
this.logger.log('Price catalog defaults ensured')
// Feature flags. Seeded via $setOnInsert so an operator who later edits a
// flag's state through the UI doesn't get their change reverted on the
// next bootstrap.
@@ -1,11 +1,15 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator'
@@ -30,6 +34,33 @@ export class CreateTenantDto {
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
plan?: 'mvp' | 'pro' | 'enterprise'
// Billing cycle for the auto-created Subscription. Defaults to monthly
// when omitted. Drives which Price catalog row attaches to the sub.
@IsOptional() @IsEnum(['monthly', 'quarterly', 'yearly'])
cycle?: 'monthly' | 'quarterly' | 'yearly'
// Currency this customer is billed in. Picked by the partner in the
// wizard. The matching Price row must have an amount set for this
// currency or the tenant is created without a Subscription (operator
// fixes up later).
@IsOptional() @IsEnum(['DKK', 'EUR', 'USD'])
currency?: 'DKK' | 'EUR' | 'USD'
// First-admin invite. Optional — when present, the wizard's "First admin"
// step has been filled in and the platform creates an Authentik user +
// local User doc tied to this tenant as part of provisioning. Failures
// here don't roll back the tenant — the operator can re-invite later.
@IsOptional() @IsString() @MinLength(1) @MaxLength(200)
adminName?: string
@IsOptional() @IsEmail() @MaxLength(254)
adminEmail?: string
// Initial seat count. Upper bound is generous — partners can provision
// bulk-license tenants and the platform doesn't impose a hard cap here.
@IsOptional() @IsInt() @Min(0) @Max(10000)
seats?: number
@IsOptional() @IsArray() @IsString({ each: true })
domains?: string[]
@@ -1,4 +1,15 @@
import { IsArray, IsEnum, IsMongoId, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
import {
IsArray,
IsEnum,
IsInt,
IsMongoId,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator'
export class UpdateTenantDto {
@IsOptional() @IsString() @MinLength(2) @MaxLength(120)
@@ -10,6 +21,9 @@ export class UpdateTenantDto {
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
plan?: 'mvp' | 'pro' | 'enterprise'
@IsOptional() @IsInt() @Min(0) @Max(10000)
seats?: number
@IsOptional() @IsArray() @IsString({ each: true })
domains?: string[]
@@ -3,6 +3,8 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { PricesModule } from '../prices/prices.module.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { ProvisioningService } from './provisioning.service.js'
@@ -14,10 +16,15 @@ import { TenantsService } from './tenants.service.js'
MongooseModule.forFeature([
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
// Subscription model accessed directly by TenantsService so a freshly
// provisioned tenant gets its Subscription doc in the same call. Price
// lookup goes through PricesService for the soft-active filter.
{ name: Subscription.name, schema: SubscriptionSchema },
]),
AuthModule,
AuditModule,
IntegrationsModule,
PricesModule,
],
controllers: [TenantsController],
providers: [TenantsService, ProvisioningService],
@@ -1,7 +1,9 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { PricesService } from '../prices/prices.service.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
@@ -10,11 +12,15 @@ import { ProvisioningService } from './provisioning.service.js'
@Injectable()
export class TenantsService {
private readonly logger = new Logger(TenantsService.name)
constructor(
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
private readonly provisioning: ProvisioningService,
private readonly audit: AuditService,
private readonly prices: PricesService,
) {}
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
@@ -36,10 +42,40 @@ export class TenantsService {
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
metadata: { plan: tenant.plan, domains: tenant.domains },
metadata: { plan: tenant.plan, domains: tenant.domains, seats: tenant.seats },
},
actor,
)
// Spin up the matching Subscription. Looks up the active Price row for
// (plan, cycle) — single row regardless of currency now — then reads
// amounts[currency] for the snapshot. If the chosen currency isn't
// priced, we still create the sub but with perSeatAmount=0 so MRR
// reports it as "Custom" rather than a misleading non-zero value.
try {
const plan = tenant.plan ?? 'mvp'
const cycle = dto.cycle ?? 'monthly'
const currency = dto.currency ?? 'DKK'
const price = await this.prices.findActive(plan, cycle)
const perSeatAmount = price ? (this.prices.amountFor(price, currency) ?? 0) : 0
await this.subModel.create({
tenantId: tenant._id,
plan,
cycle,
currency,
priceId: price?._id,
perSeatAmount,
seats: tenant.seats ?? 0,
status: 'active',
})
} catch (err) {
this.logger.warn(
`Subscription auto-create failed for tenant ${tenant.slug}: ${
err instanceof Error ? err.message : String(err)
} — tenant created, MRR will read as 0 until the sub is added.`,
)
}
// Provision external resources best-effort. Errors are recorded on the doc;
// the caller can re-POST or call /tenants/:slug/reconcile to retry.
return this.provisioning.reconcile(tenant)
@@ -0,0 +1,16 @@
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'
// Operator-only: invite someone at a partner organization. Mirrors
// InviteOperatorDto in shape — body is just identity. The partner is
// resolved from the URL slug (POST /partners/:slug/users) so it never
// appears in the body. The created user is added to the
// dezky-partner-staff Authentik group and has User.partnerId set to the
// partner's _id; future partner-portal scope guards use that to allow
// access only to the partner's own data.
export class InvitePartnerUserDto {
@IsString() @MinLength(2) @MaxLength(120)
name!: string
@IsEmail() @MaxLength(254)
email!: string
}
@@ -45,9 +45,11 @@ export class UsersController {
// The signed-in user's own profile — bootstraps the user record on first call,
// and syncs name/email/tenants/platformAdmin from the JWT on every subsequent call.
// Adds a `partner` field when User.partnerId is set so the portal can decide
// whether to render the partner-admin surface or the end-user surface.
@Get('me')
async me(@CurrentUser() jwt: AuthentikJwtPayload) {
return this.users.upsertFromAuthentik({
return this.users.meWithPartner({
subject: jwt.sub,
email: jwt.email ?? jwt.preferred_username ?? jwt.sub,
name: jwt.name ?? jwt.preferred_username ?? jwt.email ?? jwt.sub,
@@ -56,6 +58,9 @@ export class UsersController {
})
}
// Partner-scoped endpoints live in PartnerMeController under /me/partner.
// Identity endpoints (above) stay here.
@Post()
async create(@Body() dto: CreateUserDto, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
@@ -3,6 +3,9 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
import { Price, PriceSchema } from '../schemas/price.schema.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
@@ -14,6 +17,16 @@ import { UsersService } from './users.service.js'
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: Tenant.name, schema: TenantSchema },
// Partner model registered directly (instead of importing PartnersModule)
// to avoid the circular import — PartnersModule already imports
// UsersModule for the invitePartnerUser delegation.
{ name: Partner.name, schema: PartnerSchema },
// Subscription + Price are read by partnerMrr() — MRR aggregation
// runs entirely in this service rather than chaining into
// SubscriptionsModule, since we want one direct query path that's
// easy to extend (prorating, multi-currency) later.
{ name: Subscription.name, schema: SubscriptionSchema },
{ name: Price.name, schema: PriceSchema },
]),
AuthModule,
AuditModule,
@@ -2,14 +2,26 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestj
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import type { AuditEventDocument } from '../schemas/audit-event.schema.js'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Price, PriceDocument } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { CreateUserDto } from './dto/create-user.dto.js'
import type { InviteOperatorDto } from './dto/invite-operator.dto.js'
import type { InvitePartnerUserDto } from './dto/invite-partner-user.dto.js'
import type { UpdateUserDto } from './dto/update-user.dto.js'
// Authentik group every partner-staff invite gets added to. We use ONE
// group across all partners (instead of `dezky-partner-{slug}` per partner)
// because partner scoping is enforced server-side via User.partnerId — the
// group claim just marks "this user is partner staff, route them to the
// partner portal." Simpler than reconciling groups on partner rename.
const PARTNER_STAFF_GROUP = 'dezky-partner-staff'
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name)
@@ -18,10 +30,16 @@ export class UsersService {
// created once during Authentik bootstrap and never moves; no need to look
// it up every invite.
private platformAdminGroupId: string | null = null
// Same caching for the partner-staff group. Created lazily on first
// invitePartnerUser call via ensureGroup (idempotent).
private partnerStaffGroupId: string | null = null
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
private readonly audit: AuditService,
private readonly authentik: AuthentikClient,
config: ConfigService,
@@ -90,6 +108,41 @@ export class UsersService {
// Called on every authenticated request from /users/me. The JWT's groups claim
// is treated as a hint for first-time membership sync — the DB is the source of
// truth for all subsequent authorization decisions.
// What /users/me returns — the user doc plus the partner object when
// User.partnerId is set. Frontends use the presence of `partner` to decide
// whether to render the partner-admin surface vs. the end-user surface.
async meWithPartner(payload: {
subject: string
email: string
name: string
tenantSlugs: string[]
platformAdmin: boolean
}): Promise<UserDocument & { partner?: { _id: string; slug: string; name: string; status: string } }> {
const user = await this.upsertFromAuthentik(payload)
if (!user.partnerId) {
return user as UserDocument & { partner?: never }
}
const partner = await this.partnerModel
.findById(user.partnerId, { slug: 1, name: 1, status: 1 })
.exec()
if (!partner) {
// Partner was deleted out from under the user. Don't fail the whole
// /me call — just omit the partner field; the frontend will treat
// them as a regular end-user.
return user as UserDocument & { partner?: never }
}
const userObj = user.toObject() as UserDocument & {
partner?: { _id: string; slug: string; name: string; status: string }
}
userObj.partner = {
_id: String(partner._id),
slug: partner.slug,
name: partner.name,
status: partner.status,
}
return userObj
}
async upsertFromAuthentik(payload: {
subject: string
email: string
@@ -217,6 +270,391 @@ export class UsersService {
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// Invite a user that works at a partner organization. Same shape as
// inviteOperator but adds the dezky-partner-staff group, sets
// User.partnerId, and records a partner-scoped audit event. The caller
// (PartnersController) already resolved the slug → partner, so we receive
// the partnerId directly.
async invitePartnerUser(
dto: InvitePartnerUserDto,
partner: { _id: Types.ObjectId; slug: string },
actor?: AuditActor,
): Promise<{
subject: string
userId: string
// True if we attached an existing Authentik user instead of creating one.
// When attached, link/tempPassword are omitted (the user already has a
// password) and the UI shows a simpler success view.
attached?: boolean
link?: string
tempPassword?: string
}> {
const groupPk = await this.resolvePartnerStaffGroupId()
// ── Attach path: user already exists in Authentik ────────────────────
// Common when:
// - Operator promotes an existing platform admin to also work for a
// partner (e.g. internal staff cross-referenced as a partner contact).
// - User was created via a different invite path (operator team) and
// should now also be visible under a partner.
// Refuse only if their local User doc already points at a DIFFERENT
// partner — silently moving them would erase the prior relationship.
const existing = await this.authentik.findUserByEmail(dto.email)
if (existing) {
const localUser = await this.userModel.findOne({ authentikSubjectId: existing.uid }).exec()
if (
localUser?.partnerId &&
String(localUser.partnerId) !== String(partner._id)
) {
throw new ConflictException(
`User ${dto.email} already belongs to partner ${String(localUser.partnerId)} — detach them from that partner first.`,
)
}
await this.authentik.addUserToGroup(existing.pk, groupPk)
// Upsert local User. Existing docs get partnerId set; missing docs
// (Authentik-only users like akadmin pre-/users/me) are created so the
// partner team list shows them immediately instead of waiting for first
// login to materialize the doc.
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: existing.uid },
{
$set: {
email: existing.email,
// Don't clobber the local name if we have one (e.g. they
// already logged in and set it from the JWT); only seed on insert.
partnerId: partner._id,
},
$setOnInsert: {
name: existing.name || dto.name,
role: 'member',
active: true,
tenantIds: [],
platformAdmin: false,
},
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'partner.user_attached',
resourceType: 'user',
resourceId: existing.uid,
resourceName: existing.email,
partnerSlug: partner.slug,
metadata: { name: existing.name || dto.name, role: 'partner-staff' },
},
actor,
)
return { subject: existing.uid, userId: String(existing.pk), attached: true }
}
// ── Create path: brand-new user in Authentik ─────────────────────────
const created = await this.authentik.createUser({
username: dto.email,
email: dto.email,
name: dto.name,
groupPks: [groupPk],
attributes: {
partnerSlug: partner.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
// Pre-create the local User doc with partnerId so the partner team list
// reflects the invite immediately. On first login, /users/me upserts
// and reconciles email/name/lastLoginAt from the JWT (partnerId is
// preserved — see upsertFromAuthentik).
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: {
email: dto.email,
name: dto.name,
partnerId: partner._id,
},
$setOnInsert: { role: 'member', active: true, tenantIds: [], platformAdmin: false },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
let link: string | undefined
let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk)
if (!link) {
tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword)
await this.authentik.markPasswordExpired(created.pk)
}
void this.audit.record(
{
action: 'partner.user_invited',
resourceType: 'user',
resourceId: created.uid,
resourceName: dto.email,
partnerSlug: partner.slug,
metadata: {
role: 'partner-staff',
name: dto.name,
handoff: link ? 'recovery-link' : 'temp-password',
},
},
actor,
)
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// List users belonging to a partner. Called by the operator partner-detail
// page's Team section.
async listPartnerUsers(partnerId: Types.ObjectId): Promise<UserDocument[]> {
return this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
}
// List tenants attached to a partner. Used by the partner-portal's
// /partner/customers page (via /users/me/partner/tenants) and could be
// reused for operator surfaces that want partner-scoped tenant queries.
// Each tenant carries a userCount (admins + members) so the seat "used"
// column can render N/M without a second round-trip from the client.
async listPartnerTenants(
partnerId: Types.ObjectId,
): Promise<Array<TenantDocument & { userCount: number; newUserCount30d: number }>> {
const tenants = await this.tenantModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
if (tenants.length === 0) return []
const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
// Single aggregation across all of the partner's tenants. We compute
// both the total active user count and the subset created in the last
// 30 days using $cond — one pass over the collection regardless of
// how many tenants the partner has.
const counts = await this.userModel.aggregate<{
_id: Types.ObjectId
n: number
new30d: number
}>([
{ $match: { tenantIds: { $in: tenants.map((t) => t._id) }, active: true } },
{ $unwind: '$tenantIds' },
{ $match: { tenantIds: { $in: tenants.map((t) => t._id) } } },
{
$group: {
_id: '$tenantIds',
n: { $sum: 1 },
new30d: { $sum: { $cond: [{ $gte: ['$createdAt', since30d] }, 1, 0] } },
},
},
])
const countMap = new Map(counts.map((c) => [String(c._id), c]))
return tenants.map((t) => {
const obj = t.toObject() as TenantDocument & {
userCount: number
newUserCount30d: number
}
const c = countMap.get(String(t._id))
obj.userCount = c?.n ?? 0
obj.newUserCount30d = c?.new30d ?? 0
return obj
})
}
// Create (or attach) the first admin user for a freshly-provisioned
// tenant. Same shape as invitePartnerUser but adds the user to the
// tenant's Authentik group (created during provisioning) instead of
// dezky-partner-staff, sets User.tenantIds to include this tenant, and
// audits with tenantSlug.
//
// Failures are surfaced to the caller (TenantsController) rather than
// swallowed — the wizard wants to show "admin invite failed: ..." in the
// done state so the operator can retry rather than silently shipping a
// tenant with no admin.
async inviteTenantAdmin(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
dto: { name: string; email: string },
actor?: AuditActor,
): Promise<{
subject: string
userId: string
attached?: boolean
link?: string
tempPassword?: string
}> {
if (!tenant.authentikGroupId) {
throw new Error(
`Tenant ${tenant.slug} has no authentikGroupId — provisioning didn't complete. Retry /tenants/${tenant.slug}/reconcile and re-invite.`,
)
}
// ── Attach existing Authentik user ─────────────────────────────────
const existing = await this.authentik.findUserByEmail(dto.email)
if (existing) {
await this.authentik.addUserToGroup(existing.pk, tenant.authentikGroupId)
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: existing.uid },
{
$set: { email: existing.email },
$setOnInsert: {
name: existing.name || dto.name,
role: 'admin',
active: true,
platformAdmin: false,
},
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'tenant.admin_attached',
resourceType: 'user',
resourceId: existing.uid,
resourceName: existing.email,
tenantSlug: tenant.slug,
metadata: { name: existing.name || dto.name, role: 'admin' },
},
actor,
)
return { subject: existing.uid, userId: String(existing.pk), attached: true }
}
// ── Create new Authentik user ──────────────────────────────────────
const created = await this.authentik.createUser({
username: dto.email,
email: dto.email,
name: dto.name,
groupPks: [tenant.authentikGroupId],
attributes: {
tenantSlug: tenant.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: { email: dto.email, name: dto.name },
$setOnInsert: { role: 'admin', active: true, platformAdmin: false },
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
let link: string | undefined
let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk)
if (!link) {
tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword)
await this.authentik.markPasswordExpired(created.pk)
}
void this.audit.record(
{
action: 'tenant.admin_invited',
resourceType: 'user',
resourceId: created.uid,
resourceName: dto.email,
tenantSlug: tenant.slug,
metadata: {
name: dto.name,
role: 'admin',
handoff: link ? 'recovery-link' : 'temp-password',
},
},
actor,
)
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// Recent audit events scoped to a partner — events whose partnerSlug
// matches OR whose tenantSlug belongs to one of the partner's tenants.
// Used by the partner dashboard's Activity card.
async partnerActivity(
partnerId: Types.ObjectId,
partnerSlug: string,
opts: { limit?: number; before?: Date } = {},
): Promise<AuditEventDocument[]> {
const tenants = await this.tenantModel.find({ partnerId }, { slug: 1 }).exec()
return this.audit.listForPartner({
partnerSlug,
tenantSlugs: tenants.map((t) => t.slug),
limit: opts.limit,
before: opts.before,
})
}
// MRR aggregation for a partner, grouped by currency. Each subscription
// contributes to the bucket for its currency — no FX conversion, since
// the partner gets paid in whatever currency the customer was billed in.
//
// Per-seat amount is read from Subscription.perSeatAmount (the snapshot
// taken at provision time) instead of the live Price doc, so historical
// MRR is stable even if the operator edits the catalog later.
async partnerMrr(partnerId: Types.ObjectId): Promise<{
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
breakdown: Array<{
tenantId: string
tenantSlug: string
tenantName: string
plan: 'mvp' | 'pro' | 'enterprise'
cycle: 'monthly' | 'quarterly' | 'yearly'
currency: 'DKK' | 'EUR' | 'USD'
seats: number
monthlyMinor: number
custom: boolean // true when the sub has no priced amount (Enterprise / pre-catalog)
}>
}> {
const tenants = await this.tenantModel.find({ partnerId }).exec()
if (tenants.length === 0) {
return { totals: [], breakdown: [] }
}
const tenantIds = tenants.map((t) => t._id)
const subs = await this.subModel.find({ tenantId: { $in: tenantIds }, status: 'active' }).exec()
const tenantById = new Map(tenants.map((t) => [String(t._id), t]))
const breakdown = subs.map((s) => {
const tenant = tenantById.get(String(s.tenantId))!
const monthlyMinor = normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle)
return {
tenantId: String(tenant._id),
tenantSlug: tenant.slug,
tenantName: tenant.name,
plan: s.plan,
cycle: s.cycle,
currency: s.currency,
seats: s.seats,
monthlyMinor,
custom: s.perSeatAmount === 0,
}
})
// Group by currency. Use a Map to preserve insertion-on-first-seen
// ordering — but emit totals in a stable order regardless: DKK, EUR, USD.
const byCurrency = new Map<string, number>()
for (const row of breakdown) {
byCurrency.set(row.currency, (byCurrency.get(row.currency) ?? 0) + row.monthlyMinor)
}
const ORDER: Array<'DKK' | 'EUR' | 'USD'> = ['DKK', 'EUR', 'USD']
const totals = ORDER.filter((c) => byCurrency.has(c)).map((c) => ({
currency: c,
monthlyMinor: byCurrency.get(c)!,
}))
return { totals, breakdown }
}
// Resolve + cache the dezky-platform-admins group ID. The group is created
// by Authentik bootstrap so it's reliably present; ensureGroup is
// idempotent so the worst case is a no-op extra API call on cold start.
@@ -228,6 +666,18 @@ export class UsersService {
this.platformAdminGroupId = group.pk
return group.pk
}
// Same caching pattern for the partner-staff group. Created lazily the
// first time a partner invite runs — by then Authentik is past bootstrap
// and ensureGroup will either find or create it.
private async resolvePartnerStaffGroupId(): Promise<string> {
if (this.partnerStaffGroupId) return this.partnerStaffGroupId
const group = await this.authentik.ensureGroup(PARTNER_STAFF_GROUP, {
role: 'partner-staff',
})
this.partnerStaffGroupId = group.pk
return group.pk
}
}
// Generates a 16-character random password with mixed character classes.
@@ -257,3 +707,13 @@ function generateTempPassword(): string {
}
return out.join('')
}
// Convert a per-cycle subscription total (in minor units) to its monthly
// equivalent. Used by MRR aggregation. Integer math throughout — final
// rounding happens once at the partner-total level so per-row drift can't
// accumulate visibly.
function normalizeToMonthly(perCycleMinor: number, cycle: 'monthly' | 'quarterly' | 'yearly'): number {
if (cycle === 'monthly') return perCycleMinor
if (cycle === 'quarterly') return Math.round(perCycleMinor / 3)
return Math.round(perCycleMinor / 12)
}