98e49bfe34
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled
Rebuild the /admin/users detail drawer from a read-only profile into an editable, Office 365-style panel with four sections: - Username & mail: read-only primary for mailbox users; editable sign-in (Authentik-only) for mailbox-less identities; "Create mailbox" provisions a Stalwart inbox for an external-login admin - Aliases: list/add/remove mailbox aliases (Stalwart), domain-scoped - Role: member/admin toggle with a primary-account lock (owner, mailbox-less bootstrap admin, self) and a last-admin guard - Contact information: display name, first/last name, phone, alternative email — mirrored best-effort to Authentik attributes + mailbox name Ownership transfer: "Make owner" (row menu + drawer) plus an owner-side "Transfer ownership" picker, gated to tenant admins / platform admins so a departed owner can be replaced; promotes the target and demotes the prior owner to admin. Backend (platform-api): contact fields on User; AuthentikClient.updateUser; StalwartClient.setMailboxName; UsersService updateTenantMember, changeMemberPrimaryEmail, list/add/removeMemberAlias, createMailboxForMember, transferOwnership; new DTOs and tenant-member routes. All mutations audited. Portal: Nuxt proxies for the new endpoints + extended TenantUserDoc.
1807 lines
69 KiB
TypeScript
1807 lines
69 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
ForbiddenException,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
} from '@nestjs/common'
|
||
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 { OcisClient } from '../integrations/ocis.client.js'
|
||
import { StalwartClient } from '../integrations/stalwart.client.js'
|
||
import { Domain, DomainDocument } from '../schemas/domain.schema.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, roleForTenant } 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)
|
||
private readonly platformAdminGroup: string
|
||
// Cached after first successful lookup. The dezky-platform-admins group is
|
||
// 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>,
|
||
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
|
||
private readonly audit: AuditService,
|
||
private readonly authentik: AuthentikClient,
|
||
private readonly stalwart: StalwartClient,
|
||
private readonly ocis: OcisClient,
|
||
config: ConfigService,
|
||
) {
|
||
this.platformAdminGroup =
|
||
config.get<string>('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? 'dezky-platform-admins'
|
||
}
|
||
|
||
async create(dto: CreateUserDto): Promise<UserDocument> {
|
||
const exists = await this.userModel.exists({ authentikSubjectId: dto.authentikSubjectId })
|
||
if (exists) throw new ConflictException(`User ${dto.authentikSubjectId} already exists`)
|
||
|
||
const tenantIds = await this.resolveTenantIds(dto.tenantSlugs ?? [])
|
||
const role = dto.role ?? 'member'
|
||
// Seed the per-tenant role for every tenant this user is created into, so
|
||
// their effective role is explicit from the start rather than relying on
|
||
// the global-role fallback. Omitted when there are no tenants.
|
||
const tenantRoles = tenantIds.length
|
||
? Object.fromEntries(tenantIds.map((id) => [String(id), role]))
|
||
: undefined
|
||
return this.userModel.create({
|
||
authentikSubjectId: dto.authentikSubjectId,
|
||
email: dto.email,
|
||
name: dto.name,
|
||
role,
|
||
tenantIds,
|
||
tenantRoles,
|
||
})
|
||
}
|
||
|
||
async findAllForTenants(tenantIds: Types.ObjectId[]): Promise<UserDocument[]> {
|
||
return this.userModel.find({ tenantIds: { $in: tenantIds } }).sort({ createdAt: -1 }).exec()
|
||
}
|
||
|
||
async findAll(): Promise<UserDocument[]> {
|
||
return this.userModel.find().sort({ createdAt: -1 }).exec()
|
||
}
|
||
|
||
async findOneBySubject(subject: string): Promise<UserDocument> {
|
||
const user = await this.userModel.findOne({ authentikSubjectId: subject }).exec()
|
||
if (!user) throw new NotFoundException(`User ${subject} not found`)
|
||
return user
|
||
}
|
||
|
||
async update(subject: string, dto: UpdateUserDto): Promise<UserDocument> {
|
||
const patch: Record<string, unknown> = { ...dto }
|
||
if (dto.tenantSlugs !== undefined) {
|
||
patch.tenantIds = await this.resolveTenantIds(dto.tenantSlugs)
|
||
delete patch.tenantSlugs
|
||
}
|
||
const user = await this.userModel
|
||
.findOneAndUpdate({ authentikSubjectId: subject }, patch, { new: true, runValidators: true })
|
||
.exec()
|
||
if (!user) throw new NotFoundException(`User ${subject} not found`)
|
||
return user
|
||
}
|
||
|
||
async deactivate(subject: string, actor?: AuditActor): Promise<void> {
|
||
const user = await this.userModel
|
||
.findOneAndUpdate({ authentikSubjectId: subject }, { active: false }, { new: true })
|
||
.exec()
|
||
if (!user) throw new NotFoundException(`User ${subject} not found`)
|
||
void this.audit.record(
|
||
{
|
||
action: 'user.deactivated',
|
||
resourceType: 'user',
|
||
resourceId: String(user._id),
|
||
resourceName: user.email,
|
||
},
|
||
actor,
|
||
)
|
||
}
|
||
|
||
// 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
|
||
name: string
|
||
tenantSlugs: string[]
|
||
platformAdmin: boolean
|
||
}): Promise<UserDocument> {
|
||
const tenantIds = await this.resolveTenantIds(payload.tenantSlugs)
|
||
return this.userModel
|
||
.findOneAndUpdate(
|
||
{ authentikSubjectId: payload.subject },
|
||
{
|
||
$set: {
|
||
email: payload.email,
|
||
name: payload.name,
|
||
tenantIds,
|
||
platformAdmin: payload.platformAdmin,
|
||
lastLoginAt: new Date(),
|
||
},
|
||
$setOnInsert: { role: 'member', active: true },
|
||
},
|
||
{ new: true, upsert: true, runValidators: true },
|
||
)
|
||
.exec()
|
||
}
|
||
|
||
private async resolveTenantIds(slugs: string[]): Promise<Types.ObjectId[]> {
|
||
if (slugs.length === 0) return []
|
||
const tenants = await this.tenantModel.find({ slug: { $in: slugs } }, { _id: 1 }).exec()
|
||
return tenants.map((t) => t._id)
|
||
}
|
||
|
||
// Invite a new platform admin. Creates the user in Authentik, adds them to
|
||
// the dezky-platform-admins group, pre-creates the local User doc so they
|
||
// appear in the operator-team list immediately, and returns whichever
|
||
// credential-handoff path Authentik supports for our brand:
|
||
// - `link` — single-use recovery URL (preferred; requires a recovery
|
||
// flow configured in Authentik. User clicks → sets password
|
||
// + enrolls MFA themselves.)
|
||
// - `tempPassword` — random 16-char password we set on the user. Used
|
||
// when no recovery flow exists; operator hands this
|
||
// to the new user out-of-band and they change it on
|
||
// first login via Authentik's password-change flow.
|
||
// On their first login, upsertFromAuthentik() patches lastLoginAt +
|
||
// reconciles group state from the JWT — no further work needed.
|
||
//
|
||
// Email delivery is the operator's job for now (we return the credential);
|
||
// when outbound SMTP is wired (Phase 5/6), Authentik can email directly.
|
||
async inviteOperator(
|
||
dto: InviteOperatorDto,
|
||
actor?: AuditActor,
|
||
): Promise<{
|
||
subject: string
|
||
userId: string
|
||
link?: string
|
||
tempPassword?: string
|
||
}> {
|
||
// Prevent duplicate by email — Authentik will 400 but its error message
|
||
// isn't friendly. Check up front for a clean conflict.
|
||
const existing = await this.authentik.findUserByEmail(dto.email)
|
||
if (existing) {
|
||
throw new ConflictException(
|
||
`User with email ${dto.email} already exists in Authentik (uid=${existing.uid})`,
|
||
)
|
||
}
|
||
|
||
const groupPk = await this.resolvePlatformAdminGroupId()
|
||
const username = dto.email // Authentik convention — keep email + username aligned
|
||
|
||
const created = await this.authentik.createUser({
|
||
username,
|
||
email: dto.email,
|
||
name: dto.name,
|
||
groupPks: [groupPk],
|
||
attributes: { invitedBy: actor?.email, invitedAt: new Date().toISOString() },
|
||
})
|
||
|
||
// Pre-create the local User doc so the operator-team list reflects the
|
||
// invite immediately. On their first login /users/me will upsert and
|
||
// reconcile lastLoginAt + platformAdmin from the JWT.
|
||
await this.userModel
|
||
.findOneAndUpdate(
|
||
{ authentikSubjectId: created.uid },
|
||
{
|
||
$set: {
|
||
email: dto.email,
|
||
name: dto.name,
|
||
platformAdmin: true,
|
||
},
|
||
$setOnInsert: { role: 'admin', active: true, tenantIds: [] },
|
||
},
|
||
{ upsert: true, new: true, runValidators: true },
|
||
)
|
||
.exec()
|
||
|
||
// Try the preferred recovery-link path first. If Authentik has no
|
||
// recovery flow configured (returns undefined), fall back to setting a
|
||
// generated temp password — and mark it as expired so Authentik forces
|
||
// a change on the user's first login (defense-in-depth against the
|
||
// operator's plaintext-handling window).
|
||
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: 'platform.user_invited',
|
||
resourceType: 'user',
|
||
resourceId: created.uid,
|
||
resourceName: dto.email,
|
||
metadata: {
|
||
role: 'platform-admin',
|
||
name: dto.name,
|
||
handoff: link ? 'recovery-link' : 'temp-password',
|
||
},
|
||
},
|
||
actor,
|
||
)
|
||
|
||
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,
|
||
authentikUserPk: existing.pk,
|
||
},
|
||
$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,
|
||
authentikUserPk: created.pk,
|
||
},
|
||
$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()
|
||
}
|
||
|
||
// Partner-portal team list. Same set as listPartnerUsers, enriched with:
|
||
// - accessLevel/accessCount derived from partnerTenantAccess (absent = all)
|
||
// - mfaEnabled from a live Authentik authenticator-count lookup
|
||
// MFA lookups run in bounded parallel and degrade to null on any error so a
|
||
// flaky/unavailable Authentik never breaks the team list. Kept separate from
|
||
// listPartnerUsers (operator path) so that path stays a pure DB query with
|
||
// no external coupling.
|
||
async listPartnerUsersEnriched(partnerId: Types.ObjectId): Promise<
|
||
Array<
|
||
UserDocument & {
|
||
mfaEnabled: boolean | null
|
||
accessLevel: 'all' | 'scoped'
|
||
accessCount: number | null
|
||
}
|
||
>
|
||
> {
|
||
const users = await this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
||
if (users.length === 0) return []
|
||
const mfa = await Promise.all(
|
||
users.map(async (u) => {
|
||
if (!u.authentikUserPk) return null
|
||
try {
|
||
return (await this.authentik.countAuthenticators(u.authentikUserPk)) > 0
|
||
} catch {
|
||
return null
|
||
}
|
||
}),
|
||
)
|
||
return users.map((u, i) => {
|
||
const obj = u.toObject() as UserDocument & {
|
||
mfaEnabled: boolean | null
|
||
accessLevel: 'all' | 'scoped'
|
||
accessCount: number | null
|
||
}
|
||
const access = u.partnerTenantAccess
|
||
const scoped = !!access && access.length > 0
|
||
obj.accessLevel = scoped ? 'scoped' : 'all'
|
||
obj.accessCount = scoped ? access!.length : null
|
||
obj.mfaEnabled = mfa[i] ?? null
|
||
return obj
|
||
})
|
||
}
|
||
|
||
// Remove a partner-staff user from the partner: unset partnerId +
|
||
// partnerTenantAccess and drop them from the dezky-partner-staff Authentik
|
||
// group. Ownership-guarded (must belong to the caller's partner) and refuses
|
||
// to strip the last partner admin/owner so a partner can't lock itself out.
|
||
async removePartnerUser(
|
||
subject: string,
|
||
partnerId: Types.ObjectId,
|
||
actor?: AuditActor,
|
||
): Promise<{ removed: boolean }> {
|
||
const user = await this.userModel.findOne({ authentikSubjectId: subject }).exec()
|
||
if (!user) throw new NotFoundException(`User ${subject} not found`)
|
||
if (!user.partnerId || String(user.partnerId) !== String(partnerId)) {
|
||
throw new ForbiddenException('User is not part of your partner organization')
|
||
}
|
||
if (user.role === 'owner' || user.role === 'admin') {
|
||
const admins = await this.userModel
|
||
.countDocuments({ partnerId, role: { $in: ['owner', 'admin'] } })
|
||
.exec()
|
||
if (admins <= 1) {
|
||
throw new ConflictException('Cannot remove the last partner admin')
|
||
}
|
||
}
|
||
// Best-effort Authentik group removal — never block the local detach on it.
|
||
try {
|
||
const groupPk = await this.resolvePartnerStaffGroupId()
|
||
const pk = user.authentikUserPk ?? (await this.authentik.findUserByEmail(user.email))?.pk
|
||
if (pk) await this.authentik.removeUserFromGroup(pk, groupPk)
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Failed to remove ${user.email} from partner-staff group: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`,
|
||
)
|
||
}
|
||
await this.userModel
|
||
.updateOne({ _id: user._id }, { $unset: { partnerId: '', partnerTenantAccess: '' } })
|
||
.exec()
|
||
void this.audit.record(
|
||
{
|
||
action: 'partner.user_removed',
|
||
resourceType: 'user',
|
||
resourceId: subject,
|
||
resourceName: user.email,
|
||
metadata: { partnerId: String(partnerId) },
|
||
},
|
||
actor,
|
||
)
|
||
return { removed: true }
|
||
}
|
||
|
||
// 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
|
||
healthScore: number
|
||
healthBand: 'healthy' | 'watch' | 'at-risk'
|
||
}
|
||
>
|
||
> {
|
||
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
|
||
healthScore: number
|
||
healthBand: 'healthy' | 'watch' | 'at-risk'
|
||
}
|
||
const c = countMap.get(String(t._id))
|
||
obj.userCount = c?.n ?? 0
|
||
obj.newUserCount30d = c?.new30d ?? 0
|
||
const health = this.tenantHealth(t, obj.userCount)
|
||
obj.healthScore = health.healthScore
|
||
obj.healthBand = health.healthBand
|
||
return obj
|
||
})
|
||
}
|
||
|
||
// Portfolio-health heuristic for a tenant, 0–100, computed (never stored).
|
||
// Penalises non-active status, poor seat adoption, and failed/pending
|
||
// provisioning. Band: >=80 healthy, 60–79 watch, <60 at-risk.
|
||
private tenantHealth(
|
||
t: TenantDocument,
|
||
userCount: number,
|
||
): { healthScore: number; healthBand: 'healthy' | 'watch' | 'at-risk' } {
|
||
let score = 100
|
||
if (t.status === 'pending') score -= 15
|
||
else if (t.status === 'suspended' || t.status === 'deleted') score -= 60
|
||
|
||
const seats = t.seats ?? 0
|
||
if (seats > 0) {
|
||
const u = userCount / seats
|
||
if (u < 0.25) score -= 25
|
||
else if (u < 0.5) score -= 10
|
||
else if (u > 1.0) score -= 5
|
||
}
|
||
|
||
const ps = t.provisioningStatus
|
||
for (const s of [ps?.authentik, ps?.stalwart, ps?.ocis]) {
|
||
if (s === 'error') score -= 10
|
||
else if (s === 'pending') score -= 5
|
||
}
|
||
|
||
score = Math.max(0, Math.min(100, score))
|
||
const healthBand: 'healthy' | 'watch' | 'at-risk' =
|
||
score >= 80 ? 'healthy' : score >= 60 ? 'watch' : 'at-risk'
|
||
return { healthScore: score, healthBand }
|
||
}
|
||
|
||
// 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.
|
||
// Create a workspace member and provision them across systems: Authentik SSO
|
||
// (required), a Stalwart mailbox on the tenant's default domain (best-effort),
|
||
// and an OCIS account (best-effort; auto-provisions on first login otherwise).
|
||
// One temp password is set on BOTH Authentik and the mailbox, so the new user
|
||
// has a single credential — returned once for the admin to hand over.
|
||
async createTenantMember(
|
||
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
|
||
dto: { name: string; localPart: string; role: 'admin' | 'member'; domain?: string },
|
||
actor?: AuditActor,
|
||
): Promise<{
|
||
email: string
|
||
tempPassword: string
|
||
provisioning: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' }
|
||
stalwartError?: string
|
||
ocisNote?: string
|
||
}> {
|
||
if (!tenant.authentikGroupId) {
|
||
throw new BadRequestException(
|
||
`Workspace "${tenant.slug}" isn't fully provisioned (no identity group). Reconcile it first.`,
|
||
)
|
||
}
|
||
|
||
// Resolve the target domain — the named one, else the primary, else the oldest.
|
||
let domainDoc: DomainDocument | null
|
||
if (dto.domain) {
|
||
domainDoc = await this.domainModel
|
||
.findOne({ tenantId: tenant._id, domain: dto.domain.toLowerCase() })
|
||
.exec()
|
||
} else {
|
||
domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, isPrimary: true }).exec()
|
||
if (!domainDoc) {
|
||
domainDoc = await this.domainModel
|
||
.findOne({ tenantId: tenant._id })
|
||
.sort({ createdAt: 1 })
|
||
.exec()
|
||
}
|
||
}
|
||
if (!domainDoc) {
|
||
throw new BadRequestException('Add a domain to this workspace before creating users.')
|
||
}
|
||
|
||
const localPart = dto.localPart.trim().toLowerCase()
|
||
if (!/^[a-z0-9._-]+$/.test(localPart)) {
|
||
throw new BadRequestException(
|
||
'The address prefix may only contain letters, numbers, dots, hyphens and underscores.',
|
||
)
|
||
}
|
||
const email = `${localPart}@${domainDoc.domain}`
|
||
|
||
const dupe = await this.userModel.findOne({ email, tenantIds: tenant._id }).exec()
|
||
if (dupe) throw new ConflictException(`${email} already exists in this workspace.`)
|
||
|
||
const role: 'admin' | 'member' = dto.role === 'admin' ? 'admin' : 'member'
|
||
const tempPassword = generateTempPassword()
|
||
|
||
// 1) Authentik SSO identity (required) — same temp password so one credential
|
||
// works for sign-in (and OCIS, which authenticates via Authentik).
|
||
const created = await this.authentik.createUser({
|
||
username: email,
|
||
email,
|
||
name: dto.name,
|
||
groupPks: [tenant.authentikGroupId],
|
||
attributes: {
|
||
tenantSlug: tenant.slug,
|
||
invitedBy: actor?.email,
|
||
invitedAt: new Date().toISOString(),
|
||
},
|
||
})
|
||
await this.authentik.setInitialPassword(created.pk, tempPassword)
|
||
|
||
const prov: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' } = {
|
||
authentik: 'ok',
|
||
stalwart: 'skipped',
|
||
ocis: 'skipped',
|
||
}
|
||
|
||
// 2) Stalwart mailbox (best-effort) — the same temp password signs into webmail.
|
||
let stalwartAccountId: string | undefined
|
||
let stalwartError: string | undefined
|
||
if (this.stalwart.configured && domainDoc.stalwartId) {
|
||
try {
|
||
const mbx = await this.stalwart.createMailbox({
|
||
domainId: domainDoc.stalwartId,
|
||
localPart,
|
||
fullName: dto.name,
|
||
password: tempPassword,
|
||
})
|
||
stalwartAccountId = mbx.id
|
||
prov.stalwart = 'ok'
|
||
} catch (err) {
|
||
prov.stalwart = 'error'
|
||
stalwartError = (err as Error).message
|
||
this.logger.error(`Mailbox provisioning failed for ${email}: ${stalwartError}`)
|
||
}
|
||
}
|
||
|
||
// 3) OCIS account (best-effort; auto-provisions on first sign-in otherwise).
|
||
let ocisUserId: string | undefined
|
||
let ocisNote: string | undefined
|
||
const ocisRes = await this.ocis.ensureUser({ username: email, displayName: dto.name, mail: email })
|
||
if (ocisRes.deferred) {
|
||
prov.ocis = 'skipped'
|
||
ocisNote = 'auto-provisions on first sign-in'
|
||
} else {
|
||
prov.ocis = 'ok'
|
||
ocisUserId = ocisRes.id
|
||
}
|
||
|
||
// 4) Our User doc.
|
||
await this.userModel
|
||
.findOneAndUpdate(
|
||
{ authentikSubjectId: created.uid },
|
||
{
|
||
$set: {
|
||
email,
|
||
name: dto.name,
|
||
[`tenantRoles.${tenant._id}`]: role,
|
||
mailboxAddress: email,
|
||
stalwartAccountId,
|
||
ocisUserId,
|
||
authentikUserPk: created.pk,
|
||
provisioning: { ...prov, stalwartError, ocisNote },
|
||
},
|
||
$setOnInsert: { role, active: true, platformAdmin: false },
|
||
$addToSet: { tenantIds: tenant._id },
|
||
},
|
||
{ upsert: true, new: true, runValidators: true },
|
||
)
|
||
.exec()
|
||
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_created',
|
||
resourceType: 'user',
|
||
resourceId: created.uid,
|
||
resourceName: email,
|
||
tenantSlug: tenant.slug,
|
||
metadata: { name: dto.name, role, mailbox: prov.stalwart, ocis: prov.ocis },
|
||
},
|
||
actor,
|
||
)
|
||
|
||
return { email, tempPassword, provisioning: prov, stalwartError, ocisNote }
|
||
}
|
||
|
||
// Remove a member from a workspace, tearing down their provisioned accounts.
|
||
// The mailbox lives on one of THIS tenant's domains, so it's deleted (only if
|
||
// it actually belongs here — a multi-tenant user's mailbox on another tenant is
|
||
// left alone). The SSO identity + OCIS account are global, so they're deleted
|
||
// only when the user belongs to no other tenant (and isn't partner staff / a
|
||
// platform admin); otherwise we just detach this tenant.
|
||
async removeTenantMember(
|
||
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
|
||
userId: string,
|
||
actor?: AuditActor,
|
||
): Promise<void> {
|
||
let _id: Types.ObjectId
|
||
try {
|
||
_id = new Types.ObjectId(userId)
|
||
} catch {
|
||
throw new NotFoundException('User not found')
|
||
}
|
||
const user = await this.userModel.findById(_id).exec()
|
||
if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
|
||
throw new NotFoundException('User not found in this workspace')
|
||
}
|
||
|
||
// Is the user's mailbox on a domain owned by THIS tenant?
|
||
const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
|
||
const mailboxIsHere =
|
||
!!mailboxDomain &&
|
||
!!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
|
||
|
||
// 1) Delete the mailbox (best-effort) when it belongs to this tenant.
|
||
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
|
||
await this.stalwart.deleteMailbox(user.stalwartAccountId).catch((err) => {
|
||
this.logger.error(`Mailbox delete failed for ${user.email}: ${(err as Error).message}`)
|
||
})
|
||
}
|
||
|
||
// 2) Remove from this tenant's Authentik group.
|
||
if (tenant.authentikGroupId && user.authentikUserPk) {
|
||
await this.authentik
|
||
.removeUserFromGroup(user.authentikUserPk, tenant.authentikGroupId)
|
||
.catch((err) => {
|
||
this.logger.error(`Authentik group remove failed for ${user.email}: ${(err as Error).message}`)
|
||
})
|
||
}
|
||
|
||
const remaining = user.tenantIds.filter((t) => !t.equals(tenant._id))
|
||
const fullyRemove = remaining.length === 0 && !user.partnerId && !user.platformAdmin
|
||
|
||
if (fullyRemove) {
|
||
if (user.ocisUserId) await this.ocis.deleteUser(user.ocisUserId)
|
||
if (user.authentikUserPk) {
|
||
await this.authentik.deleteUser(user.authentikUserPk).catch((err) => {
|
||
this.logger.error(`Authentik user delete failed for ${user.email}: ${(err as Error).message}`)
|
||
})
|
||
}
|
||
await this.userModel.deleteOne({ _id }).exec()
|
||
} else {
|
||
// Keep the global identity; detach this tenant. Drop the mailbox handle
|
||
// only if the mailbox we removed was actually this tenant's.
|
||
const unset: Record<string, ''> = { [`tenantRoles.${tenant._id}`]: '' }
|
||
if (mailboxIsHere) {
|
||
unset.mailboxAddress = ''
|
||
unset.stalwartAccountId = ''
|
||
}
|
||
await this.userModel
|
||
.updateOne({ _id }, { $pull: { tenantIds: tenant._id }, $unset: unset })
|
||
.exec()
|
||
}
|
||
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_removed',
|
||
resourceType: 'user',
|
||
resourceId: user.authentikSubjectId,
|
||
resourceName: user.email,
|
||
tenantSlug: tenant.slug,
|
||
metadata: { fullyRemoved: fullyRemove },
|
||
},
|
||
actor,
|
||
)
|
||
}
|
||
|
||
// Shared lookup for the per-member lifecycle actions: the user doc (verified to
|
||
// belong to this tenant) + whether their mailbox is on one of this tenant's
|
||
// domains (so mailbox-side changes only touch mailboxes we own).
|
||
private async loadMember(
|
||
tenant: { _id: Types.ObjectId },
|
||
userId: string,
|
||
): Promise<{ user: UserDocument; mailboxIsHere: boolean }> {
|
||
let _id: Types.ObjectId
|
||
try {
|
||
_id = new Types.ObjectId(userId)
|
||
} catch {
|
||
throw new NotFoundException('User not found')
|
||
}
|
||
const user = await this.userModel.findById(_id).exec()
|
||
if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
|
||
throw new NotFoundException('User not found in this workspace')
|
||
}
|
||
const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
|
||
const mailboxIsHere =
|
||
!!mailboxDomain &&
|
||
!!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
|
||
return { user, mailboxIsHere }
|
||
}
|
||
|
||
// Suspend or resume a member: toggles Authentik sign-in (is_active) and freezes
|
||
// / unfreezes the mailbox. Reversible — resume restores the original password.
|
||
async setMemberSuspended(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
suspended: boolean,
|
||
actor?: AuditActor,
|
||
): Promise<void> {
|
||
const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
|
||
// Identity first — if this fails, abort before touching anything else.
|
||
if (user.authentikUserPk) {
|
||
await this.authentik.setUserActive(user.authentikUserPk, !suspended)
|
||
}
|
||
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
|
||
await this.stalwart.setMailboxSuspended(user.stalwartAccountId, suspended).catch((err) => {
|
||
this.logger.error(
|
||
`Mailbox ${suspended ? 'suspend' : 'resume'} failed for ${user.email}: ${(err as Error).message}`,
|
||
)
|
||
})
|
||
}
|
||
user.active = !suspended
|
||
await user.save()
|
||
void this.audit.record(
|
||
{
|
||
action: suspended ? 'tenant.user_suspended' : 'tenant.user_resumed',
|
||
resourceType: 'user',
|
||
resourceId: user.authentikSubjectId,
|
||
resourceName: user.email,
|
||
tenantSlug: tenant.slug,
|
||
},
|
||
actor,
|
||
)
|
||
}
|
||
|
||
// Force-logout: terminate the member's active SSO sessions.
|
||
async forceLogoutMember(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
actor?: AuditActor,
|
||
): Promise<{ sessions: number }> {
|
||
const { user } = await this.loadMember(tenant, userId)
|
||
let sessions = 0
|
||
if (user.authentikUserPk) {
|
||
sessions = await this.authentik.terminateSessions(user.authentikUserPk).catch(() => 0)
|
||
}
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_logout_forced',
|
||
resourceType: 'user',
|
||
resourceId: user.authentikSubjectId,
|
||
resourceName: user.email,
|
||
tenantSlug: tenant.slug,
|
||
metadata: { sessions },
|
||
},
|
||
actor,
|
||
)
|
||
return { sessions }
|
||
}
|
||
|
||
// Reset a member's password: one fresh temp password set on both their SSO
|
||
// login and their mailbox, returned once for the admin to hand over.
|
||
async resetMemberPassword(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
actor?: AuditActor,
|
||
): Promise<{ email: string; tempPassword: string }> {
|
||
const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
|
||
const tempPassword = generateTempPassword()
|
||
if (user.authentikUserPk) {
|
||
await this.authentik.setInitialPassword(user.authentikUserPk, tempPassword)
|
||
}
|
||
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
|
||
await this.stalwart.setMailboxPassword(user.stalwartAccountId, tempPassword).catch((err) => {
|
||
this.logger.error(`Mailbox password reset failed for ${user.email}: ${(err as Error).message}`)
|
||
})
|
||
}
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_password_reset',
|
||
resourceType: 'user',
|
||
resourceId: user.authentikSubjectId,
|
||
resourceName: user.email,
|
||
tenantSlug: tenant.slug,
|
||
},
|
||
actor,
|
||
)
|
||
return { email: user.email, tempPassword }
|
||
}
|
||
|
||
// Update a member's directory profile (display name + contact info) and/or
|
||
// their in-tenant role. Contact info is mirrored to Authentik attributes and
|
||
// the mailbox display name on a best-effort basis — the DB is authoritative,
|
||
// so an unreachable Authentik/Stalwart never blocks the local save. Returns
|
||
// the updated doc so the caller can echo the fresh state back to the UI.
|
||
async updateTenantMember(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
dto: {
|
||
name?: string
|
||
firstName?: string
|
||
lastName?: string
|
||
phone?: string
|
||
alternativeEmail?: string
|
||
role?: 'admin' | 'member'
|
||
},
|
||
actor?: AuditActor,
|
||
): Promise<UserDocument> {
|
||
const { user } = await this.loadMember(tenant, userId)
|
||
|
||
// Role change (in-tenant). The workspace's primary account — its owner and
|
||
// the mailbox-less bootstrap admin (invited on a private email to set the
|
||
// workspace up) — has an immutable role; so does the actor's own account
|
||
// (no self-demotion). Contact-info edits on these accounts are still
|
||
// allowed — only the role is frozen. We also never strip the last
|
||
// admin/owner so a workspace can't lock itself out.
|
||
if (dto.role !== undefined) {
|
||
const current = roleForTenant(user, tenant._id)
|
||
const isPrimary = current === 'owner' || (!user.stalwartAccountId && !user.mailboxAddress)
|
||
if (isPrimary) {
|
||
throw new ForbiddenException('The primary account’s role can’t be changed.')
|
||
}
|
||
if (actor?.userId && actor.userId === String(user._id)) {
|
||
throw new ForbiddenException('You can’t change your own role.')
|
||
}
|
||
if (dto.role === 'member' && current === 'admin') {
|
||
const tenantUsers = await this.userModel.find({ tenantIds: tenant._id }).exec()
|
||
const otherAdmins = tenantUsers.filter(
|
||
(u) =>
|
||
!u._id.equals(user._id) &&
|
||
['admin', 'owner'].includes(roleForTenant(u, tenant._id)),
|
||
)
|
||
if (otherAdmins.length === 0) {
|
||
throw new ConflictException('Can’t remove the last admin of this workspace.')
|
||
}
|
||
}
|
||
}
|
||
|
||
const set: Record<string, unknown> = {}
|
||
if (dto.name !== undefined) set.name = dto.name
|
||
if (dto.firstName !== undefined) set.firstName = dto.firstName
|
||
if (dto.lastName !== undefined) set.lastName = dto.lastName
|
||
if (dto.phone !== undefined) set.phone = dto.phone
|
||
if (dto.alternativeEmail !== undefined) set.alternativeEmail = dto.alternativeEmail
|
||
if (dto.role !== undefined) set[`tenantRoles.${tenant._id}`] = dto.role
|
||
|
||
if (Object.keys(set).length === 0) return user
|
||
|
||
const updated = await this.userModel
|
||
.findOneAndUpdate({ _id: user._id }, { $set: set }, { new: true, runValidators: true })
|
||
.exec()
|
||
if (!updated) throw new NotFoundException('User not found')
|
||
|
||
// Best-effort Authentik sync: display name + contact attributes.
|
||
const attrs: Record<string, unknown> = {}
|
||
if (dto.firstName !== undefined) attrs.firstName = dto.firstName
|
||
if (dto.lastName !== undefined) attrs.lastName = dto.lastName
|
||
if (dto.phone !== undefined) attrs.phone = dto.phone
|
||
if (dto.alternativeEmail !== undefined) attrs.alternativeEmail = dto.alternativeEmail
|
||
if (user.authentikUserPk && (dto.name !== undefined || Object.keys(attrs).length > 0)) {
|
||
await this.authentik
|
||
.updateUser(user.authentikUserPk, {
|
||
name: dto.name,
|
||
attributesMerge: Object.keys(attrs).length > 0 ? attrs : undefined,
|
||
})
|
||
.catch((err) =>
|
||
this.logger.warn(
|
||
`Authentik profile sync failed for ${user.email}: ${(err as Error).message}`,
|
||
),
|
||
)
|
||
}
|
||
|
||
// Best-effort mailbox display-name sync.
|
||
if (dto.name !== undefined && this.stalwart.configured && user.stalwartAccountId) {
|
||
await this.stalwart
|
||
.setMailboxName(user.stalwartAccountId, dto.name)
|
||
.catch((err) =>
|
||
this.logger.warn(
|
||
`Mailbox name sync failed for ${user.email}: ${(err as Error).message}`,
|
||
),
|
||
)
|
||
}
|
||
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_updated',
|
||
resourceType: 'user',
|
||
resourceId: updated.authentikSubjectId,
|
||
resourceName: updated.email,
|
||
tenantSlug: tenant.slug,
|
||
metadata: {
|
||
fields: Object.keys(set),
|
||
...(dto.role !== undefined ? { role: dto.role } : {}),
|
||
},
|
||
},
|
||
actor,
|
||
)
|
||
|
||
return updated
|
||
}
|
||
|
||
// Change a mailbox-less member's primary email — their Authentik sign-in
|
||
// username + email, kept aligned, plus our User.email. This is ONLY for
|
||
// identities with no Stalwart mailbox (e.g. the bootstrap admin invited on a
|
||
// private email): for a real mailbox, the primary address IS the inbox, so
|
||
// moving it would split sign-in from delivery and we refuse. Authentik's
|
||
// stable `uid` (our authentikSubjectId) is unaffected by an email change, so
|
||
// the user's identity survives the rename.
|
||
async changeMemberPrimaryEmail(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
newEmailRaw: string,
|
||
actor?: AuditActor,
|
||
): Promise<UserDocument> {
|
||
const { user } = await this.loadMember(tenant, userId)
|
||
|
||
if (user.stalwartAccountId || user.mailboxAddress) {
|
||
throw new BadRequestException(
|
||
'This member has a mailbox — their primary address is their inbox and can’t be changed here. Add an alias instead.',
|
||
)
|
||
}
|
||
if (!user.authentikUserPk) {
|
||
throw new BadRequestException('This member isn’t linked to an identity yet.')
|
||
}
|
||
|
||
const newEmail = newEmailRaw.trim().toLowerCase()
|
||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||
throw new BadRequestException('Enter a valid email address.')
|
||
}
|
||
if (newEmail === user.email.toLowerCase()) return user
|
||
|
||
// Not already taken — in Authentik or our own collection.
|
||
const existingAk = await this.authentik.findUserByEmail(newEmail)
|
||
if (existingAk && existingAk.uid !== user.authentikSubjectId) {
|
||
throw new ConflictException(`${newEmail} is already in use.`)
|
||
}
|
||
const existingLocal = await this.userModel
|
||
.findOne({ email: newEmail, _id: { $ne: user._id } })
|
||
.exec()
|
||
if (existingLocal) throw new ConflictException(`${newEmail} is already in use.`)
|
||
|
||
// Identity source first — if Authentik rejects, abort before our record so
|
||
// the two never diverge.
|
||
await this.authentik.updateUser(user.authentikUserPk, { username: newEmail, email: newEmail })
|
||
|
||
const prevEmail = user.email
|
||
user.email = newEmail
|
||
await user.save()
|
||
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_primary_email_changed',
|
||
resourceType: 'user',
|
||
resourceId: user.authentikSubjectId,
|
||
resourceName: newEmail,
|
||
tenantSlug: tenant.slug,
|
||
metadata: { from: prevEmail, to: newEmail },
|
||
},
|
||
actor,
|
||
)
|
||
|
||
return user
|
||
}
|
||
|
||
// List a member's mailbox aliases as full addresses. Aliases live in Stalwart
|
||
// as {name, domainId}; we resolve domainId → domain name via this tenant's
|
||
// domains so the UI gets `info@acme.dk`, not an opaque id. Members without a
|
||
// mailbox (SSO-only) come back with hasMailbox=false and no aliases.
|
||
async listMemberAliases(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> {
|
||
const { user } = await this.loadMember(tenant, userId)
|
||
const primary = user.mailboxAddress ?? user.email
|
||
if (!this.stalwart.configured || !user.stalwartAccountId) {
|
||
return { hasMailbox: false, primary, aliases: [] }
|
||
}
|
||
const [accounts, domains] = await Promise.all([
|
||
this.stalwart.listAccountsWithAliases(),
|
||
this.domainModel.find({ tenantId: tenant._id }, { domain: 1, stalwartId: 1 }).exec(),
|
||
])
|
||
const acct = accounts.find((a) => a.id === user.stalwartAccountId)
|
||
if (!acct) return { hasMailbox: true, primary, aliases: [] }
|
||
const domainById = new Map(
|
||
domains.filter((d) => d.stalwartId).map((d) => [d.stalwartId as string, d.domain]),
|
||
)
|
||
const aliases = acct.aliases
|
||
.map((a) => {
|
||
const dom = domainById.get(a.domainId)
|
||
return dom ? `${a.name}@${dom}` : null
|
||
})
|
||
.filter((x): x is string => x !== null)
|
||
return { hasMailbox: true, primary: acct.emailAddress || primary, aliases }
|
||
}
|
||
|
||
// Add an alias to a member's mailbox. The alias domain must be one of this
|
||
// tenant's provisioned mail domains, and the address must be free. Returns
|
||
// the refreshed alias view.
|
||
async addMemberAlias(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
dto: { localPart: string; domain: string },
|
||
actor?: AuditActor,
|
||
): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> {
|
||
const { user } = await this.loadMember(tenant, userId)
|
||
if (!this.stalwart.configured || !user.stalwartAccountId) {
|
||
throw new BadRequestException('This member has no mailbox to attach an alias to.')
|
||
}
|
||
const localPart = dto.localPart.trim().toLowerCase()
|
||
if (!/^[a-z0-9._-]+$/.test(localPart)) {
|
||
throw new BadRequestException(
|
||
'The alias prefix may only contain letters, numbers, dots, hyphens and underscores.',
|
||
)
|
||
}
|
||
const domain = dto.domain.trim().toLowerCase()
|
||
const domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
|
||
if (!domainDoc?.stalwartId) {
|
||
throw new BadRequestException(`${domain} isn’t a provisioned mail domain for this workspace.`)
|
||
}
|
||
const address = `${localPart}@${domain}`
|
||
const taken = await this.stalwart.findAccountIdByEmail(address)
|
||
if (taken) throw new ConflictException(`${address} is already in use.`)
|
||
|
||
await this.stalwart.addAlias(user.stalwartAccountId, localPart, domainDoc.stalwartId)
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_alias_added',
|
||
resourceType: 'user',
|
||
resourceId: user.authentikSubjectId,
|
||
resourceName: user.email,
|
||
tenantSlug: tenant.slug,
|
||
metadata: { alias: address },
|
||
},
|
||
actor,
|
||
)
|
||
return this.listMemberAliases(tenant, userId)
|
||
}
|
||
|
||
// Remove an alias (by full address) from a member's mailbox.
|
||
async removeMemberAlias(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
addressRaw: string,
|
||
actor?: AuditActor,
|
||
): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> {
|
||
const { user } = await this.loadMember(tenant, userId)
|
||
if (!this.stalwart.configured || !user.stalwartAccountId) {
|
||
throw new BadRequestException('This member has no mailbox.')
|
||
}
|
||
const [localPart, domain] = addressRaw.trim().toLowerCase().split('@')
|
||
if (!localPart || !domain) throw new BadRequestException('Invalid alias address.')
|
||
const domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
|
||
if (!domainDoc?.stalwartId) {
|
||
throw new BadRequestException(`${domain} isn’t a mail domain for this workspace.`)
|
||
}
|
||
await this.stalwart.removeAlias(user.stalwartAccountId, localPart, domainDoc.stalwartId)
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_alias_removed',
|
||
resourceType: 'user',
|
||
resourceId: user.authentikSubjectId,
|
||
resourceName: user.email,
|
||
tenantSlug: tenant.slug,
|
||
metadata: { alias: `${localPart}@${domain}` },
|
||
},
|
||
actor,
|
||
)
|
||
return this.listMemberAliases(tenant, userId)
|
||
}
|
||
|
||
// Provision a Stalwart mailbox for a member who doesn't have one yet — the
|
||
// bootstrap admin who signs in with an external email, typically. Their
|
||
// sign-in identity (Authentik username/email + User.email) is left untouched;
|
||
// we only attach a mailbox on a tenant domain and store its handles, so they
|
||
// can use webmail/IMAP and (the point of it) receive aliases. A fresh temp
|
||
// password is set on the mailbox and returned once for hand-off.
|
||
async createMailboxForMember(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
userId: string,
|
||
dto: { localPart: string; domain?: string },
|
||
actor?: AuditActor,
|
||
): Promise<{ email: string; tempPassword: string; user: UserDocument }> {
|
||
const { user } = await this.loadMember(tenant, userId)
|
||
if (user.stalwartAccountId || user.mailboxAddress) {
|
||
throw new ConflictException('This member already has a mailbox.')
|
||
}
|
||
if (!this.stalwart.configured) {
|
||
throw new BadRequestException('Mail provisioning is disabled — can’t create a mailbox right now.')
|
||
}
|
||
|
||
// Resolve the target domain — named, else primary, else oldest (mirrors
|
||
// createTenantMember).
|
||
let domainDoc: DomainDocument | null
|
||
if (dto.domain) {
|
||
domainDoc = await this.domainModel
|
||
.findOne({ tenantId: tenant._id, domain: dto.domain.toLowerCase() })
|
||
.exec()
|
||
} else {
|
||
domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, isPrimary: true }).exec()
|
||
if (!domainDoc) {
|
||
domainDoc = await this.domainModel
|
||
.findOne({ tenantId: tenant._id })
|
||
.sort({ createdAt: 1 })
|
||
.exec()
|
||
}
|
||
}
|
||
if (!domainDoc) {
|
||
throw new BadRequestException('Add a domain to this workspace before creating a mailbox.')
|
||
}
|
||
if (!domainDoc.stalwartId) {
|
||
throw new BadRequestException(`${domainDoc.domain} isn’t a provisioned mail domain for this workspace.`)
|
||
}
|
||
|
||
const localPart = dto.localPart.trim().toLowerCase()
|
||
if (!/^[a-z0-9._-]+$/.test(localPart)) {
|
||
throw new BadRequestException(
|
||
'The address prefix may only contain letters, numbers, dots, hyphens and underscores.',
|
||
)
|
||
}
|
||
const email = `${localPart}@${domainDoc.domain}`
|
||
const taken = await this.stalwart.findAccountIdByEmail(email)
|
||
if (taken) throw new ConflictException(`${email} is already in use.`)
|
||
|
||
const tempPassword = generateTempPassword()
|
||
const mbx = await this.stalwart.createMailbox({
|
||
domainId: domainDoc.stalwartId,
|
||
localPart,
|
||
fullName: user.name,
|
||
password: tempPassword,
|
||
})
|
||
|
||
user.mailboxAddress = email
|
||
user.stalwartAccountId = mbx.id
|
||
user.provisioning = { ...(user.provisioning ?? {}), stalwart: 'ok' }
|
||
await user.save()
|
||
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.user_mailbox_created',
|
||
resourceType: 'user',
|
||
resourceId: user.authentikSubjectId,
|
||
resourceName: email,
|
||
tenantSlug: tenant.slug,
|
||
metadata: { mailbox: email, signIn: user.email },
|
||
},
|
||
actor,
|
||
)
|
||
|
||
return { email, tempPassword, user }
|
||
}
|
||
|
||
// Transfer workspace ownership to another member. The named member becomes
|
||
// 'owner' for this tenant; any existing owner is demoted to 'admin' (never
|
||
// removed — they keep access until an admin decides otherwise). Built for the
|
||
// "owner left the company" case, so the actor doesn't have to BE the owner —
|
||
// the controller gates it to tenant admins / platform admins. Only the
|
||
// per-tenant role moves; the legacy global `role` and every other tenant's
|
||
// role are untouched.
|
||
async transferOwnership(
|
||
tenant: { _id: Types.ObjectId; slug: string },
|
||
newOwnerUserId: string,
|
||
actor?: AuditActor,
|
||
): Promise<{ newOwner: UserDocument; previousOwners: UserDocument[] }> {
|
||
const { user: newOwner } = await this.loadMember(tenant, newOwnerUserId)
|
||
if (roleForTenant(newOwner, tenant._id) === 'owner') {
|
||
throw new ConflictException('This member is already the owner.')
|
||
}
|
||
|
||
const tenantUsers = await this.userModel.find({ tenantIds: tenant._id }).exec()
|
||
const previousOwners = tenantUsers.filter(
|
||
(u) => roleForTenant(u, tenant._id) === 'owner' && !u._id.equals(newOwner._id),
|
||
)
|
||
|
||
await this.userModel
|
||
.updateOne({ _id: newOwner._id }, { $set: { [`tenantRoles.${tenant._id}`]: 'owner' } })
|
||
.exec()
|
||
for (const prev of previousOwners) {
|
||
await this.userModel
|
||
.updateOne({ _id: prev._id }, { $set: { [`tenantRoles.${tenant._id}`]: 'admin' } })
|
||
.exec()
|
||
}
|
||
|
||
void this.audit.record(
|
||
{
|
||
action: 'tenant.ownership_transferred',
|
||
resourceType: 'user',
|
||
resourceId: newOwner.authentikSubjectId,
|
||
resourceName: newOwner.email,
|
||
tenantSlug: tenant.slug,
|
||
metadata: { to: newOwner.email, from: previousOwners.map((p) => p.email) },
|
||
},
|
||
actor,
|
||
)
|
||
|
||
// Return fresh docs so the caller's roleForTenant() reflects the new state.
|
||
const freshNewOwner = await this.userModel.findById(newOwner._id).exec()
|
||
const freshPrev = previousOwners.length
|
||
? await this.userModel.find({ _id: { $in: previousOwners.map((p) => p._id) } }).exec()
|
||
: []
|
||
return { newOwner: freshNewOwner ?? newOwner, previousOwners: freshPrev }
|
||
}
|
||
|
||
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 },
|
||
{
|
||
// tenantRoles via $set (not $setOnInsert) so an EXISTING user
|
||
// invited as admin to this tenant actually becomes admin here,
|
||
// without disturbing their role in other tenants. The global
|
||
// `role` stays $setOnInsert as the legacy/first-tenant fallback.
|
||
$set: { email: existing.email, [`tenantRoles.${tenant._id}`]: 'admin' },
|
||
$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 },
|
||
{
|
||
// Per-tenant admin role via $set (see attach branch above).
|
||
$set: { email: dto.email, name: dto.name, [`tenantRoles.${tenant._id}`]: 'admin' },
|
||
$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 }
|
||
}
|
||
|
||
// Analytics for the partner reports page. Reuses listPartnerTenants (health)
|
||
// and partnerMrr (revenue) plus a signup-cohort pass over the tenants. Churn
|
||
// retention is APPROXIMATE for v1 — "retained" = currently active, since we
|
||
// don't track cancellation dates until billing (Phase 3) lands.
|
||
async partnerReports(partnerId: Types.ObjectId): Promise<{
|
||
health: { healthy: number; watch: number; atRisk: number; total: number; avgScore: number }
|
||
revenueByPlan: Array<{
|
||
plan: 'mvp' | 'pro' | 'enterprise'
|
||
currency: 'DKK' | 'EUR' | 'USD'
|
||
monthlyMinor: number
|
||
count: number
|
||
}>
|
||
topCustomers: Array<{
|
||
tenantId: string
|
||
tenantName: string
|
||
currency: 'DKK' | 'EUR' | 'USD'
|
||
monthlyMinor: number
|
||
custom: boolean
|
||
}>
|
||
churnCohorts: Array<{ month: string; total: number; retained: number; retentionPct: number }>
|
||
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
||
marginPct: number
|
||
}> {
|
||
const [tenants, mrr, partner] = await Promise.all([
|
||
this.listPartnerTenants(partnerId),
|
||
this.partnerMrr(partnerId),
|
||
this.partnerModel.findById(partnerId, { marginPct: 1 }).exec(),
|
||
])
|
||
|
||
// Health cohorts (exclude soft-deleted from the cohort).
|
||
const live = tenants.filter((t) => t.status !== 'deleted')
|
||
const health = { healthy: 0, watch: 0, atRisk: 0, total: live.length, avgScore: 0 }
|
||
let scoreSum = 0
|
||
for (const t of live) {
|
||
scoreSum += t.healthScore
|
||
if (t.healthBand === 'healthy') health.healthy++
|
||
else if (t.healthBand === 'watch') health.watch++
|
||
else health.atRisk++
|
||
}
|
||
health.avgScore = live.length ? Math.round(scoreSum / live.length) : 0
|
||
|
||
// Revenue grouped by plan × currency.
|
||
const planMap = new Map<
|
||
string,
|
||
{ plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }
|
||
>()
|
||
for (const row of mrr.breakdown) {
|
||
const key = `${row.plan}|${row.currency}`
|
||
const e = planMap.get(key) ?? { plan: row.plan, currency: row.currency, monthlyMinor: 0, count: 0 }
|
||
e.monthlyMinor += row.monthlyMinor
|
||
e.count++
|
||
planMap.set(key, e)
|
||
}
|
||
const revenueByPlan = [...planMap.values()].sort((a, b) => b.monthlyMinor - a.monthlyMinor)
|
||
|
||
// Top customers by MRR.
|
||
const topCustomers = [...mrr.breakdown]
|
||
.sort((a, b) => b.monthlyMinor - a.monthlyMinor)
|
||
.slice(0, 10)
|
||
.map((r) => ({
|
||
tenantId: r.tenantId,
|
||
tenantName: r.tenantName,
|
||
currency: r.currency,
|
||
monthlyMinor: r.monthlyMinor,
|
||
custom: r.custom,
|
||
}))
|
||
|
||
// Signup cohorts (approximate retention).
|
||
const cohortMap = new Map<string, { total: number; retained: number }>()
|
||
for (const t of tenants) {
|
||
const created = (t as { createdAt?: string | Date }).createdAt
|
||
if (!created) continue
|
||
const month = new Date(created).toISOString().slice(0, 7) // YYYY-MM
|
||
const e = cohortMap.get(month) ?? { total: 0, retained: 0 }
|
||
e.total++
|
||
if (t.status === 'active') e.retained++
|
||
cohortMap.set(month, e)
|
||
}
|
||
const churnCohorts = [...cohortMap.entries()]
|
||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||
.map(([month, v]) => ({
|
||
month,
|
||
total: v.total,
|
||
retained: v.retained,
|
||
retentionPct: v.total ? Math.round((v.retained / v.total) * 100) : 0,
|
||
}))
|
||
|
||
return {
|
||
health,
|
||
revenueByPlan,
|
||
topCustomers,
|
||
churnCohorts,
|
||
totals: mrr.totals,
|
||
marginPct: partner?.marginPct ?? 0,
|
||
}
|
||
}
|
||
|
||
// Platform-wide analytics for the operator reports page (all tenants/subs,
|
||
// not partner-scoped). Status distribution + revenue by plan + top tenants +
|
||
// signup growth.
|
||
async platformReports(): Promise<{
|
||
tenants: { active: number; pending: number; suspended: number; deleted: number; total: number }
|
||
revenueByPlan: Array<{
|
||
plan: 'mvp' | 'pro' | 'enterprise'
|
||
currency: 'DKK' | 'EUR' | 'USD'
|
||
monthlyMinor: number
|
||
count: number
|
||
}>
|
||
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
||
topTenants: Array<{ tenantId: string; tenantName: string; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
||
growth: Array<{ month: string; count: number }>
|
||
}> {
|
||
const [tenants, subs] = await Promise.all([
|
||
this.tenantModel.find().exec(),
|
||
this.subModel.find({ status: 'active' }).exec(),
|
||
])
|
||
const tenantById = new Map(tenants.map((t) => [String(t._id), t]))
|
||
|
||
const statusCounts = { active: 0, pending: 0, suspended: 0, deleted: 0, total: tenants.length }
|
||
for (const t of tenants) statusCounts[t.status] += 1
|
||
|
||
const breakdown = subs.map((s) => {
|
||
const t = tenantById.get(String(s.tenantId))
|
||
return {
|
||
tenantId: String(s.tenantId),
|
||
tenantName: t?.name ?? String(s.tenantId),
|
||
plan: s.plan,
|
||
currency: s.currency,
|
||
monthlyMinor: normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle),
|
||
}
|
||
})
|
||
|
||
const planMap = new Map<
|
||
string,
|
||
{ plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }
|
||
>()
|
||
const byCurrency = new Map<'DKK' | 'EUR' | 'USD', number>()
|
||
for (const r of breakdown) {
|
||
const key = `${r.plan}|${r.currency}`
|
||
const e = planMap.get(key) ?? { plan: r.plan, currency: r.currency, monthlyMinor: 0, count: 0 }
|
||
e.monthlyMinor += r.monthlyMinor
|
||
e.count++
|
||
planMap.set(key, e)
|
||
byCurrency.set(r.currency, (byCurrency.get(r.currency) ?? 0) + r.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)! }))
|
||
|
||
const topTenants = [...breakdown].sort((a, b) => b.monthlyMinor - a.monthlyMinor).slice(0, 10)
|
||
|
||
const growthMap = new Map<string, number>()
|
||
for (const t of tenants) {
|
||
const created = (t as { createdAt?: string | Date }).createdAt
|
||
if (!created) continue
|
||
const month = new Date(created).toISOString().slice(0, 7)
|
||
growthMap.set(month, (growthMap.get(month) ?? 0) + 1)
|
||
}
|
||
const growth = [...growthMap.entries()]
|
||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||
.map(([month, count]) => ({ month, count }))
|
||
|
||
return {
|
||
tenants: statusCounts,
|
||
revenueByPlan: [...planMap.values()].sort((a, b) => b.monthlyMinor - a.monthlyMinor),
|
||
totals,
|
||
topTenants,
|
||
growth,
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
private async resolvePlatformAdminGroupId(): Promise<string> {
|
||
if (this.platformAdminGroupId) return this.platformAdminGroupId
|
||
const group = await this.authentik.ensureGroup(this.platformAdminGroup, {
|
||
role: 'platform-admin',
|
||
})
|
||
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.
|
||
// Authentik's default password policy requires length + complexity; this
|
||
// generator clears every reasonable policy. The new user changes it on
|
||
// first login.
|
||
function generateTempPassword(): string {
|
||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' // omit I + O (visually confusable)
|
||
const lower = 'abcdefghijkmnpqrstuvwxyz' // omit l (confusable with 1)
|
||
const digit = '23456789' // omit 0 + 1
|
||
const symbol = '!@#$%&*+-='
|
||
const all = upper + lower + digit + symbol
|
||
|
||
// Pick at least one from each class; fill the rest from `all`; shuffle.
|
||
const out: string[] = [
|
||
upper[Math.floor(Math.random() * upper.length)],
|
||
lower[Math.floor(Math.random() * lower.length)],
|
||
digit[Math.floor(Math.random() * digit.length)],
|
||
symbol[Math.floor(Math.random() * symbol.length)],
|
||
]
|
||
while (out.length < 16) {
|
||
out.push(all[Math.floor(Math.random() * all.length)])
|
||
}
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1))
|
||
;[out[i], out[j]] = [out[j], out[i]]
|
||
}
|
||
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)
|
||
}
|