import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common'; import type { CapabilityProvider } from '@insignia/iios-contracts'; import { SandboxProvider } from './sandbox.provider'; import { HttpProvider } from './http.provider'; import { EmailProvider } from './email.provider'; import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider'; import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider'; import { credKeyFromEnv } from './secret-crypto'; import { ProviderCredentialService } from './provider-credential.service'; import { STORAGE_PORT, type StoragePort } from '../media/storage.port'; const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; /** * Maps a channelType to the provider that executes egress for it. Sandbox by * default; if `IIOS_PROVIDER_URL_` is set, a real HttpProvider * overrides the sandbox for that channel (the "flip the binding" swap). Unknown * channels fail closed — no silent egress path. * * Precedence is registration ORDER (register() does Map.set → last wins). For EMAIL: * sandbox → HTTP EmailProvider (if URL set) → SMTP (if SMTP env set), so SMTP > HTTP > sandbox. */ @Injectable() export class CapabilityProviderRegistry { private readonly byChannel = new Map(); constructor( @Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort, @Optional() private readonly credentials?: ProviderCredentialService, ) { for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch])); for (const ch of DEFAULT_CHANNELS) { const url = process.env[`IIOS_PROVIDER_URL_${ch}`]; if (!url) continue; // EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one. this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url)); } // Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments // resolve through the media StoragePort when one is bound (else attachments FAIL closed). const smtp = smtpIdentityFromEnv(); if (smtp) { const resolver = this.storage ? storageResolver(this.storage) : undefined; this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver)); } // BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered // only when the platform key + credential store are present — else SMS stays on the sandbox. if (credKeyFromEnv() && this.credentials) { const creds = this.credentials; this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise)); } } register(provider: CapabilityProvider): void { for (const ch of provider.channelTypes) this.byChannel.set(ch, provider); } forChannel(channelType: string): CapabilityProvider { const p = this.byChannel.get(channelType); if (!p) throw new NotFoundException(`no egress provider registered for channel: ${channelType}`); return p; } /** Ops view: which provider backs each channel. */ bindings(): Record { return Object.fromEntries([...this.byChannel.entries()].map(([ch, p]) => [ch, p.name])); } }