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