chore(services): rename services/provisioning -> services/platform-api
O.0 prep from OPERATOR-PLAN.md. Mechanical refactor before adding partner management and operator-specific endpoints. The service now owns more than just provisioning orchestration (it'll soon own partners, tenant lifecycle actions, multi-audience JWT validation), so the name 'platform-api' reflects its scope better. What changed: - Directory: services/provisioning/ -> services/platform-api/ - Package: @dezky/provisioning -> @dezky/platform-api - Docker: container_name dezky-provisioning -> dezky-platform-api; compose service key 'provisioning' -> 'platform-api'; volume provisioning_node_modules -> platform_api_node_modules - Portal: PROVISIONING_INTERNAL_URL env var -> PLATFORM_API_INTERNAL_URL, default URL http://provisioning:3001 -> http://platform-api:3001 in all three proxy routes (me.get.ts, tenants/index.post.ts, tenants/[slug]/ reconcile.post.ts), plus NUXT_API_BASE updated - Health endpoint service identifier and main.ts log lines updated to 'dezky-platform-api' - Docs swept: README, CLAUDE.md, SERVICES.md, AUTHENTIK-SETUP.md, NEXT-STEPS.md, TROUBLESHOOTING.md, OPERATOR-PLAN.md, traefik/dynamic.yml What deliberately stays: - Internal module names ProvisioningService / ProvisioningModule (those describe an orchestration sub-concern, not the service's purpose) - Tenant.provisioningStatus / provisioningErrors field names (state per integration, not service name) - File services/platform-api/src/tenants/provisioning.service.ts - 'Hetzner provisioning' references in production-prep docs (infrastructure provisioning, unrelated) Verified end-to-end after rename: /api/me returns 200 with profile + 2 tenants + subscription, /api/tenants/dezky/reconcile returns 200 with Authentik integration still ok. OPERATOR-PLAN.md O.0 checkboxes ticked.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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<TenantDocument>,
|
||||
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<TenantDocument> {
|
||||
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 + OCIS are stubbed — the upstream call no-ops and we record the
|
||||
// honest 'skipped' state by returning it from the step.
|
||||
await this.runStep(tenant, 'stalwart', async () => {
|
||||
const domain = this.domainFor(tenant.slug)
|
||||
await this.stalwart.ensureDomain(domain, `Mail domain for tenant ${tenant.slug}`)
|
||||
tenant.stalwartDomain = domain
|
||||
return 'skipped'
|
||||
})
|
||||
|
||||
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<IntegrationState | void>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user