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