Spaces:
Running
Running
Download apps/api/src/integrations/integrations.service.ts from Benaah/MicProtocol: direct link, hf CLI and curl.
- Browser
- Download file 77.2 kB
-
https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/integrations/integrations.service.ts
- Command line
-
hf download hf://spaces/Benaah/MicProtocol/apps/api/src/integrations/integrations.service.ts
-
curl -L -o integrations.service.ts https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/integrations/integrations.service.ts
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 }>;
}
()
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,
(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`,
},