465 lines
17 KiB
TypeScript
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;
|
|
}
|