1045 lines
39 KiB
TypeScript
1045 lines
39 KiB
TypeScript
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
|
|
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
|
|
import { AircallError, aircallTelephonyProvider, type AircallCredentials, type TelephonyProviderUser } from "@hivegrid/integrations";
|
|
import {
|
|
buildClickToCallVM,
|
|
buildTelephonyCallDetailVM,
|
|
buildTelephonyCallListVM,
|
|
buildTelephonySettingsVM,
|
|
type ClickToCallNumber,
|
|
type ClickToCallVM,
|
|
type TelephonyCallDetailVM,
|
|
type TelephonyCallListRow,
|
|
type TelephonyCallListVM,
|
|
type TelephonyConnectionStatus,
|
|
type TelephonyMatchConfidence,
|
|
type TelephonyMatchedEntityType,
|
|
type TelephonyProvider,
|
|
type TelephonySettingsVM,
|
|
} from "@hivegrid/view-models";
|
|
|
|
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
|
|
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
|
|
import {
|
|
AppsEngineServiceError,
|
|
checkConnectionHealth,
|
|
disconnectConnection,
|
|
runWithConnectionCredentials,
|
|
} from "~/utils/integrations/appsEngineService.server";
|
|
|
|
import {
|
|
canReadAllTelephonyCalls,
|
|
requireTelephonyPermission,
|
|
telephonyHasPermission,
|
|
TELEPHONY_PERMISSIONS,
|
|
type TelephonyRouteContext,
|
|
} from "../routes/telephonyAccess.server";
|
|
import { AIRCALL_OAUTH_INSTALL_PATH, telephonyCallDetailPath, telephonyCallsPath, telephonySettingsPath } from "../routes/telephonyRoutePaths";
|
|
import { aircallOAuthConfigured } from "./AircallConnectionService.server";
|
|
import { halloPetraWebhookConfigured } from "./HalloPetraConnectionService.server";
|
|
|
|
const AIRCALL_APP_ID = "aircall";
|
|
const HALLOPETRA_APP_ID = "hallopetra";
|
|
const MAX_NOTE_LENGTH = 20_000;
|
|
const MAX_CALL_ROWS = 100;
|
|
const MAX_MATCH_OPTIONS_PER_TYPE = 100;
|
|
const E164_PATTERN = /^\+[1-9]\d{6,14}$/;
|
|
|
|
type MatchedEntity = {
|
|
readonly type: TelephonyMatchedEntityType;
|
|
readonly id: string;
|
|
readonly label: string;
|
|
};
|
|
|
|
type CallLabelMaps = {
|
|
readonly users: ReadonlyMap<string, string>;
|
|
readonly contacts: ReadonlyMap<string, string>;
|
|
readonly leads: ReadonlyMap<string, string>;
|
|
readonly projects: ReadonlyMap<string, string>;
|
|
};
|
|
|
|
export class TelephonyServiceError extends Error {
|
|
readonly messageKey: string;
|
|
|
|
constructor(messageKey: string) {
|
|
super(messageKey);
|
|
this.name = "TelephonyServiceError";
|
|
this.messageKey = messageKey;
|
|
}
|
|
}
|
|
|
|
export async function loadTelephonySettings(ctx: TelephonyRouteContext, tenantSlug: string): Promise<TelephonySettingsVM> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.manage);
|
|
|
|
const snapshot = await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
const [connections, memberships] = await Promise.all([
|
|
tx.telephonyConnection.findMany({
|
|
where: { tenantId: ctx.tenantId },
|
|
include: { userBindings: true },
|
|
orderBy: { updatedAt: "desc" },
|
|
}),
|
|
tx.tenantUser.findMany({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
status: TenantUserStatus.ACTIVE,
|
|
user: { active: true },
|
|
},
|
|
select: {
|
|
id: true,
|
|
user: { select: { email: true, firstName: true, lastName: true } },
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
}),
|
|
]);
|
|
const dataConnections = connections.length
|
|
? await tx.dataConnection.findMany({
|
|
where: {
|
|
id: { in: connections.map((connection) => connection.dataConnectionId) },
|
|
tenantId: ctx.tenantId,
|
|
archivedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
appId: true,
|
|
label: true,
|
|
connectionStatus: true,
|
|
lastHealthCheck: true,
|
|
healthMessageKey: true,
|
|
},
|
|
})
|
|
: [];
|
|
const dataById = new Map(dataConnections.map((connection) => [connection.id, connection]));
|
|
const connection = connections.find((candidate) => {
|
|
const dataConnection = dataById.get(candidate.dataConnectionId);
|
|
return (
|
|
dataConnection?.appId === appIdForProvider(candidate.provider) &&
|
|
(dataConnection.connectionStatus === "active" || dataConnection.connectionStatus === "degraded")
|
|
);
|
|
}) ?? connections.find((candidate) => dataById.get(candidate.dataConnectionId)?.appId === appIdForProvider(candidate.provider)) ?? null;
|
|
return { connection, dataConnection: connection ? (dataById.get(connection.dataConnectionId) ?? null) : null, memberships };
|
|
});
|
|
|
|
let providerUsers: readonly TelephonyProviderUser[] = [];
|
|
if (
|
|
snapshot.connection &&
|
|
snapshot.dataConnection &&
|
|
(snapshot.dataConnection.connectionStatus === "active" || snapshot.dataConnection.connectionStatus === "degraded")
|
|
) {
|
|
if (snapshot.connection.provider === "aircall") {
|
|
providerUsers = await runWithAircallCredentials(ctx.tenantId, snapshot.dataConnection.id, (credentials) =>
|
|
aircallTelephonyProvider.listUsers(credentials)
|
|
).catch(() => []);
|
|
}
|
|
}
|
|
|
|
const membershipLabels = new Map(snapshot.memberships.map((membership) => [membership.id, userLabel(membership.user)]));
|
|
const providerById = new Map(providerUsers.map((providerUser) => [providerUser.id, providerUser]));
|
|
for (const binding of snapshot.connection?.userBindings ?? []) {
|
|
if (!providerById.has(binding.providerUserId)) {
|
|
providerById.set(binding.providerUserId, {
|
|
id: binding.providerUserId,
|
|
name: binding.providerDisplayName ?? binding.providerEmail ?? binding.providerUserId,
|
|
email: binding.providerEmail ?? "",
|
|
available: false,
|
|
availabilityStatus: null,
|
|
defaultNumberId: binding.defaultProviderNumberId,
|
|
});
|
|
}
|
|
}
|
|
const bindingByProviderId = new Map((snapshot.connection?.userBindings ?? []).map((binding) => [binding.providerUserId, binding]));
|
|
|
|
const bindings = [...providerById.values()]
|
|
.map((providerUser) => {
|
|
const binding = bindingByProviderId.get(providerUser.id);
|
|
return {
|
|
// Provider ids also address users that do not have a persisted binding yet.
|
|
id: providerUser.id,
|
|
providerUserId: providerUser.id,
|
|
providerName: providerUser.name,
|
|
providerEmail: validEmailOrNull(providerUser.email),
|
|
hivegridUserId: binding?.tenantUserId ?? null,
|
|
hivegridUserLabel: binding ? (membershipLabels.get(binding.tenantUserId) ?? null) : null,
|
|
match: binding ? (binding.source === "manual" ? ("manual" as const) : ("email" as const)) : ("unmatched" as const),
|
|
};
|
|
})
|
|
.sort((left, right) => left.providerName.localeCompare(right.providerName));
|
|
|
|
const connection =
|
|
snapshot.connection && snapshot.dataConnection
|
|
? {
|
|
id: snapshot.dataConnection.id,
|
|
provider: snapshot.connection.provider,
|
|
label: snapshot.dataConnection.label,
|
|
externalCompanyName: snapshot.connection.providerAccountName,
|
|
status: toSettingsConnectionStatus(snapshot.dataConnection.connectionStatus),
|
|
lastHealthCheckAt: snapshot.dataConnection.lastHealthCheck?.toISOString() ?? null,
|
|
errorKey:
|
|
snapshot.dataConnection.connectionStatus === "broken" || snapshot.dataConnection.connectionStatus === "degraded"
|
|
? snapshot.dataConnection.healthMessageKey
|
|
: null,
|
|
bindings,
|
|
}
|
|
: null;
|
|
|
|
return buildTelephonySettingsVM({
|
|
connection,
|
|
canManage: true,
|
|
providerConfigured: aircallOAuthConfigured() || halloPetraWebhookConfigured(),
|
|
connectHref: `${AIRCALL_OAUTH_INSTALL_PATH}?tenant=${encodeURIComponent(tenantSlug)}`,
|
|
providerOptions: [
|
|
{
|
|
provider: "aircall",
|
|
labelKey: "telephony.provider.aircall",
|
|
connectionType: "oauth2",
|
|
configured: aircallOAuthConfigured(),
|
|
connectHref: `${AIRCALL_OAUTH_INSTALL_PATH}?tenant=${encodeURIComponent(tenantSlug)}`,
|
|
},
|
|
{
|
|
provider: "hallopetra",
|
|
labelKey: "telephony.provider.hallopetra",
|
|
connectionType: "api_key",
|
|
configured: halloPetraWebhookConfigured(),
|
|
connectHref: telephonySettingsPath(tenantSlug),
|
|
},
|
|
],
|
|
userOptions: snapshot.memberships.map((membership) => ({
|
|
id: membership.id,
|
|
label: userLabel(membership.user),
|
|
email: membership.user.email,
|
|
})),
|
|
});
|
|
}
|
|
|
|
export async function checkTelephonyConnection(ctx: TelephonyRouteContext, tenantSlug: string, dataConnectionId: string): Promise<void> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.manage);
|
|
await requireTelephonyConnection(ctx.tenantId, dataConnectionId);
|
|
await checkConnectionHealth({
|
|
tenantId: ctx.tenantId,
|
|
connectionId: dataConnectionId,
|
|
notificationDeeplink: telephonySettingsPath(tenantSlug),
|
|
});
|
|
}
|
|
|
|
export async function disconnectTelephonyConnection(ctx: TelephonyRouteContext, dataConnectionId: string): Promise<void> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.manage);
|
|
const connection = await requireTelephonyConnection(ctx.tenantId, dataConnectionId);
|
|
|
|
if (connection.provider === "aircall" && connection.webhookId) {
|
|
try {
|
|
await runWithAircallCredentials(ctx.tenantId, dataConnectionId, (credentials) =>
|
|
aircallTelephonyProvider.deleteWebhook(credentials, connection.webhookId!)
|
|
);
|
|
} catch {
|
|
// Keep encrypted credentials and the local webhook lookup hash intact.
|
|
// Otherwise Aircall would continue delivering to an endpoint that can no
|
|
// longer authenticate or remove the registered provider webhook.
|
|
throw new TelephonyServiceError("telephony.settings.disconnect.providerError");
|
|
}
|
|
}
|
|
|
|
await disconnectConnection({
|
|
tenantId: ctx.tenantId,
|
|
connectionId: dataConnectionId,
|
|
expectedAppId: appIdForProvider(connection.provider),
|
|
actorUserId: ctx.userId,
|
|
});
|
|
await withTenantRLS(ctx.tenantId, (tx) =>
|
|
tx.telephonyConnection.updateMany({
|
|
where: { id: connection.id, tenantId: ctx.tenantId },
|
|
data: { webhookId: null, webhookTokenHash: null },
|
|
})
|
|
);
|
|
}
|
|
|
|
export async function saveTelephonyUserBinding(
|
|
ctx: TelephonyRouteContext,
|
|
input: {
|
|
readonly dataConnectionId: string;
|
|
readonly providerUserId: string;
|
|
readonly tenantUserId: string | null;
|
|
}
|
|
): Promise<void> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.manage);
|
|
const providerUserId = input.providerUserId.trim();
|
|
if (!providerUserId || providerUserId.length > 128) {
|
|
throw new TelephonyServiceError("telephony.errors.invalidRequest");
|
|
}
|
|
const connection = await requireTelephonyConnection(ctx.tenantId, input.dataConnectionId);
|
|
if (connection.provider !== "aircall") {
|
|
throw new TelephonyServiceError("telephony.settings.bindings.notSupported");
|
|
}
|
|
|
|
if (!input.tenantUserId) {
|
|
await withTenantRLS(ctx.tenantId, (tx) =>
|
|
tx.telephonyUserBinding.deleteMany({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
connectionId: connection.id,
|
|
providerUserId,
|
|
},
|
|
})
|
|
);
|
|
await auditTelephony(ctx, "TELEPHONY_BINDING_REMOVED", "TelephonyConnection", connection.id, {
|
|
providerUserId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const providerUsers = await runWithAircallCredentials(ctx.tenantId, input.dataConnectionId, (credentials) =>
|
|
aircallTelephonyProvider.listUsers(credentials)
|
|
).catch((error: unknown) => {
|
|
throw mapProviderError(error);
|
|
});
|
|
const providerUser = providerUsers.find((candidate) => candidate.id === providerUserId);
|
|
if (!providerUser) {
|
|
throw new CatalogError(foundationErrors.notFound, { providerUserId });
|
|
}
|
|
|
|
await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
const membership = await tx.tenantUser.findFirst({
|
|
where: {
|
|
id: input.tenantUserId!,
|
|
tenantId: ctx.tenantId,
|
|
status: TenantUserStatus.ACTIVE,
|
|
user: { active: true },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (!membership) {
|
|
throw new CatalogError(foundationErrors.notFound, { tenantUserId: input.tenantUserId! });
|
|
}
|
|
|
|
// Both sides are unique per connection. Remove a previous assignment on
|
|
// either side before creating the explicit manual mapping.
|
|
await tx.telephonyUserBinding.deleteMany({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
connectionId: connection.id,
|
|
OR: [{ providerUserId }, { tenantUserId: membership.id }],
|
|
},
|
|
});
|
|
await tx.telephonyUserBinding.create({
|
|
data: {
|
|
tenantId: ctx.tenantId,
|
|
connectionId: connection.id,
|
|
tenantUserId: membership.id,
|
|
providerUserId,
|
|
providerEmail: providerUser.email,
|
|
providerDisplayName: providerUser.name,
|
|
defaultProviderNumberId: providerUser.defaultNumberId,
|
|
source: "manual",
|
|
},
|
|
});
|
|
});
|
|
await auditTelephony(ctx, "TELEPHONY_BINDING_SAVED", "TelephonyConnection", connection.id, {
|
|
providerUserId,
|
|
tenantUserId: input.tenantUserId,
|
|
});
|
|
}
|
|
|
|
export async function loadTelephonyCalls(ctx: TelephonyRouteContext, tenantSlug: string): Promise<TelephonyCallListVM> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.readOwn);
|
|
const canReadAll = canReadAllTelephonyCalls(ctx);
|
|
const { calls, labels } = await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
const calls = await tx.telephonyCallSession.findMany({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
archivedAt: null,
|
|
...(canReadAll ? {} : { tenantUserId: ctx.tenantUserId }),
|
|
},
|
|
orderBy: { startedAt: "desc" },
|
|
take: MAX_CALL_ROWS,
|
|
});
|
|
return { calls, labels: await resolveCallLabelMaps(tx, ctx.tenantId, calls) };
|
|
});
|
|
|
|
const rows: TelephonyCallListRow[] = calls.map((call) => {
|
|
const matched = matchedEntityForCall(call, labels);
|
|
return {
|
|
id: call.id,
|
|
direction: call.direction,
|
|
status: call.status,
|
|
phoneNumber: counterpartNumber(call),
|
|
counterpartLabel: matched?.label ?? null,
|
|
matchedEntityType: matched?.type ?? null,
|
|
matchedEntityId: matched?.id ?? null,
|
|
matchedEntityLabel: matched?.label ?? null,
|
|
matchConfidence: toViewMatchConfidence(call.matchConfidence),
|
|
boundUserId: call.tenantUserId,
|
|
boundUserLabel: call.tenantUserId ? (labels.users.get(call.tenantUserId) ?? null) : null,
|
|
startedAt: call.startedAt.toISOString(),
|
|
durationSeconds: call.durationSeconds,
|
|
noteSnippet: noteSnippet(call.noteMarkdown),
|
|
hasRecording: Boolean(call.recordingUrl),
|
|
detailHref: telephonyCallDetailPath(tenantSlug, call.id),
|
|
};
|
|
});
|
|
return buildTelephonyCallListVM({ rows, canReadAll });
|
|
}
|
|
|
|
export async function loadTelephonyCallDetail(ctx: TelephonyRouteContext, tenantSlug: string, callId: string): Promise<TelephonyCallDetailVM> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.readOwn);
|
|
const canReadAll = canReadAllTelephonyCalls(ctx);
|
|
const canOverrideMatch = telephonyHasPermission(ctx, TELEPHONY_PERMISSIONS.manage);
|
|
const result = await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
const call = await tx.telephonyCallSession.findFirst({
|
|
where: {
|
|
id: callId,
|
|
tenantId: ctx.tenantId,
|
|
archivedAt: null,
|
|
...(canReadAll ? {} : { tenantUserId: ctx.tenantUserId }),
|
|
},
|
|
include: { connection: { select: { provider: true } } },
|
|
});
|
|
if (!call) return null;
|
|
|
|
const labels = await resolveCallLabelMaps(tx, ctx.tenantId, [call]);
|
|
const matchOptions = canOverrideMatch ? await loadMatchOptions(tx, ctx.tenantId) : [];
|
|
return { call, labels, matchOptions };
|
|
});
|
|
if (!result) {
|
|
throw new CatalogError(foundationErrors.notFound, { callId });
|
|
}
|
|
|
|
const matched = matchedEntityForCall(result.call, result.labels);
|
|
const canCall = telephonyHasPermission(ctx, TELEPHONY_PERMISSIONS.call);
|
|
return buildTelephonyCallDetailVM({
|
|
id: result.call.id,
|
|
provider: result.call.connection.provider,
|
|
direction: result.call.direction,
|
|
status: result.call.status,
|
|
fromNumber: result.call.fromNumber,
|
|
toNumber: result.call.toNumber,
|
|
startedAt: result.call.startedAt.toISOString(),
|
|
answeredAt: result.call.answeredAt?.toISOString() ?? null,
|
|
endedAt: result.call.endedAt?.toISOString() ?? null,
|
|
durationSeconds: result.call.durationSeconds,
|
|
boundUserId: result.call.tenantUserId,
|
|
boundUserLabel: result.call.tenantUserId ? (result.labels.users.get(result.call.tenantUserId) ?? null) : null,
|
|
matchedEntityType: matched?.type ?? null,
|
|
matchedEntityId: matched?.id ?? null,
|
|
matchedEntityLabel: matched?.label ?? null,
|
|
matchConfidence: toViewMatchConfidence(result.call.matchConfidence),
|
|
noteMarkdown: result.call.noteMarkdown ?? "",
|
|
recordingUrl: safeHttpsUrl(result.call.recordingUrl),
|
|
matchOptions: result.matchOptions,
|
|
canEditNote: canCall,
|
|
canOverrideMatch,
|
|
canCallBack: canCall && E164_PATTERN.test(counterpartNumber(result.call)),
|
|
listHref: telephonyCallsPath(tenantSlug),
|
|
});
|
|
}
|
|
|
|
export async function saveTelephonyCallNote(ctx: TelephonyRouteContext, input: { readonly callId: string; readonly noteMarkdown: string }): Promise<void> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.call);
|
|
if (input.noteMarkdown.length > MAX_NOTE_LENGTH) {
|
|
throw new TelephonyServiceError("telephony.errors.noteTooLong");
|
|
}
|
|
const canReadAll = canReadAllTelephonyCalls(ctx);
|
|
const updated = await withTenantRLS(ctx.tenantId, (tx) =>
|
|
tx.telephonyCallSession.updateMany({
|
|
where: {
|
|
id: input.callId,
|
|
tenantId: ctx.tenantId,
|
|
archivedAt: null,
|
|
...(canReadAll ? {} : { tenantUserId: ctx.tenantUserId }),
|
|
},
|
|
data: { noteMarkdown: input.noteMarkdown },
|
|
})
|
|
);
|
|
if (updated.count !== 1) {
|
|
throw new CatalogError(foundationErrors.notFound, { callId: input.callId });
|
|
}
|
|
await auditTelephony(ctx, "TELEPHONY_CALL_NOTE_SAVED", "TelephonyCallSession", input.callId, {
|
|
noteLength: input.noteMarkdown.length,
|
|
});
|
|
}
|
|
|
|
export async function overrideTelephonyCallMatch(
|
|
ctx: TelephonyRouteContext,
|
|
input: {
|
|
readonly callId: string;
|
|
readonly match: { readonly type: TelephonyMatchedEntityType; readonly id: string } | null;
|
|
}
|
|
): Promise<void> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.manage);
|
|
await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
const call = await tx.telephonyCallSession.findFirst({
|
|
where: { id: input.callId, tenantId: ctx.tenantId, archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (!call) {
|
|
throw new CatalogError(foundationErrors.notFound, { callId: input.callId });
|
|
}
|
|
if (input.match) {
|
|
await requireMatchedEntity(tx, ctx.tenantId, input.match);
|
|
}
|
|
await tx.telephonyCallSession.updateMany({
|
|
where: { id: input.callId, tenantId: ctx.tenantId, archivedAt: null },
|
|
data: {
|
|
matchedKontaktId: input.match?.type === "contact" ? input.match.id : null,
|
|
matchedLeadId: input.match?.type === "lead" ? input.match.id : null,
|
|
matchedProjektId: input.match?.type === "project" ? input.match.id : null,
|
|
matchConfidence: input.match ? "manual" : "none",
|
|
manualOverrideAt: new Date(),
|
|
manualOverrideUserId: ctx.userId,
|
|
},
|
|
});
|
|
});
|
|
await auditTelephony(ctx, "TELEPHONY_CALL_MATCH_OVERRIDDEN", "TelephonyCallSession", input.callId, {
|
|
matchType: input.match?.type ?? null,
|
|
matchId: input.match?.id ?? null,
|
|
});
|
|
}
|
|
|
|
export async function loadTelephonyClickToCallVM(ctx: TelephonyRouteContext, numbers: readonly ClickToCallNumber[]): Promise<ClickToCallVM> {
|
|
const normalizedNumbers = normalizeClickToCallNumbers(numbers);
|
|
const connectionState = ctx.hasModule
|
|
? await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
const dataConnection = await tx.dataConnection.findFirst({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
appId: { in: [AIRCALL_APP_ID, HALLOPETRA_APP_ID] },
|
|
archivedAt: null,
|
|
connectionStatus: { in: ["active", "degraded"] },
|
|
},
|
|
select: { id: true, appId: true },
|
|
});
|
|
if (!dataConnection) return { connectionActive: false, userBound: false, outboundSupported: false };
|
|
const provider = dataConnection.appId === HALLOPETRA_APP_ID ? "hallopetra" : "aircall";
|
|
const connection = await tx.telephonyConnection.findFirst({
|
|
where: { tenantId: ctx.tenantId, dataConnectionId: dataConnection.id, provider },
|
|
select: { id: true, dataConnectionId: true },
|
|
});
|
|
if (!connection) return { connectionActive: false, userBound: false, outboundSupported: false };
|
|
if (dataConnection.appId === HALLOPETRA_APP_ID) {
|
|
return { connectionActive: true, userBound: false, outboundSupported: false };
|
|
}
|
|
const binding = await tx.telephonyUserBinding.findFirst({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
connectionId: connection.id,
|
|
tenantUserId: ctx.tenantUserId,
|
|
},
|
|
select: { defaultProviderNumberId: true },
|
|
});
|
|
return {
|
|
connectionActive: dataConnection.appId === AIRCALL_APP_ID,
|
|
userBound: Boolean(binding?.defaultProviderNumberId),
|
|
outboundSupported: dataConnection.appId === AIRCALL_APP_ID,
|
|
};
|
|
})
|
|
: { connectionActive: false, userBound: false, outboundSupported: false };
|
|
|
|
return buildClickToCallVM({
|
|
numbers: normalizedNumbers,
|
|
hasModule: ctx.hasModule,
|
|
connectionActive: connectionState.connectionActive,
|
|
userBound: connectionState.userBound,
|
|
outboundSupported: connectionState.outboundSupported,
|
|
canCall: telephonyHasPermission(ctx, TELEPHONY_PERMISSIONS.call),
|
|
});
|
|
}
|
|
|
|
export async function loadProjectTelephonyClickToCallVM(
|
|
ctx: TelephonyRouteContext,
|
|
input: {
|
|
readonly projectId: string;
|
|
readonly contacts: readonly { readonly contactId: string; readonly label: string; readonly primary: boolean }[];
|
|
}
|
|
): Promise<ClickToCallVM> {
|
|
const contactIds = uniqueIds(input.contacts.map((contact) => contact.contactId));
|
|
const numbers =
|
|
contactIds.length === 0
|
|
? []
|
|
: await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
const links = await tx.projektKontakt.findMany({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
projektId: input.projectId,
|
|
kontaktId: { in: contactIds },
|
|
},
|
|
select: { kontaktId: true, primaer: true },
|
|
});
|
|
const linkedIds = uniqueIds(links.map((link) => link.kontaktId));
|
|
if (linkedIds.length === 0) return [];
|
|
const phones = await tx.kontaktTelefon.findMany({
|
|
where: { tenantId: ctx.tenantId, kontaktId: { in: linkedIds } },
|
|
select: { id: true, kontaktId: true, telefon: true, primaer: true },
|
|
orderBy: [{ primaer: "desc" }, { createdAt: "asc" }],
|
|
});
|
|
const contactById = new Map(input.contacts.map((contact) => [contact.contactId, contact]));
|
|
const primaryContacts = new Set(links.filter((link) => link.primaer).map((link) => link.kontaktId));
|
|
return phones
|
|
.map((phone) => {
|
|
const contact = contactById.get(phone.kontaktId);
|
|
return contact
|
|
? {
|
|
id: phone.id,
|
|
label: contact.label,
|
|
e164: phone.telefon,
|
|
primary: (primaryContacts.has(phone.kontaktId) || contact.primary) && phone.primaer,
|
|
}
|
|
: null;
|
|
})
|
|
.filter((number): number is ClickToCallNumber => number !== null)
|
|
.sort((left, right) => Number(right.primary) - Number(left.primary));
|
|
});
|
|
return loadTelephonyClickToCallVM(ctx, numbers);
|
|
}
|
|
|
|
export async function startTelephonyEntityCall(
|
|
ctx: TelephonyRouteContext,
|
|
input: {
|
|
readonly entityType: "contact" | "lead" | "project";
|
|
readonly entityId: string;
|
|
readonly numberId: string;
|
|
readonly destination: string;
|
|
}
|
|
): Promise<{ readonly accepted: true }> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.call);
|
|
const destination = normalizeE164(input.destination);
|
|
if (!destination) throw new TelephonyServiceError("telephony.call.error.invalidNumber");
|
|
if (!input.entityId || !input.numberId || input.entityId.length > 256 || input.numberId.length > 256) {
|
|
throw new CatalogError(foundationErrors.notFound);
|
|
}
|
|
|
|
const allowed = await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
if (input.entityType === "contact") {
|
|
const phone = await tx.kontaktTelefon.findFirst({
|
|
where: {
|
|
id: input.numberId,
|
|
tenantId: ctx.tenantId,
|
|
kontaktId: input.entityId,
|
|
kontakt: { archivedAt: null },
|
|
},
|
|
select: { telefon: true },
|
|
});
|
|
return normalizeE164(phone?.telefon ?? "") === destination;
|
|
}
|
|
if (input.entityType === "lead") {
|
|
if (input.numberId !== `lead:${input.entityId}`) return false;
|
|
const lead = await tx.inboxLead.findFirst({
|
|
where: { id: input.entityId, tenantId: ctx.tenantId, archivedAt: null },
|
|
select: { telefon: true },
|
|
});
|
|
return normalizeE164(lead?.telefon ?? "") === destination;
|
|
}
|
|
const phone = await tx.kontaktTelefon.findFirst({
|
|
where: { id: input.numberId, tenantId: ctx.tenantId, kontakt: { archivedAt: null } },
|
|
select: { kontaktId: true, telefon: true },
|
|
});
|
|
if (!phone || normalizeE164(phone.telefon) !== destination) return false;
|
|
const link = await tx.projektKontakt.findFirst({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
projektId: input.entityId,
|
|
kontaktId: phone.kontaktId,
|
|
projekt: { archivedAt: null },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return Boolean(link);
|
|
});
|
|
if (!allowed) {
|
|
throw new CatalogError(foundationErrors.notFound, { entityType: input.entityType, entityId: input.entityId });
|
|
}
|
|
return startTelephonyOutboundCall(ctx, destination);
|
|
}
|
|
|
|
export async function startTelephonyOutboundCall(ctx: TelephonyRouteContext, destination: string): Promise<{ readonly accepted: true }> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.call);
|
|
if (!E164_PATTERN.test(destination)) {
|
|
throw new TelephonyServiceError("telephony.call.error.invalidNumber");
|
|
}
|
|
const target = await withTenantRLS(ctx.tenantId, async (tx) => {
|
|
const dataConnection = await tx.dataConnection.findFirst({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
appId: { in: [AIRCALL_APP_ID, HALLOPETRA_APP_ID] },
|
|
archivedAt: null,
|
|
connectionStatus: { in: ["active", "degraded"] },
|
|
},
|
|
select: { id: true, appId: true },
|
|
});
|
|
if (!dataConnection) return { connection: null, binding: null };
|
|
const provider = dataConnection.appId === HALLOPETRA_APP_ID ? "hallopetra" : "aircall";
|
|
const connection = await tx.telephonyConnection.findFirst({
|
|
where: { tenantId: ctx.tenantId, dataConnectionId: dataConnection.id, provider },
|
|
select: { id: true, dataConnectionId: true, provider: true },
|
|
});
|
|
if (!connection || connection.provider !== "aircall") return { connection, binding: null };
|
|
const binding = await tx.telephonyUserBinding.findFirst({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
connectionId: connection.id,
|
|
tenantUserId: ctx.tenantUserId,
|
|
},
|
|
select: {
|
|
providerUserId: true,
|
|
defaultProviderNumberId: true,
|
|
},
|
|
});
|
|
return { connection, binding };
|
|
});
|
|
if (!target.connection) {
|
|
throw new TelephonyServiceError("telephony.call.fallback.noConnection");
|
|
}
|
|
if (target.connection.provider !== "aircall") {
|
|
throw new TelephonyServiceError("telephony.call.fallback.providerInboundOnly");
|
|
}
|
|
const binding = target.binding;
|
|
if (!binding?.defaultProviderNumberId) {
|
|
throw new TelephonyServiceError("telephony.call.fallback.noBinding");
|
|
}
|
|
const connection = target.connection;
|
|
const providerNumberId = binding.defaultProviderNumberId;
|
|
|
|
try {
|
|
const result = await runWithAircallCredentials(ctx.tenantId, connection.dataConnectionId, (credentials) =>
|
|
aircallTelephonyProvider.startCall(credentials, {
|
|
userId: binding.providerUserId,
|
|
numberId: providerNumberId,
|
|
to: destination,
|
|
})
|
|
);
|
|
await auditTelephony(ctx, "TELEPHONY_OUTBOUND_CALL_REQUESTED", "TelephonyConnection", connection.id, {
|
|
// The destination is personal data and deliberately stays out of audit metadata.
|
|
providerUserId: binding.providerUserId,
|
|
});
|
|
return result;
|
|
} catch (error) {
|
|
if (error instanceof CatalogError) throw error;
|
|
if (error instanceof AppsEngineServiceError) {
|
|
throw new TelephonyServiceError("telephony.call.error.connectionUnavailable");
|
|
}
|
|
throw mapProviderError(error);
|
|
}
|
|
}
|
|
|
|
export async function startTelephonyCallBack(ctx: TelephonyRouteContext, callId: string): Promise<{ readonly accepted: true }> {
|
|
requireTelephonyPermission(ctx, TELEPHONY_PERMISSIONS.call);
|
|
const canReadAll = canReadAllTelephonyCalls(ctx);
|
|
const call = await withTenantRLS(ctx.tenantId, (tx) =>
|
|
tx.telephonyCallSession.findFirst({
|
|
where: {
|
|
id: callId,
|
|
tenantId: ctx.tenantId,
|
|
archivedAt: null,
|
|
...(canReadAll ? {} : { tenantUserId: ctx.tenantUserId }),
|
|
},
|
|
select: { direction: true, fromNumber: true, toNumber: true },
|
|
})
|
|
);
|
|
if (!call) {
|
|
throw new CatalogError(foundationErrors.notFound, { callId });
|
|
}
|
|
return startTelephonyOutboundCall(ctx, counterpartNumber(call));
|
|
}
|
|
|
|
async function requireTelephonyConnection(tenantId: string, dataConnectionId: string) {
|
|
const connection = await withTenantRLS(tenantId, (tx) =>
|
|
tx.telephonyConnection.findFirst({
|
|
where: { tenantId, dataConnectionId },
|
|
select: { id: true, dataConnectionId: true, provider: true, webhookId: true },
|
|
})
|
|
);
|
|
if (!connection) {
|
|
throw new CatalogError(foundationErrors.notFound, { connectionId: dataConnectionId });
|
|
}
|
|
return connection;
|
|
}
|
|
|
|
function appIdForProvider(provider: TelephonyProvider): "aircall" | "hallopetra" {
|
|
return provider;
|
|
}
|
|
|
|
async function runWithAircallCredentials<T>(tenantId: string, dataConnectionId: string, callback: (credentials: AircallCredentials) => Promise<T>): Promise<T> {
|
|
return runWithConnectionCredentials({ tenantId, connectionId: dataConnectionId, expectedAppId: AIRCALL_APP_ID }, (credentials) =>
|
|
callback({
|
|
accessToken: typeof credentials.accessToken === "string" ? credentials.accessToken : null,
|
|
})
|
|
);
|
|
}
|
|
|
|
async function resolveCallLabelMaps(
|
|
tx: TenantRlsClient,
|
|
tenantId: string,
|
|
calls: readonly {
|
|
readonly tenantUserId: string | null;
|
|
readonly matchedKontaktId: string | null;
|
|
readonly matchedLeadId: string | null;
|
|
readonly matchedProjektId: string | null;
|
|
}[]
|
|
): Promise<CallLabelMaps> {
|
|
const tenantUserIds = uniqueIds(calls.map((call) => call.tenantUserId));
|
|
const contactIds = uniqueIds(calls.map((call) => call.matchedKontaktId));
|
|
const leadIds = uniqueIds(calls.map((call) => call.matchedLeadId));
|
|
const projectIds = uniqueIds(calls.map((call) => call.matchedProjektId));
|
|
const [memberships, contacts, leads, projects] = await Promise.all([
|
|
tenantUserIds.length
|
|
? tx.tenantUser.findMany({
|
|
where: { tenantId, id: { in: tenantUserIds } },
|
|
select: {
|
|
id: true,
|
|
user: { select: { email: true, firstName: true, lastName: true } },
|
|
},
|
|
})
|
|
: Promise.resolve([]),
|
|
contactIds.length
|
|
? tx.kontakt.findMany({
|
|
where: { tenantId, id: { in: contactIds }, archivedAt: null },
|
|
select: {
|
|
id: true,
|
|
kontaktTyp: true,
|
|
vorname: true,
|
|
nachname: true,
|
|
orgName: true,
|
|
primaerEmail: true,
|
|
primaerTelefon: true,
|
|
},
|
|
})
|
|
: Promise.resolve([]),
|
|
leadIds.length
|
|
? tx.inboxLead.findMany({
|
|
where: { tenantId, id: { in: leadIds }, archivedAt: null },
|
|
select: { id: true, vorname: true, nachname: true, email: true, telefon: true },
|
|
})
|
|
: Promise.resolve([]),
|
|
projectIds.length
|
|
? tx.projekt.findMany({
|
|
where: { tenantId, id: { in: projectIds }, archivedAt: null },
|
|
select: { id: true, projektname: true, projektNummer: true },
|
|
})
|
|
: Promise.resolve([]),
|
|
]);
|
|
return {
|
|
users: new Map(memberships.map((membership) => [membership.id, userLabel(membership.user)])),
|
|
contacts: new Map(contacts.map((contact) => [contact.id, contactLabel(contact)])),
|
|
leads: new Map(leads.map((lead) => [lead.id, leadLabel(lead)])),
|
|
projects: new Map(projects.map((project) => [project.id, project.projektname || project.projektNummer])),
|
|
};
|
|
}
|
|
|
|
async function loadMatchOptions(tx: TenantRlsClient, tenantId: string) {
|
|
const [contacts, leads, projects] = await Promise.all([
|
|
tx.kontakt.findMany({
|
|
where: { tenantId, archivedAt: null },
|
|
select: {
|
|
id: true,
|
|
kontaktTyp: true,
|
|
vorname: true,
|
|
nachname: true,
|
|
orgName: true,
|
|
primaerEmail: true,
|
|
primaerTelefon: true,
|
|
},
|
|
orderBy: { updatedAt: "desc" },
|
|
take: MAX_MATCH_OPTIONS_PER_TYPE,
|
|
}),
|
|
tx.inboxLead.findMany({
|
|
where: { tenantId, archivedAt: null },
|
|
select: { id: true, vorname: true, nachname: true, email: true, telefon: true },
|
|
orderBy: { updatedAt: "desc" },
|
|
take: MAX_MATCH_OPTIONS_PER_TYPE,
|
|
}),
|
|
tx.projekt.findMany({
|
|
where: { tenantId, archivedAt: null },
|
|
select: { id: true, projektname: true, projektNummer: true },
|
|
orderBy: { updatedAt: "desc" },
|
|
take: MAX_MATCH_OPTIONS_PER_TYPE,
|
|
}),
|
|
]);
|
|
return [
|
|
...contacts.map((contact) => ({ type: "contact" as const, id: contact.id, label: contactLabel(contact) })),
|
|
...leads.map((lead) => ({ type: "lead" as const, id: lead.id, label: leadLabel(lead) })),
|
|
...projects.map((project) => ({
|
|
type: "project" as const,
|
|
id: project.id,
|
|
label: project.projektname || project.projektNummer,
|
|
})),
|
|
];
|
|
}
|
|
|
|
async function requireMatchedEntity(
|
|
tx: TenantRlsClient,
|
|
tenantId: string,
|
|
match: { readonly type: TelephonyMatchedEntityType; readonly id: string }
|
|
): Promise<void> {
|
|
const row =
|
|
match.type === "contact"
|
|
? await tx.kontakt.findFirst({
|
|
where: { id: match.id, tenantId, archivedAt: null },
|
|
select: { id: true },
|
|
})
|
|
: match.type === "lead"
|
|
? await tx.inboxLead.findFirst({
|
|
where: { id: match.id, tenantId, archivedAt: null },
|
|
select: { id: true },
|
|
})
|
|
: await tx.projekt.findFirst({
|
|
where: { id: match.id, tenantId, archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (!row) {
|
|
throw new CatalogError(foundationErrors.notFound, {
|
|
entityType: match.type,
|
|
entityId: match.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
function matchedEntityForCall(
|
|
call: {
|
|
readonly matchedKontaktId: string | null;
|
|
readonly matchedLeadId: string | null;
|
|
readonly matchedProjektId: string | null;
|
|
},
|
|
labels: CallLabelMaps
|
|
): MatchedEntity | null {
|
|
if (call.matchedKontaktId) {
|
|
return {
|
|
type: "contact",
|
|
id: call.matchedKontaktId,
|
|
label: labels.contacts.get(call.matchedKontaktId) ?? call.matchedKontaktId,
|
|
};
|
|
}
|
|
if (call.matchedLeadId) {
|
|
return {
|
|
type: "lead",
|
|
id: call.matchedLeadId,
|
|
label: labels.leads.get(call.matchedLeadId) ?? call.matchedLeadId,
|
|
};
|
|
}
|
|
if (call.matchedProjektId) {
|
|
return {
|
|
type: "project",
|
|
id: call.matchedProjektId,
|
|
label: labels.projects.get(call.matchedProjektId) ?? call.matchedProjektId,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function counterpartNumber(call: { readonly direction: "inbound" | "outbound"; readonly fromNumber: string; readonly toNumber: string }): string {
|
|
return call.direction === "inbound" ? call.fromNumber : call.toNumber;
|
|
}
|
|
|
|
function toViewMatchConfidence(value: "hard" | "soft" | "manual" | "none"): TelephonyMatchConfidence {
|
|
return value === "hard" ? "exact" : value;
|
|
}
|
|
|
|
function toSettingsConnectionStatus(value: "pending" | "active" | "degraded" | "broken" | "revoked"): TelephonyConnectionStatus {
|
|
if (value === "pending") return "connecting";
|
|
if (value === "active") return "active";
|
|
if (value === "revoked") return "revoked";
|
|
return "error";
|
|
}
|
|
|
|
function noteSnippet(value: string | null): string | null {
|
|
const compact = value?.replace(/\s+/gu, " ").trim() ?? "";
|
|
if (!compact) return null;
|
|
return compact.length > 120 ? `${compact.slice(0, 117)}…` : compact;
|
|
}
|
|
|
|
function userLabel(user: { readonly firstName: string; readonly lastName: string; readonly email: string }): string {
|
|
return (
|
|
[user.firstName, user.lastName]
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.join(" ") || user.email
|
|
);
|
|
}
|
|
|
|
function contactLabel(contact: {
|
|
readonly kontaktTyp: "privatperson" | "organisation";
|
|
readonly vorname: string | null;
|
|
readonly nachname: string | null;
|
|
readonly orgName: string | null;
|
|
readonly primaerEmail: string | null;
|
|
readonly primaerTelefon: string | null;
|
|
}): string {
|
|
if (contact.kontaktTyp === "organisation" && contact.orgName?.trim()) return contact.orgName.trim();
|
|
return [contact.vorname, contact.nachname].filter(Boolean).join(" ").trim() || contact.primaerEmail || contact.primaerTelefon || "—";
|
|
}
|
|
|
|
function leadLabel(lead: {
|
|
readonly vorname: string | null;
|
|
readonly nachname: string | null;
|
|
readonly email: string | null;
|
|
readonly telefon: string | null;
|
|
}): string {
|
|
return [lead.vorname, lead.nachname].filter(Boolean).join(" ").trim() || lead.email || lead.telefon || "—";
|
|
}
|
|
|
|
function safeHttpsUrl(value: string | null): string | null {
|
|
if (!value) return null;
|
|
try {
|
|
const parsed = new URL(value);
|
|
return parsed.protocol === "https:" ? parsed.toString() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function validEmailOrNull(value: string): string | null {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value) ? value : null;
|
|
}
|
|
|
|
function uniqueIds(values: readonly (string | null)[]): string[] {
|
|
return [...new Set(values.filter((value): value is string => Boolean(value)))];
|
|
}
|
|
|
|
function normalizeClickToCallNumbers(numbers: readonly ClickToCallNumber[]): ClickToCallNumber[] {
|
|
const byNumber = new Map<string, ClickToCallNumber>();
|
|
for (const number of numbers) {
|
|
const e164 = normalizeE164(number.e164);
|
|
if (!e164 || !number.id || number.id.length > 256) continue;
|
|
const normalized = { ...number, label: number.label.trim() || e164, e164 };
|
|
const current = byNumber.get(e164);
|
|
if (!current || (!current.primary && normalized.primary)) byNumber.set(e164, normalized);
|
|
}
|
|
return [...byNumber.values()];
|
|
}
|
|
|
|
function normalizeE164(value: string): string | null {
|
|
const compact = value
|
|
.trim()
|
|
.replace(/[^+\d]/gu, "")
|
|
.replace(/(?!^)\+/gu, "");
|
|
const normalized = compact.startsWith("00") ? `+${compact.slice(2)}` : compact;
|
|
return E164_PATTERN.test(normalized) ? normalized : null;
|
|
}
|
|
|
|
function mapProviderError(error: unknown): TelephonyServiceError {
|
|
if (error instanceof AircallError) {
|
|
if (error.code === "user_unavailable") {
|
|
return new TelephonyServiceError("telephony.call.error.userUnavailable");
|
|
}
|
|
if (error.code === "auth_token_invalid" || error.code === "auth_scope_missing") {
|
|
return new TelephonyServiceError("telephony.call.error.connectionUnavailable");
|
|
}
|
|
}
|
|
return new TelephonyServiceError("telephony.call.error.providerUnavailable");
|
|
}
|
|
|
|
async function auditTelephony(
|
|
ctx: TelephonyRouteContext,
|
|
action: string,
|
|
targetType: string,
|
|
targetId: string,
|
|
metadata: Record<string, string | number | null>
|
|
): Promise<void> {
|
|
await createAuditEvent({
|
|
tenantId: ctx.tenantId,
|
|
actorId: ctx.userId,
|
|
actorType: "user",
|
|
action,
|
|
targetType,
|
|
targetId,
|
|
sourceModule: "telephony",
|
|
outcome: "success",
|
|
metadata: metadata as never,
|
|
});
|
|
}
|