Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/telephony/services/AircallConnectionService.server.ts
T

465 lines
17 KiB
TypeScript

import { createHash } from "node:crypto";
import { loadRequiredWebConfig } from "@hivegrid/config";
import { getRlsExecutionContext } from "@hivegrid/db/rls-context.server";
import {
AIRCALL_CALL_WEBHOOK_EVENTS,
buildAircallAuthorizationUrl,
createAircallWebhook,
deleteAircallWebhook,
exchangeAircallCodeForToken,
listAircallUsers,
pingAircall,
resolveAircallOAuthConfig,
type AircallOAuthConfig,
type TelephonyProviderUser,
} from "@hivegrid/integrations";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { reconnectConnection, runWithConnectionCredentials } from "~/utils/integrations/appsEngineService.server";
import { signAircallOAuthState, verifyAircallOAuthState, type AircallOAuthStatePayload } from "./aircallOAuthState.server";
const AIRCALL_APP_ID = "aircall";
const AIRCALL_CONNECTION_LABEL = "Aircall";
type AircallConnectionStatus = "pending" | "active" | "degraded" | "broken" | "revoked";
type AircallSlot = {
readonly telephonyConnectionId: string;
readonly dataConnectionId: string;
readonly connectionStatus: AircallConnectionStatus;
readonly webhookId: string | null;
};
type AircallRuntimeConfig = {
readonly oauth: AircallOAuthConfig;
readonly webhookUrl: string;
readonly webhookName: string;
};
type PreviousCredentials = {
readonly accessToken: string;
};
export class AircallConnectError extends Error {
readonly code:
| "not_configured"
| "invalid_state"
| "provider_conflict"
| "slot_conflict"
| "exchange_failed"
| "webhook_failed"
| "persistence_failed";
constructor(code: AircallConnectError["code"]) {
super(code);
this.name = "AircallConnectError";
this.code = code;
}
}
export type AircallConnectResult = {
readonly tenantId: string;
readonly tenantSlug: string;
readonly dataConnectionId: string;
readonly telephonyConnectionId: string;
readonly providerUserCount: number;
readonly autoBoundUserCount: number;
};
export function aircallOAuthConfigured(): boolean {
try {
resolveRuntimeConfig();
return true;
} catch {
return false;
}
}
/** Reserve/reuse the tenant's only Aircall slot and build a fresh install URL. */
export async function buildAircallInstallUrl(input: { tenantId: string; tenantSlug: string; userId: string }): Promise<string> {
const config = resolveRuntimeConfig();
const slot = await ensureAircallSlot(input.tenantId, input.userId);
const state = signAircallOAuthState({
tenantId: input.tenantId,
tenantSlug: input.tenantSlug,
userId: input.userId,
connectionId: slot.dataConnectionId,
});
return buildAircallAuthorizationUrl(config.oauth, state);
}
/**
* Complete a fresh install or reinstall. Aircall has no refresh/revoke-token
* endpoint: reinstallation replaces the permanent token and its webhook.
*/
export async function completeAircallOAuthCallback(input: { code: string; state: string }): Promise<AircallConnectResult> {
const verified = verifyAircallOAuthState(input.state);
if (!verified.ok) throw new AircallConnectError("invalid_state");
const payload = verified.payload;
const rlsContext = getRlsExecutionContext();
if (rlsContext?.tenantId !== payload.tenantId || rlsContext.userId !== payload.userId) {
throw new AircallConnectError("invalid_state");
}
const config = resolveRuntimeConfig();
const slot = await readAircallSlot(payload.tenantId, payload.connectionId);
if (!slot) throw new AircallConnectError("invalid_state");
if (slot.connectionStatus === "active" || slot.connectionStatus === "degraded") {
let callbackStarted = false;
try {
return await runWithConnectionCredentials(
{
tenantId: payload.tenantId,
connectionId: slot.dataConnectionId,
expectedAppId: AIRCALL_APP_ID,
},
async (credentials) => {
callbackStarted = true;
return finishAircallInstall({ input, config, slot, payload, previousCredentials: parsePreviousCredentials(credentials) });
}
);
} catch (error) {
// An inconsistent legacy slot may have no decryptable credentials. That
// must not prevent a fresh OAuth install from repairing it. Errors after
// provider setup began are never retried here.
if (callbackStarted) throw error;
}
}
return finishAircallInstall({ input, config, slot, payload, previousCredentials: null });
}
async function finishAircallInstall(input: {
readonly input: { readonly code: string; readonly state: string };
readonly config: AircallRuntimeConfig;
readonly slot: AircallSlot;
readonly payload: AircallOAuthStatePayload;
readonly previousCredentials: PreviousCredentials | null;
}): Promise<AircallConnectResult> {
let accessToken: string;
let providerUsers: readonly TelephonyProviderUser[];
try {
const tokenSet = await exchangeAircallCodeForToken(input.config.oauth, input.input.code);
accessToken = tokenSet.accessToken;
await pingAircall({ accessToken });
// A successful company-wide User V2 read proves that the consent granted
// the public_api scope. Aircall itself restricts installation to company admins.
providerUsers = await listAircallUsers({ accessToken });
} catch {
throw new AircallConnectError("exchange_failed");
}
let webhook: Awaited<ReturnType<typeof createAircallWebhook>>;
try {
webhook = await createAircallWebhook(
{ accessToken },
{
name: input.config.webhookName,
url: input.config.webhookUrl,
events: AIRCALL_CALL_WEBHOOK_EVENTS,
}
);
} catch {
throw new AircallConnectError("webhook_failed");
}
const credentials = { accessToken, webhookToken: webhook.token } satisfies Record<string, unknown>;
try {
await reconnectConnection({
tenantId: input.payload.tenantId,
connectionId: input.slot.dataConnectionId,
expectedAppId: AIRCALL_APP_ID,
credentials,
actorUserId: input.payload.userId,
});
} catch {
await deleteAircallWebhook({ accessToken }, webhook.id).catch(() => undefined);
throw new AircallConnectError("persistence_failed");
}
const webhookTokenHash = hashWebhookToken(webhook.token);
let persisted: { readonly telephonyConnectionId: string; readonly autoBoundUserCount: number };
try {
persisted = await withTenantRLS(input.payload.tenantId, async (tx) => {
const connection = await tx.telephonyConnection.upsert({
where: { tenantId_provider: { tenantId: input.payload.tenantId, provider: "aircall" } },
create: {
tenantId: input.payload.tenantId,
dataConnectionId: input.slot.dataConnectionId,
provider: "aircall",
webhookId: webhook.id,
webhookTokenHash,
},
update: {
dataConnectionId: input.slot.dataConnectionId,
webhookId: webhook.id,
webhookTokenHash,
},
select: { id: true },
});
const autoBoundUserCount = await replaceAutomaticBindings({
tx,
tenantId: input.payload.tenantId,
connectionId: connection.id,
providerUsers,
});
return { telephonyConnectionId: connection.id, autoBoundUserCount };
});
} catch {
await Promise.all([
deleteAircallWebhook({ accessToken }, webhook.id).catch(() => undefined),
markConnectionBroken(input.payload.tenantId, input.slot.dataConnectionId),
]);
throw new AircallConnectError("persistence_failed");
}
if (input.previousCredentials && input.slot.webhookId && input.slot.webhookId !== webhook.id) {
try {
await deleteAircallWebhook({ accessToken: input.previousCredentials.accessToken }, input.slot.webhookId);
} catch {
// The new connection is already usable. Surface the stale provider
// webhook explicitly instead of reporting a clean reinstall while an
// unmanaged webhook keeps delivering events. A durable cleanup retry
// would require a dedicated provider-operation outbox.
await markConnectionDegraded(input.payload.tenantId, input.slot.dataConnectionId);
throw new AircallConnectError("webhook_failed");
}
}
return {
tenantId: input.payload.tenantId,
tenantSlug: input.payload.tenantSlug,
dataConnectionId: input.slot.dataConnectionId,
telephonyConnectionId: persisted.telephonyConnectionId,
providerUserCount: providerUsers.length,
autoBoundUserCount: persisted.autoBoundUserCount,
};
}
async function ensureAircallSlot(tenantId: string, actorUserId: string): Promise<AircallSlot> {
try {
return await withTenantRLS(tenantId, async (tx) => {
const [telephony, candidates, otherProviderSlots] = await Promise.all([
tx.telephonyConnection.findUnique({
where: { tenantId_provider: { tenantId, provider: "aircall" } },
select: { id: true, dataConnectionId: true, webhookId: true },
}),
tx.dataConnection.findMany({
where: { tenantId, appId: AIRCALL_APP_ID },
select: { id: true, connectionStatus: true },
orderBy: { createdAt: "asc" },
take: 2,
}),
tx.telephonyConnection.findMany({
where: { tenantId, provider: { not: "aircall" } },
select: { dataConnectionId: true },
}),
]);
if (otherProviderSlots.length > 0) {
const activeOtherProvider = await tx.dataConnection.findFirst({
where: {
tenantId,
id: { in: otherProviderSlots.map((slot) => slot.dataConnectionId) },
connectionStatus: { in: ["pending", "active", "degraded"] },
archivedAt: null,
},
select: { id: true },
});
if (activeOtherProvider) throw new AircallConnectError("provider_conflict");
}
if (candidates.length > 1 || (telephony && candidates.some((candidate) => candidate.id !== telephony.dataConnectionId))) {
throw new AircallConnectError("slot_conflict");
}
if (telephony) {
const dataConnection = candidates.find((candidate) => candidate.id === telephony.dataConnectionId);
if (!dataConnection) throw new AircallConnectError("slot_conflict");
return {
telephonyConnectionId: telephony.id,
dataConnectionId: telephony.dataConnectionId,
connectionStatus: dataConnection.connectionStatus,
webhookId: telephony.webhookId,
};
}
const dataConnection =
candidates[0] ??
(await tx.dataConnection.create({
data: {
tenantId,
appId: AIRCALL_APP_ID,
label: AIRCALL_CONNECTION_LABEL,
connectionStatus: "pending",
healthMessageKey: "integrations.health.notConnected",
createdByUserId: actorUserId,
},
select: { id: true, connectionStatus: true },
}));
const created = await tx.telephonyConnection.create({
data: { tenantId, provider: "aircall", dataConnectionId: dataConnection.id },
select: { id: true },
});
return {
telephonyConnectionId: created.id,
dataConnectionId: dataConnection.id,
connectionStatus: dataConnection.connectionStatus,
webhookId: null,
};
});
} catch (error) {
if (error instanceof AircallConnectError) throw error;
// A concurrent admin may have won the unique (tenant, provider) insert.
// Re-read the canonical slot; the losing DataConnection insert rolled back.
const concurrentSlot = await readAircallSlotForTenant(tenantId);
if (concurrentSlot) return concurrentSlot;
throw error;
}
}
async function readAircallSlot(tenantId: string, dataConnectionId: string): Promise<AircallSlot | null> {
const slot = await readAircallSlotForTenant(tenantId);
return slot?.dataConnectionId === dataConnectionId ? slot : null;
}
async function readAircallSlotForTenant(tenantId: string): Promise<AircallSlot | null> {
return withTenantRLS(tenantId, async (tx) => {
const telephony = await tx.telephonyConnection.findUnique({
where: { tenantId_provider: { tenantId, provider: "aircall" } },
select: { id: true, dataConnectionId: true, webhookId: true },
});
if (!telephony) return null;
const dataConnection = await tx.dataConnection.findFirst({
where: { id: telephony.dataConnectionId, tenantId, appId: AIRCALL_APP_ID },
select: { connectionStatus: true },
});
if (!dataConnection) return null;
return {
telephonyConnectionId: telephony.id,
dataConnectionId: telephony.dataConnectionId,
connectionStatus: dataConnection.connectionStatus,
webhookId: telephony.webhookId,
};
});
}
async function replaceAutomaticBindings(input: {
readonly tx: TenantRlsClient;
readonly tenantId: string;
readonly connectionId: string;
readonly providerUsers: readonly TelephonyProviderUser[];
}): Promise<number> {
const [memberships, manualBindings] = await Promise.all([
input.tx.tenantUser.findMany({
where: {
tenantId: input.tenantId,
status: TenantUserStatus.ACTIVE,
user: { active: true },
},
select: { id: true, user: { select: { email: true } } },
}),
input.tx.telephonyUserBinding.findMany({
where: { tenantId: input.tenantId, connectionId: input.connectionId, source: "manual" },
select: { tenantUserId: true, providerUserId: true },
}),
]);
const manualTenantUsers = new Set(manualBindings.map((binding) => binding.tenantUserId));
const manualProviderUsers = new Set(manualBindings.map((binding) => binding.providerUserId));
const tenantByEmail = uniqueByEmail(memberships.map((membership) => ({ email: membership.user.email, value: membership.id })));
const providerByEmail = uniqueByEmail(input.providerUsers.map((providerUser) => ({ email: providerUser.email, value: providerUser })));
const automaticBindings = [...providerByEmail.entries()]
.map(([email, providerUser]) => {
const tenantUserId = tenantByEmail.get(email);
if (!tenantUserId || manualTenantUsers.has(tenantUserId) || manualProviderUsers.has(providerUser.id)) return null;
return {
tenantId: input.tenantId,
connectionId: input.connectionId,
tenantUserId,
providerUserId: providerUser.id,
providerEmail: email,
providerDisplayName: providerUser.name,
defaultProviderNumberId: providerUser.defaultNumberId,
source: "email_auto" as const,
};
})
.filter(isPresent);
await input.tx.telephonyUserBinding.deleteMany({
where: { tenantId: input.tenantId, connectionId: input.connectionId, source: "email_auto" },
});
if (automaticBindings.length > 0) {
await input.tx.telephonyUserBinding.createMany({ data: automaticBindings, skipDuplicates: true });
}
return automaticBindings.length;
}
function uniqueByEmail<T>(rows: readonly { readonly email: string; readonly value: T }[]): Map<string, T> {
const values = new Map<string, T>();
const duplicates = new Set<string>();
for (const row of rows) {
const email = row.email.trim().toLowerCase();
if (!email || duplicates.has(email)) continue;
if (values.has(email)) {
values.delete(email);
duplicates.add(email);
} else {
values.set(email, row.value);
}
}
return values;
}
function parsePreviousCredentials(credentials: Readonly<Record<string, unknown>>): PreviousCredentials | null {
const accessToken = typeof credentials.accessToken === "string" ? credentials.accessToken.trim() : "";
return accessToken ? { accessToken } : null;
}
function resolveRuntimeConfig(): AircallRuntimeConfig {
const webConfig = loadRequiredWebConfig();
const aircall = webConfig.aircall;
const oauth = resolveAircallOAuthConfig({
AIRCALL_CLIENT_ID: aircall?.clientId,
AIRCALL_CLIENT_SECRET: aircall?.clientSecret,
AIRCALL_REDIRECT_URI: aircall?.redirectUri,
});
const webhookUrl = aircall?.webhookUrl?.trim();
if (!oauth || !webhookUrl) throw new AircallConnectError("not_configured");
return { oauth, webhookUrl, webhookName: webConfig.appName };
}
function hashWebhookToken(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}
async function markConnectionBroken(tenantId: string, dataConnectionId: string): Promise<void> {
await withTenantRLS(tenantId, (tx) =>
tx.dataConnection.updateMany({
where: { id: dataConnectionId, tenantId, appId: AIRCALL_APP_ID },
data: { connectionStatus: "broken", healthMessageKey: "telephony.health.unknown", lastHealthCheck: new Date() },
})
).catch(() => undefined);
}
async function markConnectionDegraded(tenantId: string, dataConnectionId: string): Promise<void> {
await withTenantRLS(tenantId, (tx) =>
tx.dataConnection.updateMany({
where: { id: dataConnectionId, tenantId, appId: AIRCALL_APP_ID },
data: {
connectionStatus: "degraded",
healthMessageKey: "telephony.health.staleWebhookCleanupFailed",
lastHealthCheck: new Date(),
},
})
).catch(() => undefined);
}
function isPresent<T>(value: T | null): value is T {
return value !== null;
}