import { Injectable, Logger } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { AuthentikClient } from '../integrations/authentik.client.js' import { OcisClient } from '../integrations/ocis.client.js' import { StalwartClient } from '../integrations/stalwart.client.js' import { IntegrationState, Tenant, TenantDocument, } from '../schemas/tenant.schema.js' // Orchestrates provisioning across Authentik / Stalwart / OCIS. Each step is // independent — one failure doesn't roll back the others — and the per-step // status is recorded on the tenant document so the operation is idempotent // when retried. @Injectable() export class ProvisioningService { private readonly logger = new Logger(ProvisioningService.name) constructor( @InjectModel(Tenant.name) private readonly tenantModel: Model, private readonly authentik: AuthentikClient, private readonly stalwart: StalwartClient, private readonly ocis: OcisClient, ) {} // Runs all integrations and writes back per-step state. Returns the refreshed // tenant doc so the controller can return it to the caller. async reconcile(tenant: TenantDocument): Promise { this.logger.log(`Reconciling tenant "${tenant.slug}"`) await this.runStep(tenant, 'authentik', async () => { const group = await this.authentik.ensureGroup(tenant.slug, { tenantId: tenant.id }) tenant.authentikGroupId = String(group.pk) }) // Stalwart provisioning is real when STALWART_PROVISIONING_ENABLED is on; // otherwise we record the honest 'skipped' state. ensureDomain is idempotent // and auto-generates the domain's DKIM keys. await this.runStep(tenant, 'stalwart', async () => { const domain = this.domainFor(tenant.slug) if (!this.stalwart.configured) return 'skipped' await this.stalwart.ensureDomain(domain, `Mail domain for tenant ${tenant.slug}`) tenant.stalwartDomain = domain // falls through to 'ok' — a real upstream call succeeded }) await this.runStep(tenant, 'ocis', async () => { const space = await this.ocis.ensureSpace(tenant.slug) tenant.ocisSpaceId = space.id return 'skipped' }) // If every required integration is either 'ok' or 'skipped' (not 'error' / // 'pending'), activate the tenant. Skipped steps don't block activation — // they just won't have their resources wired up yet. const keys = ['authentik', 'stalwart', 'ocis'] as const const allSettled = keys.every((k) => { const s = tenant.provisioningStatus[k] return s === 'ok' || s === 'skipped' }) if (allSettled && tenant.status === 'pending') { tenant.status = 'active' } // Mongoose doesn't auto-detect mutations inside nested subdocuments — flag // these paths as modified so the save() actually persists our changes. tenant.markModified('provisioningStatus') tenant.markModified('provisioningErrors') await tenant.save() return tenant } // Step returns its terminal state explicitly. Returning void means "this step // ran a real upstream call successfully" — that's mapped to 'ok'. Returning a // specific state ('skipped', etc.) lets stub integrations be honest about // not actually doing the work. private async runStep( tenant: TenantDocument, key: 'authentik' | 'stalwart' | 'ocis', work: () => Promise, ): Promise { try { const result = await work() tenant.provisioningStatus[key] = result ?? 'ok' if (tenant.provisioningErrors[key]) delete tenant.provisioningErrors[key] } catch (err) { const msg = (err as Error).message tenant.provisioningStatus[key] = 'error' tenant.provisioningErrors[key] = msg this.logger.error(`Tenant "${tenant.slug}" — ${key} step failed: ${msg}`) } } // Maps tenant slug → mail domain. Production should use a real registered // domain (e.g. acme.dezky.com); locally we use the .local hierarchy. private domainFor(slug: string): string { return `${slug}.dezky.local` } // Best-effort cleanup. Called when a tenant is hard-deleted (not soft-deleted). async tearDown(tenant: TenantDocument): Promise { if (tenant.authentikGroupId) { await this.authentik.deleteGroup(tenant.authentikGroupId).catch((err) => { this.logger.error(`Failed to delete Authentik group: ${(err as Error).message}`) }) } if (tenant.stalwartDomain) { await this.stalwart.deleteDomain(tenant.stalwartDomain).catch((err) => { this.logger.error(`Failed to delete Stalwart domain: ${(err as Error).message}`) }) } if (tenant.ocisSpaceId) { await this.ocis.deleteSpace(tenant.ocisSpaceId).catch((err) => { this.logger.error(`Failed to delete OCIS space: ${(err as Error).message}`) }) } } }