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
@@ -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))`)
}
}
}