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

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,
});
}