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