MicProtocol / apps /api /src /integrations /integrations.service.ts
Benaah's picture
Upload folder using huggingface_hub
6abfd9d verified
Raw History Blame Contribute Delete
77.2 kB
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException, ForbiddenException, Inject, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { randomBytes } from "crypto"; import type { RedisClientType } from "redis"; import { PrismaService } from "../prisma/prisma.service"; import { WorkspaceRole, CredentialSource } from "@micprotocol/shared-types"; import { SubscriptionCredentialsService } from "../billing/subscription-credentials.service"; import { SelfHostedClientsService, RiversideRoom, ZoomMeeting, DocumensoDocument, CalComEventType, CalComBooking, ChatwootConversation, ChatwootMessage, ChatwootContact, PretixEvent, PretixItem, PretixOrder, PretixOrderPosition, ListmonkList, ListmonkSubscriber } from "./clients/self-hosted-clients.service"; import { REDIS_CLIENT } from "../common/redis.constants"; import type { IntegrationProvider } from "@prisma/client"; export type IntegrationProviderName = | "google_calendar" | "google_mail" | "outlook" | "riverside" | "zoom" | "cloudflare_r2" | "deepgram" | "nvidia_nim" | "deepseek" | "moonshot" // Self-hosted open-source integration providers | "calcom" | "chatwoot" | "documenso" | "formbricks" | "listmonk" | "pretix"; export type IntegrationStatusValue = "connected" | "disconnected" | "expired" | "error"; interface OAuthConfig { clientId: string; clientSecret: string; authUrl: string; tokenUrl: string; scopes: string[]; redirectUri: string; } interface SelfHostedConfig { baseUrl: string; apiKeyEnv: string; webhookSecretEnv: string; healthEndpoint: string; } interface CalendarEventData { title: string; description?: string; startTime: string; endTime: string; timezone?: string; location?: string; meetLink?: string; attendees?: Array<{ email: string; name?: string }>; } @Injectable() export class IntegrationsService { private readonly logger = new Logger(IntegrationsService.name); private readonly oauthConfigs: Record<string, OAuthConfig>; private readonly selfHostedConfigs: Record<string, SelfHostedConfig>; // OAuth `state` nonce β†’ pending flow (fallback store for single-instance // deployments when Redis is unavailable). Primary store is Redis so the // callback can land on any instance. private readonly oauthStateCache = new Map< string, { workspaceId: string; provider: string; userId: string; expiresAt: number } >(); constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, private readonly subscriptionCredentialsService: SubscriptionCredentialsService, private readonly selfHostedClients: SelfHostedClientsService, @Inject(REDIS_CLIENT) private readonly redis: RedisClientType, ) { const baseRedirect = this.configService.get<string>("API_URL", "http://localhost:4000"); this.oauthConfigs = { google_calendar: { clientId: this.configService.get<string>("GOOGLE_CLIENT_ID", ""), clientSecret: this.configService.get<string>("GOOGLE_CLIENT_SECRET", ""), authUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", scopes: [ "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events", ], redirectUri: `${baseRedirect}/api/integrations/google/callback`, }, google_mail: { clientId: this.configService.get<string>("GOOGLE_CLIENT_ID", ""), clientSecret: this.configService.get<string>("GOOGLE_CLIENT_SECRET", ""), authUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", scopes: [ "https://www.googleapis.com/auth/gmail.send", "https://www.googleapis.com/auth/gmail.compose", "https://www.googleapis.com/auth/gmail.modify", ], redirectUri: `${baseRedirect}/api/integrations/google/callback`, }, outlook: { clientId: this.configService.get<string>("OUTLOOK_CLIENT_ID", ""), clientSecret: this.configService.get<string>("OUTLOOK_CLIENT_SECRET", ""), authUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token", scopes: [ "https://graph.microsoft.com/Calendars.ReadWrite", "https://graph.microsoft.com/Mail.Send", "https://graph.microsoft.com/User.Read", ], redirectUri: `${baseRedirect}/api/integrations/outlook/callback`, }, riverside: { clientId: this.configService.get<string>("RIVERSIDE_API_KEY", ""), clientSecret: "", authUrl: "https://riverside.fm/api/v2/authorize", tokenUrl: "https://riverside.fm/api/v2/token", scopes: ["rooms:create", "rooms:read", "recordings:read"], redirectUri: `${baseRedirect}/api/integrations/riverside/callback`, }, zoom: { clientId: this.configService.get<string>("ZOOM_CLIENT_ID", ""), clientSecret: this.configService.get<string>("ZOOM_CLIENT_SECRET", ""), authUrl: "https://zoom.us/oauth/authorize", tokenUrl: "https://zoom.us/oauth/token", scopes: ["meeting:write", "meeting:read", "user:read"], redirectUri: `${baseRedirect}/api/integrations/zoom/callback`, }, cloudflare_r2: { clientId: this.configService.get<string>("CLOUDFLARE_R2_ACCESS_KEY_ID", ""), clientSecret: this.configService.get<string>("CLOUDFLARE_R2_SECRET_ACCESS_KEY", ""), authUrl: "", tokenUrl: "", scopes: [ "read", "write", "delete", "list", ], redirectUri: "", }, }; this.selfHostedConfigs = { calcom: { baseUrl: this.configService.get<string>("CALCOM_URL", "http://localhost:3002"), apiKeyEnv: "CALCOM_API_KEY", webhookSecretEnv: "CALCOM_WEBHOOK_SECRET", healthEndpoint: "/api/health", }, chatwoot: { baseUrl: this.configService.get<string>("CHATWOOT_URL", "http://localhost:3005"), apiKeyEnv: "CHATWOOT_API_KEY", webhookSecretEnv: "CHATWOOT_WEBHOOK_SECRET", healthEndpoint: "/health", }, documenso: { baseUrl: this.configService.get<string>("DOCUMENSO_URL", "http://localhost:3004"), apiKeyEnv: "DOCUMENSO_API_KEY", webhookSecretEnv: "DOCUMENSO_WEBHOOK_SECRET", healthEndpoint: "/api/health", }, formbricks: { baseUrl: this.configService.get<string>("FORMBRICKS_URL", "http://localhost:3001"), apiKeyEnv: "FORMBRICKS_API_KEY", webhookSecretEnv: "FORMBRICKS_WEBHOOK_SECRET", healthEndpoint: "/api/health", }, listmonk: { baseUrl: this.configService.get<string>("LISTMONK_URL", "http://localhost:9000"), apiKeyEnv: "LISTMONK_API_TOKEN", webhookSecretEnv: "", healthEndpoint: "/health", }, pretix: { baseUrl: this.configService.get<string>("PRETIX_URL", "http://pretix:8000"), apiKeyEnv: "PRETIX_API_KEY", webhookSecretEnv: "PRETIX_WEBHOOK_SECRET", healthEndpoint: "/health", }, }; } // ═══ Credential Resolution (Tier-Aware) ════════════ /** * Resolve credentials for a provider in a workspace, considering * the workspace's subscription tier and the platform-managed credential pool. * * Returns a `ResolvedCredential` from `SubscriptionCredentialsService`, * which checks tier access first, then resolves from either * PlatformIntegrationCredential (platform-managed) or WorkspaceIntegration * (workspace OAuth). */ async resolveCredentialsWithTier( workspaceId: string, provider: IntegrationProviderName, ) { return this.subscriptionCredentialsService.resolveCredentials(workspaceId, provider); } // ═══ Integration CRUD ═══════════════════════════════ async listIntegrations(workspaceId: string, userId: string) { await this.ensureWorkspaceAccess(workspaceId, userId); const integrations = await this.prisma.workspaceIntegration.findMany({ where: { workspaceId }, orderBy: { createdAt: "desc" }, }); // Enrich with provider metadata return integrations.map((integration) => { const config = this.oauthConfigs[integration.provider]; return { id: integration.id, workspaceId: integration.workspaceId, provider: integration.provider, status: integration.status, providerAccountId: integration.providerAccountId, providerEmail: integration.providerEmail, providerName: integration.providerName, scopes: integration.scopes, lastSyncAt: integration.lastSyncAt, errorMessage: integration.errorMessage, metadata: integration.metadata, createdAt: integration.createdAt, updatedAt: integration.updatedAt, isConfigurable: !!config?.clientId, hasValidConfig: this.isProviderConfigured(integration.provider as IntegrationProviderName), }; }); } async getIntegration(workspaceId: string, provider: IntegrationProviderName, userId: string) { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.prisma.workspaceIntegration.findUnique({ where: { workspaceId_provider: { workspaceId, provider: provider as IntegrationProvider, }, }, }); if (!integration) { return null; } return { id: integration.id, workspaceId: integration.workspaceId, provider: integration.provider, status: integration.status, providerAccountId: integration.providerAccountId, providerEmail: integration.providerEmail, providerName: integration.providerName, scopes: integration.scopes, lastSyncAt: integration.lastSyncAt, errorMessage: integration.errorMessage, metadata: integration.metadata, createdAt: integration.createdAt, updatedAt: integration.updatedAt, }; } async disconnectIntegration(workspaceId: string, provider: IntegrationProviderName, userId: string) { await this.ensureWorkspacePermission(workspaceId, userId); const integration = await this.prisma.workspaceIntegration.findUnique({ where: { workspaceId_provider: { workspaceId, provider: provider as IntegrationProvider, }, }, }); if (!integration) { throw new NotFoundException(`Integration ${provider} not found for this workspace`); } // Revoke tokens if possible await this.revokeProviderTokens(integration); // Update status and clear tokens await this.prisma.workspaceIntegration.update({ where: { id: integration.id }, data: { status: "disconnected", accessToken: null, refreshToken: null, tokenExpiresAt: null, providerAccountId: null, providerEmail: null, providerName: null, errorMessage: null, }, }); this.logger.log(`Disconnected ${provider} for workspace ${workspaceId}`); return { success: true, message: `${provider} integration disconnected` }; } // ═══ OAuth Flow ════════════════════════════════════ async getAuthUrl( workspaceId: string, provider: IntegrationProviderName, userId: string, ): Promise<string> { await this.ensureWorkspacePermission(workspaceId, userId); const config = this.oauthConfigs[provider]; const selfHosted = this.selfHostedConfigs[provider]; if (selfHosted) { return selfHosted.baseUrl; } if (!config || !config.clientId) { throw new BadRequestException( `Provider ${provider} is not configured. Set up API keys in environment variables first.` ); } // For non-OAuth providers (like Cloudflare R2), we handle differently if (provider === "cloudflare_r2") { return ""; // Will be handled via API key form } // SECURITY: use an unguessable random nonce for OAuth `state`, // stored server-side and bound to the initiating user/workspace. // A reversible base64 state is forgeable and enables OAuth CSRF / // account-linking attacks (victim's provider account could be bound // into an attacker-controlled workspace). const state = randomBytes(24).toString("hex"); await this.storeOAuthState(state, { workspaceId, provider, userId }); const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: config.redirectUri, response_type: "code", scope: config.scopes.join(" "), state, access_type: "offline", prompt: "consent", }); return `${config.authUrl}?${params.toString()}`; } async handleOAuthCallback( code: string, state: string, ): Promise<{ workspaceId: string; provider: string; success: boolean }> { // SECURITY: the `state` must be a nonce we issued and stored (with // short TTL). It is consumed exactly once. const stateData = await this.consumeOAuthState(state); if (!stateData) { throw new BadRequestException( "Invalid or expired OAuth state parameter", ); } const { workspaceId, provider, userId } = stateData; const config = this.oauthConfigs[provider]; if (!config) { throw new BadRequestException(`Unknown provider: ${provider}`); } // Exchange code for tokens let tokens: { accessToken: string; refreshToken: string; expiresAt: Date }; try { tokens = await this.exchangeOAuthCode(provider, code, config); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`OAuth token exchange failed for ${provider}: ${message}`); await this.upsertIntegration(workspaceId, provider as IntegrationProviderName, { status: "error" as IntegrationStatusValue, errorMessage: `OAuth token exchange failed: ${message}`, }); return { workspaceId, provider, success: false }; } // Fetch provider account info let accountInfo: { id: string; email: string; name: string } | null = null; try { accountInfo = await this.fetchProviderAccountInfo(provider as IntegrationProviderName, tokens.accessToken); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.warn(`Could not fetch account info for ${provider}: ${message}`); } // Store integration await this.upsertIntegration(workspaceId, provider as IntegrationProviderName, { status: "connected", accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, tokenExpiresAt: tokens.expiresAt, scopes: config.scopes, providerAccountId: accountInfo?.id ?? null, providerEmail: accountInfo?.email ?? null, providerName: accountInfo?.name ?? null, errorMessage: null, lastSyncAt: new Date(), }); this.logger.log(`Successfully connected ${provider} for workspace ${workspaceId}`); return { workspaceId, provider, success: true }; } async updateApiKeyIntegration( workspaceId: string, provider: IntegrationProviderName, userId: string, credentials: { accessKeyId?: string; secretAccessKey?: string; apiKey?: string; accountId?: string; baseUrl?: string; webhookSecret?: string }, ) { await this.ensureWorkspacePermission(workspaceId, userId); const selfHosted = this.selfHostedConfigs[provider]; const isSelfHosted = !!selfHosted; const isCloudflare = provider === "cloudflare_r2"; if (!isSelfHosted && !isCloudflare) { throw new BadRequestException( "API key update is only supported for self-hosted integrations (calcom, chatwoot, documenso, formbricks, listmonk, pretix) and Cloudflare R2" ); } if (isCloudflare) { if (!credentials.accessKeyId || !credentials.secretAccessKey) { throw new BadRequestException("Access Key ID and Secret Access Key are required"); } await this.upsertIntegration(workspaceId, provider, { status: "connected", accessToken: credentials.secretAccessKey, providerAccountId: credentials.accessKeyId, providerName: credentials.accountId || null, scopes: ["read", "write", "delete", "list"], errorMessage: null, lastSyncAt: new Date(), metadata: { accessKeyId: credentials.accessKeyId, accountId: credentials.accountId || null, endpoint: `https://${credentials.accountId || ""}.r2.cloudflarestorage.com`, }, }); return { success: true, message: "Cloudflare R2 integration updated" }; } if (isSelfHosted) { if (!credentials.apiKey) { throw new BadRequestException(`API key is required for ${provider}`); } await this.upsertIntegration(workspaceId, provider, { status: "connected", accessToken: credentials.apiKey, providerName: provider, scopes: ["read", "write"], errorMessage: null, lastSyncAt: new Date(), metadata: { baseUrl: credentials.baseUrl || selfHosted.baseUrl, webhookSecret: credentials.webhookSecret || "", }, }); this.logger.log(`Connected ${provider} for workspace ${workspaceId}`); return { success: true, message: `${provider} integration connected` }; } } // ═══ Calendar Sync ════════════════════════════════ async syncCalendarEvents( workspaceId: string, userId: string, provider: "google_calendar" | "outlook", ) { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, provider); if (!integration.accessToken) { throw new BadRequestException(`${provider} is not connected`); } try { const events = await this.fetchCalendarEvents(provider, integration.accessToken); // Upsert events to our DB for (const event of events) { await this.prisma.integrationEvent.upsert({ where: { integrationId_providerEventId: { integrationId: integration.id, providerEventId: event.id || "", }, }, create: { integrationId: integration.id, providerEventId: event.id || "", title: event.title, description: event.description || null, startTime: new Date(event.startTime), endTime: new Date(event.endTime), timezone: event.timezone || "UTC", location: event.location || null, meetLink: event.meetLink || null, attendees: event.attendees || [], isSynced: true, lastSyncedAt: new Date(), }, update: { title: event.title, description: event.description || null, startTime: new Date(event.startTime), endTime: new Date(event.endTime), timezone: event.timezone || "UTC", location: event.location || null, meetLink: event.meetLink || null, attendees: event.attendees || [], isSynced: true, lastSyncedAt: new Date(), }, }); } // Update sync timestamp await this.prisma.workspaceIntegration.update({ where: { id: integration.id }, data: { lastSyncAt: new Date() }, }); return { success: true, syncedCount: events.length, message: `Synced ${events.length} calendar events`, }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`Calendar sync failed for ${provider}: ${message}`); throw new BadRequestException(`Calendar sync failed: ${message}`); } } async getSyncedEvents( workspaceId: string, provider: "google_calendar" | "outlook", userId: string, timeMin?: string, timeMax?: string, ) { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.prisma.workspaceIntegration.findUnique({ where: { workspaceId_provider: { workspaceId, provider, }, }, }); if (!integration) return []; const whereClause: { integrationId: string; OR?: Array<{ startTime?: { gte: Date }; endTime?: { lte: Date } }> } = { integrationId: integration.id, }; if (timeMin || timeMax) { whereClause.OR = []; if (timeMin) { whereClause.OR.push({ startTime: { gte: new Date(timeMin) } }); } if (timeMax) { whereClause.OR.push({ endTime: { lte: new Date(timeMax) } }); } } return this.prisma.integrationEvent.findMany({ where: whereClause, orderBy: { startTime: "asc" }, take: 200, }); } async createCalendarEvent( workspaceId: string, userId: string, provider: "google_calendar" | "outlook", eventData: CalendarEventData, ) { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, provider); if (!integration.accessToken) { throw new BadRequestException(`${provider} is not connected`); } try { const providerEvent = await this.createProviderEvent(provider, integration.accessToken, eventData); if (providerEvent?.id) { await this.prisma.integrationEvent.create({ data: { integrationId: integration.id, providerEventId: providerEvent.id, title: eventData.title, description: eventData.description || null, startTime: new Date(eventData.startTime), endTime: new Date(eventData.endTime), timezone: eventData.timezone || "UTC", location: eventData.location || null, meetLink: providerEvent.meetLink || eventData.meetLink || null, attendees: eventData.attendees || [], isSynced: true, lastSyncedAt: new Date(), }, }); } return { success: true, providerEventId: providerEvent?.id, meetLink: providerEvent?.meetLink }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`Failed to create calendar event on ${provider}: ${message}`); throw new BadRequestException(`Failed to create event: ${message}`); } } // ═══ Utility Queries ════════════════════════════════ async getIntegrationStats(workspaceId: string, userId: string) { await this.ensureWorkspaceAccess(workspaceId, userId); const integrations = await this.prisma.workspaceIntegration.findMany({ where: { workspaceId }, }); const total = integrations.length; const connected = integrations.filter((i) => i.status === "connected").length; const withErrors = integrations.filter((i) => i.status === "error").length; const providerStatuses: Record<string, string> = {}; for (const integration of integrations) { providerStatuses[integration.provider] = integration.status; } return { total, connected, withErrors, providerStatuses, }; } // ═══ Provider Metrics ═══ async getProviderMetrics() { // Delegate to the self-hosted clients service which tracks // per-provider request counts, average latency, and error rates. return this.selfHostedClients.getMetrics(); } async getAllProviders() { const providers: Array<{ id: IntegrationProviderName; name: string; description: string; icon: string; color: string; isOAuth: boolean; isConfigured: boolean; docsUrl: string; }> = [ { id: "google_calendar", name: "Google Calendar", description: "Sync recording slots and availability. Automatically create events for bookings.", icon: "calendar", color: "#4285F4", isOAuth: true, isConfigured: this.isProviderConfigured("google_calendar"), docsUrl: "https://developers.google.com/calendar", }, { id: "google_mail", name: "Gmail", description: "Send outreach sequences, booking confirmations, and follow-ups via your Gmail account.", icon: "mail", color: "#EA4335", isOAuth: true, isConfigured: this.isProviderConfigured("google_mail"), docsUrl: "https://developers.google.com/gmail", }, { id: "outlook", name: "Outlook Calendar & Mail", description: "Sync with Microsoft 365 calendar and send emails via Outlook.", icon: "mail", color: "#0078D4", isOAuth: true, isConfigured: this.isProviderConfigured("outlook"), docsUrl: "https://learn.microsoft.com/en-us/graph/", }, { id: "riverside", name: "Riverside.fm", description: "Auto-generate recording rooms and manage recordings from within MicProtocol.", icon: "video", color: "#6C5CE7", isOAuth: true, isConfigured: this.isProviderConfigured("riverside"), docsUrl: "https://riverside.fm/api", }, { id: "zoom", name: "Zoom", description: "Create Zoom meetings for recordings and manage meeting settings.", icon: "video", color: "#2D8CFF", isOAuth: true, isConfigured: this.isProviderConfigured("zoom"), docsUrl: "https://developers.zoom.us", }, { id: "cloudflare_r2", name: "Cloudflare R2", description: "Store audio recordings, video clips, and assets in Cloudflare R2 object storage.", icon: "cloud", color: "#F38020", isOAuth: false, isConfigured: this.isProviderConfigured("cloudflare_r2"), docsUrl: "https://developers.cloudflare.com/r2/", }, // ── Self-Hosted Integrations ──────────────── { id: "calcom", name: "Cal.com", description: "Self-hosted scheduling and booking. Manage availability, event types, and automate booking confirmations.", icon: "calendar", color: "#3B82F6", isOAuth: false, isConfigured: this.isProviderConfigured("calcom"), docsUrl: "https://cal.com/docs", }, { id: "chatwoot", name: "Chatwoot", description: "Self-hosted customer support platform. Live chat, helpdesk tickets, and omnichannel messaging.", icon: "message-circle", color: "#1F93FF", isOAuth: false, isConfigured: this.isProviderConfigured("chatwoot"), docsUrl: "https://www.chatwoot.com/docs", }, { id: "documenso", name: "Documenso", description: "Self-hosted document e-signing. Send, sign, and manage contracts and agreements.", icon: "file-signature", color: "#7C3AED", isOAuth: false, isConfigured: this.isProviderConfigured("documenso"), docsUrl: "https://documenso.com/docs", }, { id: "formbricks", name: "Formbricks", description: "Self-hosted surveys and feedback. Create in-app surveys, NPS polls, and customer experience analytics.", icon: "square-poll-vertical", color: "#0F172A", isOAuth: false, isConfigured: this.isProviderConfigured("formbricks"), docsUrl: "https://formbricks.com/docs", }, { id: "listmonk", name: "Listmonk", description: "Self-hosted newsletter and email marketing. Manage mailing lists, campaigns, and transactional emails.", icon: "mail", color: "#10B981", isOAuth: false, isConfigured: this.isProviderConfigured("listmonk"), docsUrl: "https://listmonk.app/docs", }, { id: "pretix", name: "Pretix", description: "Self-hosted ticketing and event registration. Manage events, tickets, orders, and QR-coded attendees.", icon: "ticket", color: "#F59E0B", isOAuth: false, isConfigured: this.isProviderConfigured("pretix"), docsUrl: "https://docs.pretix.eu", }, ]; return providers; } // ═══ Private: OAuth state nonce store ═══════════════ private async storeOAuthState( state: string, data: { workspaceId: string; provider: string; userId: string }, ): Promise<void> { try { await this.redis.setEx( `oauth:state:${state}`, 600, JSON.stringify(data), ); } catch { // Single-instance fallback (Redis unavailable). this.oauthStateCache.set(state, { ...data, expiresAt: Date.now() + 10 * 60 * 1000, }); } } private async consumeOAuthState( state: string, ): Promise<{ workspaceId: string; provider: string; userId: string } | null> { if (!state) return null; try { const raw = await this.redis.get(`oauth:state:${state}`); if (raw) { await this.redis.del(`oauth:state:${state}`); return JSON.parse(raw); } } catch { /* fall through to the in-memory cache */ } const cached = this.oauthStateCache.get(state); if (cached) { this.oauthStateCache.delete(state); if (cached.expiresAt > Date.now()) return cached; } return null; } // ═══ Private: Token Management ═══════════════════════ private async exchangeOAuthCode( provider: string, code: string, config: OAuthConfig, ): Promise<{ accessToken: string; refreshToken: string; expiresAt: Date }> { const response = await fetch(config.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: config.clientId, client_secret: config.clientSecret, redirect_uri: config.redirectUri, grant_type: "authorization_code", }), }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`Token exchange failed: ${response.status} ${errorBody}`); } const data = (await response.json()) as Record<string, any>; const expiresAt = new Date(); expiresAt.setSeconds(expiresAt.getSeconds() + (data.expires_in || 3600)); return { accessToken: data.access_token, refreshToken: data.refresh_token || "", expiresAt, }; } private async refreshAccessToken( provider: string, refreshToken: string, ): Promise<{ accessToken: string; expiresAt: Date }> { const config = this.oauthConfigs[provider]; if (!config) throw new Error(`Unknown provider: ${provider}`); const response = await fetch(config.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ refresh_token: refreshToken, client_id: config.clientId, client_secret: config.clientSecret, grant_type: "refresh_token", }), }); if (!response.ok) { throw new Error(`Token refresh failed: ${response.status}`); } const data = (await response.json()) as Record<string, any>; const expiresAt = new Date(); expiresAt.setSeconds(expiresAt.getSeconds() + (data.expires_in || 3600)); return { accessToken: data.access_token, expiresAt, }; } private async revokeProviderTokens(integration: { provider: string; refreshToken?: string | null }) { if (!integration.refreshToken) return; try { const config = this.oauthConfigs[integration.provider]; if (config) { await fetch(config.tokenUrl.replace("/token", "/revoke"), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: integration.refreshToken, client_id: config.clientId, client_secret: config.clientSecret, }), }); } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.warn(`Token revocation failed for ${integration.provider}: ${message}`); } } private async getValidatedIntegration(workspaceId: string, provider: IntegrationProviderName) { const integration = await this.prisma.workspaceIntegration.findUnique({ where: { workspaceId_provider: { workspaceId, provider: provider as IntegrationProvider, }, }, }); if (!integration || integration.status !== "connected") { // ── Tier-aware fallback ────────────────────────── // If the workspace hasn't connected its own OAuth account, check whether // the subscription tier provides platform-managed credentials for this // provider. This is the core mechanism that lets workspaces use // integrations (Deepgram, NVIDIA NIM, DeepSeek, Moonshot, etc.) without managing their own keys. try { const resolved = await this.subscriptionCredentialsService.resolveCredentials( workspaceId, provider, ); if (resolved.source === "platform" && resolved.accessToken) { // Return a shape compatible with the callers that expect an // integration-like object with an `accessToken` field. return { id: `platform:${provider}`, workspaceId, provider, status: "connected" as const, accessToken: resolved.accessToken, refreshToken: resolved.refreshToken, tokenExpiresAt: resolved.tokenExpiresAt, scopes: resolved.scopes, metadata: resolved.metadata, // Marker so callers know this is a platform credential, not a // per-workspace OAuth connection. _source: "platform" as const, }; } if (resolved.source === "platform" && resolved.apiKey) { return { id: `platform:${provider}`, workspaceId, provider, status: "connected" as const, accessToken: resolved.apiKey, refreshToken: null, tokenExpiresAt: null, scopes: resolved.scopes, metadata: { ...resolved.metadata, _credentialType: "apiKey", apiSecret: resolved.apiSecret, endpointUrl: resolved.endpointUrl, }, _source: "platform" as const, }; } } catch (tierError: unknown) { // If the tier check itself fails (e.g. subscription not active, tier // doesn't include this provider), re-throw β€” it's more informative // than the generic "not connected" message. const tierErr = tierError instanceof Error ? tierError : new Error(String(tierError)); if ( tierErr.constructor?.name === "ForbiddenException" || tierErr.constructor?.name === "BadRequestException" ) { throw tierErr; } // Otherwise fall through to the original error below this.logger.warn( `Tier credential resolution failed for ${provider}: ${tierErr.message}`, ); } throw new BadRequestException( `${provider} is not connected. Please connect it in Settings > Integrations first.`, ); } // Check if token expired and refresh if needed if (integration.tokenExpiresAt && new Date() >= integration.tokenExpiresAt && integration.refreshToken) { try { const newTokens = await this.refreshAccessToken(provider, integration.refreshToken); await this.prisma.workspaceIntegration.update({ where: { id: integration.id }, data: { accessToken: newTokens.accessToken, tokenExpiresAt: newTokens.expiresAt, }, }); return { ...integration, accessToken: newTokens.accessToken }; } catch { await this.prisma.workspaceIntegration.update({ where: { id: integration.id }, data: { status: "expired" }, }); throw new BadRequestException(`${provider} token expired. Please reconnect.`); } } return integration; } // ═══ Private: Provider API calls ════════════════════ private async fetchProviderAccountInfo( provider: IntegrationProviderName, accessToken: string, ): Promise<{ id: string; email: string; name: string } | null> { try { switch (provider) { case "google_calendar": case "google_mail": { const resp = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (resp.ok) { const d = (await resp.json()) as Record<string, any>; return { id: d.id, email: d.email, name: d.name }; } break; } case "outlook": { const resp = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (resp.ok) { const d = (await resp.json()) as Record<string, any>; return { id: d.id, email: d.mail || d.userPrincipalName, name: d.displayName }; } break; } case "zoom": { const resp = await fetch("https://api.zoom.us/v2/users/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (resp.ok) { const d = (await resp.json()) as Record<string, any>; return { id: d.id, email: d.email, name: `${d.first_name} ${d.last_name}` }; } break; } case "riverside": { const resp = await fetch("https://riverside.fm/api/v2/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (resp.ok) { const d = (await resp.json()) as Record<string, any>; return { id: d.id, email: d.email || "", name: d.name || "" }; } break; } default: break; } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.warn(`Failed to fetch account info for ${provider}: ${message}`); } return null; } private async fetchCalendarEvents( provider: "google_calendar" | "outlook", accessToken: string, ): Promise<Array<{ id: string; title: string; description?: string; startTime: string; endTime: string; timezone?: string; location?: string; meetLink?: string; attendees?: Array<{ email: string; name?: string }> }>> { const timeMin = new Date(); timeMin.setDate(timeMin.getDate() - 30); // Past 30 days const timeMax = new Date(); timeMax.setDate(timeMax.getDate() + 90); // Next 90 days const params = new URLSearchParams({ timeMin: timeMin.toISOString(), timeMax: timeMax.toISOString(), singleEvents: "true", orderBy: "startTime", maxResults: "250", }); let url: string; if (provider === "google_calendar") { url = `https://www.googleapis.com/calendar/v3/calendars/primary/events?${params}`; } else { url = `https://graph.microsoft.com/v1.0/me/calendar/events?$top=250&$filter=start/dateTime ge '${timeMin.toISOString()}'`; } const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`Calendar API error: ${response.status} ${errorBody}`); } const data = (await response.json()) as Record<string, unknown>; // Google Calendar API response types interface GoogleCalendarEvent { id?: string; summary?: string; description?: string; start?: { dateTime?: string; date?: string; timeZone?: string }; end?: { dateTime?: string; date?: string; timeZone?: string }; location?: string; conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }; attendees?: Array<{ email?: string; displayName?: string }>; } // Outlook Calendar API response types interface OutlookCalendarEvent { id?: string; subject?: string; bodyPreview?: string; start?: { dateTime?: string; date?: string; timeZone?: string }; end?: { dateTime?: string; date?: string; timeZone?: string }; location?: { displayName?: string }; onlineMeetingUrl?: string; attendees?: Array<{ emailAddress?: { address?: string; name?: string } }>; } if (provider === "google_calendar") { return ((data.items || []) as GoogleCalendarEvent[]).map((item) => ({ id: item.id || "", title: item.summary || "Untitled Event", description: item.description || undefined, startTime: item.start?.dateTime || item.start?.date || "", endTime: item.end?.dateTime || item.end?.date || "", timezone: item.start?.timeZone || "UTC", location: item.location || undefined, meetLink: item.conferenceData?.entryPoints?.find((ep) => ep.entryPointType === "video")?.uri || undefined, attendees: (item.attendees || []).map((a) => ({ email: a.email || "", name: a.displayName || a.email || "", })), })); } else { return ((data.value || []) as OutlookCalendarEvent[]).map((item) => ({ id: item.id || "", title: item.subject || "Untitled Event", description: item.bodyPreview || undefined, startTime: item.start?.dateTime || item.start?.date || "", endTime: item.end?.dateTime || item.end?.date || "", timezone: item.start?.timeZone || "UTC", location: item.location?.displayName || undefined, meetLink: item.onlineMeetingUrl || undefined, attendees: (item.attendees || []).map((a) => ({ email: a.emailAddress?.address || "", name: a.emailAddress?.name || "", })), })); } } private async createProviderEvent( provider: "google_calendar" | "outlook", accessToken: string, eventData: CalendarEventData, ): Promise<{ id: string; meetLink?: string } | null> { try { if (provider === "google_calendar") { const body = { summary: eventData.title, description: eventData.description || "", start: { dateTime: eventData.startTime, timeZone: eventData.timezone || "UTC", }, end: { dateTime: eventData.endTime, timeZone: eventData.timezone || "UTC", }, location: eventData.location || undefined, attendees: (eventData.attendees || []).map((a) => ({ email: a.email })), conferenceData: { createRequest: { requestId: `micprotocol-${Date.now()}`, conferenceSolutionKey: { type: "hangoutsMeet" }, }, }, }; const response = await fetch( "https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1", { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }, ); if (!response.ok) throw new Error(`Google Calendar API error: ${response.status}`); const gCalResult = (await response.json()) as { id?: string; hangoutLink?: string; conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }; }; return { id: gCalResult.id || "", meetLink: gCalResult.hangoutLink || gCalResult.conferenceData?.entryPoints?.find((ep) => ep.entryPointType === "video")?.uri, }; } else { // Outlook const body = { subject: eventData.title, body: { contentType: "HTML", content: eventData.description || "", }, start: { dateTime: eventData.startTime, timeZone: eventData.timezone || "UTC", }, end: { dateTime: eventData.endTime, timeZone: eventData.timezone || "UTC", }, location: eventData.location ? { displayName: eventData.location } : undefined, attendees: (eventData.attendees || []).map((a) => ({ emailAddress: { address: a.email, name: a.name }, type: "required", })), isOnlineMeeting: true, onlineMeetingProvider: "teamsForBusiness", }; const response = await fetch( "https://graph.microsoft.com/v1.0/me/events", { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }, ); if (!response.ok) throw new Error(`Outlook API error: ${response.status}`); const outlookResult = (await response.json()) as Record<string, any>; return { id: outlookResult.id, meetLink: outlookResult.onlineMeeting?.joinUrl || outlookResult.onlineMeetingUrl, }; } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`Failed to create provider event: ${message}`); return null; } } // ═══ Private: Storage helpers ════════════════════════ private async upsertIntegration( workspaceId: string, provider: IntegrationProviderName, opts: { status: IntegrationStatusValue; accessToken?: string | null; refreshToken?: string | null; tokenExpiresAt?: Date | null; scopes?: string[]; providerAccountId?: string | null; providerEmail?: string | null; providerName?: string | null; errorMessage?: string | null; lastSyncAt?: Date | null; metadata?: Record<string, unknown>; }, ) { const updateData: { status?: IntegrationStatusValue; errorMessage?: string | null; accessToken?: string; refreshToken?: string; tokenExpiresAt?: Date | null; scopes?: string[]; providerAccountId?: string; providerEmail?: string; providerName?: string; lastSyncAt?: Date; metadata?: Record<string, unknown>; } = { status: opts.status, errorMessage: opts.errorMessage ?? null, }; if (opts.accessToken !== undefined) updateData.accessToken = opts.accessToken ?? undefined; if (opts.refreshToken !== undefined) updateData.refreshToken = opts.refreshToken ?? undefined; if (opts.tokenExpiresAt !== undefined) updateData.tokenExpiresAt = opts.tokenExpiresAt; if (opts.scopes !== undefined) updateData.scopes = opts.scopes; if (opts.providerAccountId !== undefined) updateData.providerAccountId = opts.providerAccountId ?? undefined; if (opts.providerEmail !== undefined) updateData.providerEmail = opts.providerEmail ?? undefined; if (opts.providerName !== undefined) updateData.providerName = opts.providerName ?? undefined; if (opts.lastSyncAt !== undefined) updateData.lastSyncAt = opts.lastSyncAt ?? undefined; if (opts.metadata !== undefined) updateData.metadata = opts.metadata; return this.prisma.workspaceIntegration.upsert({ where: { workspaceId_provider: { workspaceId, provider: provider as unknown as IntegrationProvider, }, }, create: { workspaceId, provider: provider as unknown as IntegrationProvider, status: opts.status, errorMessage: opts.errorMessage, accessToken: opts.accessToken ?? undefined, refreshToken: opts.refreshToken ?? undefined, tokenExpiresAt: opts.tokenExpiresAt ?? undefined, scopes: opts.scopes ?? [], providerAccountId: opts.providerAccountId ?? undefined, providerEmail: opts.providerEmail ?? undefined, providerName: opts.providerName ?? undefined, lastSyncAt: opts.lastSyncAt ?? undefined, metadata: (opts.metadata as any) ?? {}, }, update: { status: opts.status, errorMessage: opts.errorMessage, accessToken: opts.accessToken ?? undefined, refreshToken: opts.refreshToken ?? undefined, tokenExpiresAt: opts.tokenExpiresAt ?? undefined, scopes: opts.scopes, providerAccountId: opts.providerAccountId ?? undefined, providerEmail: opts.providerEmail ?? undefined, providerName: opts.providerName ?? undefined, lastSyncAt: opts.lastSyncAt ?? undefined, metadata: opts.metadata !== undefined ? (opts.metadata as any) : undefined, }, }); } // ═══ Self-Hosted Integration Management ═══ async listSelfHostedIntegrations(workspaceId: string, userId: string) { await this.ensureWorkspaceAccess(workspaceId, userId); const integrations = await this.prisma.workspaceIntegration.findMany({ where: { workspaceId, provider: { in: ["calcom", "chatwoot", "documenso", "formbricks", "listmonk", "pretix"] as unknown as IntegrationProvider[] }, }, orderBy: { createdAt: "desc" }, }); return integrations.map((integration) => ({ id: integration.id, workspaceId: integration.workspaceId, provider: integration.provider, status: integration.status, providerName: integration.providerName, lastSyncAt: integration.lastSyncAt, errorMessage: integration.errorMessage, metadata: integration.metadata, createdAt: integration.createdAt, })); } async updateWebhookSecret( workspaceId: string, provider: IntegrationProviderName, secret: string, userId: string, ) { await this.ensureWorkspacePermission(workspaceId, userId); const integration = await this.prisma.workspaceIntegration.findUnique({ where: { workspaceId_provider: { workspaceId, provider: provider as IntegrationProvider, }, }, }); if (!integration) { throw new NotFoundException(`${provider} integration not found for this workspace`); } await this.prisma.workspaceIntegration.update({ where: { id: integration.id }, data: { metadata: { ...(integration.metadata as Record<string, any>), webhookSecret: secret, }, }, }); this.logger.log(`Updated webhook secret for ${provider} on workspace ${workspaceId}`); return { success: true, message: `Webhook secret updated for ${provider}` }; } // ═══ High-Level Integration Methods ═══════════════════ /** * Send an email via Gmail integration. */ async sendEmail(workspaceId: string, data: { to: string[]; cc?: string[]; bcc?: string[]; subject: string; htmlBody: string; replyTo?: string; threadId?: string; }): Promise<{ id: string; threadId: string }> { const integration = await this.getValidatedIntegration(workspaceId, "google_mail"); const token = integration.accessToken; if (!token) throw new BadRequestException("Gmail access token not available"); return this.selfHostedClients.sendGmailMessage(token, data); } /** * Create a Riverside recording room. */ async createRiversideRoom(workspaceId: string, data: { name: string; scheduleStartTime?: string; participants?: Array<{ email: string; name: string }>; }): Promise<RiversideRoom> { const integration = await this.getValidatedIntegration(workspaceId, "riverside"); const token = integration.accessToken; if (!token) throw new BadRequestException("Riverside access token not available"); return this.selfHostedClients.createRiversideRoom(token, data); } /** * Create a Zoom meeting. */ async createZoomMeeting(workspaceId: string, data: { topic: string; startTime: string; durationMinutes: number; timezone?: string; agenda?: string; settings?: { waitingRoom?: boolean; allowMultipleParticipants?: boolean; recordAutomatically?: boolean }; }): Promise<ZoomMeeting> { const integration = await this.getValidatedIntegration(workspaceId, "zoom"); const token = integration.accessToken; if (!token) throw new BadRequestException("Zoom access token not available"); return this.selfHostedClients.createZoomMeeting(token, data); } /** * Upload a file to Cloudflare R2 storage. */ async uploadToR2(workspaceId: string, bucket: string, key: string, data: Buffer, contentType: string): Promise<{ key: string; url: string }> { // For R2, we check if the provider is configured (uses env vars for credentials) if (!this.isProviderConfigured("cloudflare_r2")) { throw new BadRequestException("Cloudflare R2 is not configured"); } const accessKeyId = this.configService.get<string>("R2_ACCESS_KEY_ID", ""); const secretAccessKey = this.configService.get<string>("R2_SECRET_ACCESS_KEY", ""); const accountId = this.configService.get<string>("R2_ACCOUNT_ID", ""); // Temporarily set the credentials for the signed request return this.selfHostedClients.uploadToR2(bucket, key, data, contentType); } /** * Download a file from Cloudflare R2 storage. */ async downloadFromR2(workspaceId: string, bucket: string, key: string): Promise<Buffer> { if (!this.isProviderConfigured("cloudflare_r2")) { throw new BadRequestException("Cloudflare R2 is not configured"); } return this.selfHostedClients.downloadFromR2(bucket, key); } /** * Delete a file from Cloudflare R2 storage. */ async deleteFromR2(workspaceId: string, bucket: string, key: string): Promise<void> { if (!this.isProviderConfigured("cloudflare_r2")) { throw new BadRequestException("Cloudflare R2 is not configured"); } return this.selfHostedClients.deleteFromR2(bucket, key); } /** * Send a document for signing via Documenso. */ async sendDocument(workspaceId: string, data: { title: string; recipients: Array<{ email: string; name: string; role?: "SIGNER" | "VIEWER" | "APPROVER" }>; documentUrl?: string; file?: Buffer; fileName?: string; }): Promise<DocumensoDocument> { const integration = await this.getValidatedIntegration(workspaceId, "documenso"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Documenso API key not available"); return this.selfHostedClients.sendDocument(apiKey, data); } /** * Get the status of a Documenso document. */ async getDocumensoDocumentStatus(workspaceId: string, documentId: number): Promise<DocumensoDocument> { const integration = await this.getValidatedIntegration(workspaceId, "documenso"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Documenso API key not available"); return this.selfHostedClients.getDocumentStatus(apiKey, documentId); } // ════════════════════════════════════════════════════════════════════ // Cal.com Scheduling Integration (Self-Hosted) // ═══════════════════════════════════════════════════════════════════ /** * List available event types from a self-hosted cal.com instance. */ async listCalComEventTypes(workspaceId: string, userId: string): Promise<CalComEventType[]> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "calcom"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Cal.com API key not available"); return this.selfHostedClients.listEventTypes(); } /** * Create a booking on a self-hosted cal.com instance. */ async createCalComBooking( workspaceId: string, userId: string, data: { eventTypeId: number; start: string; attendee: { email: string; name: string; timeZone: string }; meetingUrl?: string; }, ): Promise<CalComBooking> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "calcom"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Cal.com API key not available"); return this.selfHostedClients.createBooking(data.eventTypeId, { start: data.start, attendee: data.attendee, meetingUrl: data.meetingUrl, }); } // ═══════════════════════════════════════════════════════════════════ // Chatwoot Customer Support Integration (Self-Hosted) // ═══════════════════════════════════════════════════════════════════ /** * List conversations from a self-hosted Chatwoot instance. */ async getChatwootConversations(workspaceId: string, userId: string): Promise<ChatwootConversation[]> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "chatwoot"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Chatwoot API key not available"); const base = await this.selfHostedClients.getChatwootUrl(); const account = await this.selfHostedClients.chatwootAccountId(); return this.selfHostedClients.fetchWithRetry<{ payload: ChatwootConversation[]; meta: Record<string, any> }>( "chatwoot", "listConversations", `${base}/api/v1/accounts/${account}/conversations`, { headers: this.selfHostedClients.chatwootHeaders(apiKey), signal: AbortSignal.timeout(10000) }, ).then((body) => body.payload ?? []); } /** * Get messages for a specific Chatwoot conversation. */ async getChatwootMessages( workspaceId: string, userId: string, conversationId: number, ): Promise<ChatwootMessage[]> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "chatwoot"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Chatwoot API key not available"); const base = await this.selfHostedClients.getChatwootUrl(); const account = await this.selfHostedClients.chatwootAccountId(); return this.selfHostedClients.fetchWithRetry<{ payload: ChatwootMessage[]; meta: Record<string, any> }>( "chatwoot", "getMessages", `${base}/api/v1/accounts/${account}/conversations/${conversationId}/messages`, { headers: this.selfHostedClients.chatwootHeaders(apiKey), signal: AbortSignal.timeout(10000) }, ).then((body) => body.payload ?? []); } /** * Send a message in a Chatwoot conversation. */ async sendChatwootMessage( workspaceId: string, userId: string, data: { conversationId: number; content: string; }, ): Promise<ChatwootMessage> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "chatwoot"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Chatwoot API key not available"); return this.selfHostedClients.sendChatwootMessage( apiKey, data.conversationId, data.content, ); } /** * Create a new Chatwoot conversation (and contact if needed). */ async createChatwootConversation( workspaceId: string, userId: string, data: { inboxId: number; contactName: string; contactEmail: string; contactPhone?: string; message: string; customAttributes?: Record<string, any>; }, ): Promise<{ conversation: ChatwootConversation; contact: ChatwootContact }> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "chatwoot"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Chatwoot API key not available"); let contact: ChatwootContact; try { contact = await this.selfHostedClients.createChatwootContact( apiKey, data.contactName, data.contactEmail, data.contactPhone, data.customAttributes, ); } catch { contact = { id: 0, name: data.contactName, email: data.contactEmail }; } const conversation = await this.selfHostedClients.createChatwootConversation( apiKey, data.inboxId, contact.id, data.message, ); return { conversation, contact }; } /** * Send a transactional email via Listmonk. */ async sendListmonkEmail(workspaceId: string, data: { to: string; subject: string; body: string; type?: "html" | "plain"; }): Promise<void> { const integration = await this.getValidatedIntegration(workspaceId, "listmonk"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Listmonk API key not available"); return this.selfHostedClients.sendTransactional(apiKey, data); } /** * List mailing lists from a self-hosted listmonk instance. */ async listListmonkLists(workspaceId: string): Promise<ListmonkList[]> { const integration = await this.getValidatedIntegration(workspaceId, "listmonk"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Listmonk API key not available"); return this.selfHostedClients.listLists(apiKey); } /** * Create a mailing list in a self-hosted listmonk instance. */ async createListmonkList(workspaceId: string, data: { name: string; type?: "public" | "private" | "optin" }): Promise<ListmonkList> { const integration = await this.getValidatedIntegration(workspaceId, "listmonk"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Listmonk API key not available"); return this.selfHostedClients.createList(apiKey, data.name, data.type ?? "public"); } /** * Subscribe a contact to a listmonk mailing list. */ async subscribeListmonkSubscriber(workspaceId: string, data: { listId: number; email: string; name?: string; attrs?: Record<string, any> }): Promise<ListmonkSubscriber> { const integration = await this.getValidatedIntegration(workspaceId, "listmonk"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Listmonk API key not available"); return this.selfHostedClients.subscribe(apiKey, data.listId, data.email, data.name, data.attrs); } // ═══════════════════════════════════════════════════════════════════════ // Pretix Ticketing Integration (Self-Hosted) // ═══════════════════════════════════════════════════════════════════════ /** * List events from a self-hosted pretix instance. */ async listPretixEvents(workspaceId: string, userId: string, organizer: string): Promise<PretixEvent[]> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "pretix"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Pretix API key not available"); return this.selfHostedClients.listPretixEvents(apiKey, organizer); } /** * List ticket items for a pretix event. */ async listPretixItems(workspaceId: string, userId: string, organizer: string, event: string): Promise<PretixItem[]> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "pretix"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Pretix API key not available"); return this.selfHostedClients.listPretixItems(apiKey, organizer, event); } /** * List orders for a pretix event. */ async listPretixOrders(workspaceId: string, userId: string, organizer: string, event: string): Promise<PretixOrder[]> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "pretix"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Pretix API key not available"); return this.selfHostedClients.listPretixOrders(apiKey, organizer, event); } /** * List order positions (tickets) for a pretix order. */ async listPretixOrderPositions(workspaceId: string, userId: string, organizer: string, event: string, order: string): Promise<PretixOrderPosition[]> { await this.ensureWorkspaceAccess(workspaceId, userId); const integration = await this.getValidatedIntegration(workspaceId, "pretix"); const apiKey = integration.accessToken; if (!apiKey) throw new BadRequestException("Pretix API key not available"); return this.selfHostedClients.listPretixOrderPositions(apiKey, organizer, event, order); } private isProviderConfigured(provider: IntegrationProviderName): boolean { const config = this.oauthConfigs[provider]; const selfHosted = this.selfHostedConfigs[provider]; if (selfHosted) { const apiKey = this.configService.get<string>(selfHosted.apiKeyEnv, ""); return !!apiKey; } if (!config) return false; if (provider === "cloudflare_r2") { return !!(config.clientId && config.clientSecret); } return !!(config.clientId && config.clientSecret); } // ═══ Private: Permissions ═══════════════════════════ private async ensureWorkspaceAccess(workspaceId: string, userId: string) { const membership = await this.prisma.workspaceMember.findUnique({ where: { userId_workspaceId: { userId, workspaceId } }, }); if (!membership) { throw new NotFoundException("Workspace not found or access denied"); } return membership; } private async ensureWorkspacePermission(workspaceId: string, userId: string) { const membership = await this.ensureWorkspaceAccess(workspaceId, userId); const role = membership.role as WorkspaceRole; if (![WorkspaceRole.OWNER, WorkspaceRole.ADMIN].includes(role)) { throw new ConflictException("Only workspace owners and admins can manage integrations"); } return membership; } }
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { randomBytes } from "crypto";
import type { RedisClientType } from "redis";
import { PrismaService } from "../prisma/prisma.service";
import { WorkspaceRole, CredentialSource } from "@micprotocol/shared-types";
import { SubscriptionCredentialsService } from "../billing/subscription-credentials.service";
import { SelfHostedClientsService, RiversideRoom, ZoomMeeting, DocumensoDocument, CalComEventType, CalComBooking, ChatwootConversation, ChatwootMessage, ChatwootContact, PretixEvent, PretixItem, PretixOrder, PretixOrderPosition, ListmonkList, ListmonkSubscriber } from "./clients/self-hosted-clients.service";
import { REDIS_CLIENT } from "../common/redis.constants";
import type { IntegrationProvider } from "@prisma/client";
export type IntegrationProviderName =
| "google_calendar"
| "google_mail"
| "outlook"
| "riverside"
| "zoom"
| "cloudflare_r2"
| "deepgram"
| "nvidia_nim"
| "deepseek"
| "moonshot"
// Self-hosted open-source integration providers
| "calcom"
| "chatwoot"
| "documenso"
| "formbricks"
| "listmonk"
| "pretix";
export type IntegrationStatusValue = "connected" | "disconnected" | "expired" | "error";
interface OAuthConfig {
clientId: string;
clientSecret: string;
authUrl: string;
tokenUrl: string;
scopes: string[];
redirectUri: string;
}
interface SelfHostedConfig {
baseUrl: string;
apiKeyEnv: string;
webhookSecretEnv: string;
healthEndpoint: string;
}
interface CalendarEventData {
title: string;
description?: string;
startTime: string;
endTime: string;
timezone?: string;
location?: string;
meetLink?: string;
attendees?: Array<{ email: string; name?: string }>;
}
@Injectable()
export class IntegrationsService {
private readonly logger = new Logger(IntegrationsService.name);
private readonly oauthConfigs: Record<string, OAuthConfig>;
private readonly selfHostedConfigs: Record<string, SelfHostedConfig>;
// OAuth `state` nonce β†’ pending flow (fallback store for single-instance
// deployments when Redis is unavailable). Primary store is Redis so the
// callback can land on any instance.
private readonly oauthStateCache = new Map<
string,
{ workspaceId: string; provider: string; userId: string; expiresAt: number }
>();
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly subscriptionCredentialsService: SubscriptionCredentialsService,
private readonly selfHostedClients: SelfHostedClientsService,
@Inject(REDIS_CLIENT) private readonly redis: RedisClientType,
) {
const baseRedirect = this.configService.get<string>("API_URL", "http://localhost:4000");
this.oauthConfigs = {
google_calendar: {
clientId: this.configService.get<string>("GOOGLE_CLIENT_ID", ""),
clientSecret: this.configService.get<string>("GOOGLE_CLIENT_SECRET", ""),
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
scopes: [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
],
redirectUri: `${baseRedirect}/api/integrations/google/callback`,
},