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, @InjectModel(Tenant.name) private readonly tenantModel: Model, @InjectModel(Partner.name) private readonly partnerModel: Model, @InjectModel(Subscription.name) private readonly subModel: Model, @InjectModel(Price.name) private readonly priceModel: Model, @InjectModel(Domain.name) private readonly domainModel: Model, private readonly audit: AuditService, private readonly authentik: AuthentikClient, private readonly stalwart: StalwartClient, private readonly ocis: OcisClient, config: ConfigService, ) { this.platformAdminGroup = config.get('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? 'dezky-platform-admins' } async create(dto: CreateUserDto): Promise { 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 { return this.userModel.find({ tenantIds: { $in: tenantIds } }).sort({ createdAt: -1 }).exec() } async findAll(): Promise { return this.userModel.find().sort({ createdAt: -1 }).exec() } async findOneBySubject(subject: string): Promise { 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 { const patch: Record = { ...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 { 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 { 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 { 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 { 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 { 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 { 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 = { [`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 { 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 { 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 = {} 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 = {} 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 { 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 { 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() 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() 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() 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 { 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 { 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) }