import { FastifyInstance } from "fastify" import { promises as fs } from "node:fs" import net from "node:net" import path from "node:path" import { randomBytes } from "node:crypto" import { and, desc, eq, ilike, inArray, ne, or } from "drizzle-orm" import { authProfileBranches, authProfiles, authProfileTeams, authUsers, branches, teams, telephonyCalls, telephonyExtensions, telephonyTrunks, } from "../../db/schema" const envFlag = (value: string | undefined, fallback: boolean) => { if (value === undefined || value === "") return fallback return ["1", "true", "yes", "on"].includes(value.toLowerCase()) } const telephonyEnabled = () => envFlag(process.env.TELEPHONY_ENABLED, process.env.NODE_ENV !== "production") const asteriskHttpStatusUrl = () => process.env.TELEPHONY_ASTERISK_HTTP_URL || "" const asteriskHttpStatusUrls = () => { const configuredUrl = asteriskHttpStatusUrl() return configuredUrl ? [configuredUrl] : [] } const publicAsteriskWsUrl = () => process.env.TELEPHONY_ASTERISK_WS_URL || "" const defaultAsteriskGeneratedDir = () => { const cwd = process.cwd() return path.resolve(cwd, cwd.endsWith(`${path.sep}backend`) ? "../telephony/generated" : "telephony/generated") } const asteriskGeneratedDir = () => process.env.TELEPHONY_ASTERISK_GENERATED_DIR || defaultAsteriskGeneratedDir() const defaultAsteriskAmiHost = () => process.env.NODE_ENV === "production" ? "" : "127.0.0.1" const asteriskAmiConfig = () => ({ host: process.env.TELEPHONY_ASTERISK_AMI_HOST || defaultAsteriskAmiHost(), port: Number(process.env.TELEPHONY_ASTERISK_AMI_PORT || 5038), username: process.env.TELEPHONY_ASTERISK_AMI_USER || "fedeo", password: process.env.TELEPHONY_ASTERISK_AMI_PASSWORD || "fedeo-ami-dev", }) const sipDomain = () => process.env.TELEPHONY_SIP_DOMAIN || "localhost" const trunkProviders = { telekom: { key: "telekom", label: "Telekom", defaultRegistrar: "tel.t-online.de", aorContactIncludesUser: false, contactUserUsesCallerId: false, }, easybell: { key: "easybell", label: "Easybell", defaultRegistrar: "voip.easybell.de", aorContactIncludesUser: true, contactUserUsesCallerId: true, }, } as const type TrunkProviderKey = keyof typeof trunkProviders const normalizeTrunkProvider = (provider: string | null | undefined): TrunkProviderKey => provider === "easybell" ? "easybell" : "telekom" const trunkProviderConfig = (provider: string | null | undefined) => trunkProviders[normalizeTrunkProvider(provider)] const sanitizeTrunk = (trunk: any) => ({ id: trunk?.id || null, provider: normalizeTrunkProvider(trunk?.provider), enabled: Boolean(trunk?.enabled), registrar: trunk?.registrar || trunkProviderConfig(trunk?.provider).defaultRegistrar, sipUser: trunk?.sipUser || "", authUser: trunk?.authUser || "", passwordConfigured: Boolean(trunk?.password), callerId: trunk?.callerId || "", inboundExtension: trunk?.inboundExtension || "1001", defaultRouteExtensionId: trunk?.defaultRouteExtensionId || null, outboundPrefix: trunk?.outboundPrefix || "0", externalSignalingAddress: trunk?.externalSignalingAddress || "", externalMediaAddress: trunk?.externalMediaAddress || "", localNetworks: trunk?.localNetworks || "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8", }) const envExternalTelephonyConfig = () => { const provider = process.env.TELEPHONY_EXTERNAL_PROVIDER || ( envFlag(process.env.TELEPHONY_TELEKOM_ENABLED, false) ? "telekom" : "" ) const enabled = envFlag( process.env.TELEPHONY_EXTERNAL_ENABLED, envFlag(process.env.TELEPHONY_TELEKOM_ENABLED, false) ) return { enabled, provider: provider || null, inboundExtension: process.env.TELEPHONY_EXTERNAL_INBOUND_EXTENSION || process.env.TELEPHONY_TELEKOM_INBOUND_EXTENSION || "1001", outboundPrefix: process.env.TELEPHONY_TELEKOM_OUTBOUND_PREFIX || "0", registrar: provider === "telekom" || envFlag(process.env.TELEPHONY_TELEKOM_ENABLED, false) ? (process.env.TELEPHONY_TELEKOM_REGISTRAR || "tel.t-online.de") : null, sipUserConfigured: Boolean(process.env.TELEPHONY_TELEKOM_SIP_USER), authUserConfigured: Boolean(process.env.TELEPHONY_TELEKOM_AUTH_USER), passwordConfigured: Boolean(process.env.TELEPHONY_TELEKOM_PASSWORD), callerIdConfigured: Boolean(process.env.TELEPHONY_TELEKOM_CALLER_ID), } } const fetchWithTimeout = async (url: string, timeoutMs = 2500) => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { return await fetch(url, { signal: controller.signal }) } finally { clearTimeout(timeout) } } const requireTenant = (tenantId: number | null) => { if (!tenantId) { throw Object.assign(new Error("Kein aktiver Mandant"), { statusCode: 400 }) } return tenantId } const bodyString = (body: any, key: string) => { const value = body?.[key] return typeof value === "string" && value.trim() ? value.trim() : null } const bodyNumber = (body: any, key: string) => { const value = body?.[key] if (value === null || value === undefined || value === "") return null const number = Number(value) return Number.isFinite(number) ? number : null } const normalizeExtensionTargetType = (value: string | null | undefined) => { if (value === "team") return "team" if (value === "branch") return "branch" return "user" } const normalizeExtension = (value: string | null | undefined) => asteriskValue(value).replace(/[^0-9*#+]/g, "") const generateSipPassword = () => randomBytes(18).toString("base64url") const bodyDate = (body: any, key: string) => { const value = body?.[key] if (!value) return null const date = new Date(value) return Number.isNaN(date.getTime()) ? null : date } const durationSeconds = (startedAt?: Date | null, endedAt?: Date | null) => { if (!startedAt || !endedAt) return null return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 1000)) } const asteriskValue = (value: string | null | undefined) => String(value || "").replace(/[\r\n]/g, "").trim() const renderProviderPjsipConfig = (trunk: any) => { const provider = trunkProviderConfig(trunk?.provider) if (!trunk?.enabled) { return [ "; Von FEDEO generiert.", `; ${provider.label}-Trunk ist deaktiviert.`, "", ].join("\n") } const providerKey = provider.key const registrar = asteriskValue(trunk.registrar) || provider.defaultRegistrar const sipUser = asteriskValue(trunk.sipUser) const authUser = asteriskValue(trunk.authUser) || sipUser const password = asteriskValue(trunk.password) const callerId = asteriskValue(trunk.callerId) || sipUser const externalMediaAddress = asteriskValue(trunk.externalMediaAddress || trunk.externalSignalingAddress) const contactUser = provider.contactUserUsesCallerId && callerId ? callerId : sipUser const aorContact = provider.aorContactIncludesUser && contactUser ? `sip:${contactUser}@${registrar}` : `sip:${registrar}` return [ "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", `[${providerKey}-auth]`, "type=auth", "auth_type=userpass", `username=${authUser}`, `password=${password}`, "", `[${providerKey}-aor]`, "type=aor", `contact=${aorContact}`, "", `[${providerKey}]`, "type=endpoint", "transport=transport-udp", `context=from-${providerKey}`, "disallow=all", providerKey === "easybell" ? "allow=alaw,ulaw,g722" : "allow=alaw,ulaw", `aors=${providerKey}-aor`, `outbound_auth=${providerKey}-auth`, `from_user=${sipUser}`, `from_domain=${registrar}`, `callerid=${provider.label} <${callerId}>`, ...(externalMediaAddress ? [`media_address=${externalMediaAddress}`] : []), "direct_media=no", "force_rport=yes", "rewrite_contact=yes", "rtp_symmetric=yes", ...(providerKey === "easybell" ? [ "ice_support=no", "send_pai=yes", ] : []), "timers=no", "", `[${providerKey}-identify]`, "type=identify", `endpoint=${providerKey}`, `match=${registrar}`, "", `[${providerKey}-registration]`, "type=registration", "transport=transport-udp", `outbound_auth=${providerKey}-auth`, `server_uri=sip:${registrar}`, `client_uri=sip:${sipUser}@${registrar}`, `contact_user=${contactUser}`, "retry_interval=60", `forbidden_retry_interval=${providerKey === "easybell" ? "600" : "300"}`, `expiration=${providerKey === "easybell" ? "1800" : "480"}`, "line=yes", `endpoint=${providerKey}`, "", ].join("\n") } const renderProviderExtensionsConfig = (trunk: any) => { const provider = trunkProviderConfig(trunk?.provider) if (!trunk?.enabled) { return [ "; Von FEDEO generiert.", `; ${provider.label}-Routing ist deaktiviert.`, "", ].join("\n") } const providerKey = provider.key const inboundExtension = asteriskValue(trunk.inboundExtension) || "1001" const outboundPrefix = asteriskValue(trunk.outboundPrefix) || "0" const escapedPrefix = outboundPrefix.replace(/[^0-9*#+]/g, "") const callerId = asteriskValue(trunk.callerId) || asteriskValue(trunk.sipUser) const inboundDid = callerId.replace(/[^0-9+]/g, "") return [ "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", "[fedeo-local]", escapedPrefix ? `exten => _${escapedPrefix}X.,1,NoOp(FEDEO ausgehend über ${provider.label}: $` + "{EXTEN})" : `exten => _X.,1,NoOp(FEDEO ausgehend über ${provider.label}: $` + "{EXTEN})", ` same => n,Set(CALLERID(num)=${callerId})`, ` same => n,Dial(PJSIP/$` + `{EXTEN}@${providerKey},60)`, " same => n,Hangup()", "", `exten => _+X.,1,NoOp(FEDEO ausgehend über ${provider.label}: $` + "{EXTEN})", ` same => n,Set(CALLERID(num)=${callerId})`, ` same => n,Dial(PJSIP/$` + `{EXTEN}@${providerKey},60)`, " same => n,Hangup()", "", `[from-${providerKey}]`, ...(inboundDid ? [ `exten => ${inboundDid},1,NoOp(FEDEO eingehend über ${provider.label} DID: $` + "{EXTEN})", ` same => n,Dial(PJSIP/${inboundExtension},30)`, " same => n,Hangup()", "", ] : []), `exten => s,1,NoOp(FEDEO eingehend über ${provider.label})`, ` same => n,Dial(PJSIP/${inboundExtension},30)`, " same => n,Hangup()", "", `exten => _X!,1,NoOp(FEDEO eingehend über ${provider.label}: $` + "{EXTEN})", ` same => n,Dial(PJSIP/${inboundExtension},30)`, " same => n,Hangup()", "", ].join("\n") } const renderWebRtcConfig = (trunk: any) => { const externalMediaAddress = asteriskValue(trunk?.externalMediaAddress || trunk?.externalSignalingAddress) if (!externalMediaAddress) { return [ "; Von FEDEO generiert.", "; Kein externes WebRTC-Media-Rewrite konfiguriert.", "", ].join("\n") } return [ "; Von FEDEO generiert. Diese Datei wird innerhalb des FEDEO-WebRTC-Templates inkludiert.", `media_address=${externalMediaAddress}`, "bind_rtp_to_media_address=yes", "", ].join("\n") } const extensionDialTarget = (extension: any, dialTargets: string[]) => { const dial = dialTargets.length ? `Dial(${dialTargets.map((target) => `PJSIP/${target}`).join("&")},30)` : "Hangup()" return [ `exten => ${extension.extension},1,NoOp(FEDEO Nebenstelle ${extension.extension}: ${extension.displayName || extension.targetType})`, ` same => n,${dial}`, " same => n,Hangup()", "", ].join("\n") } const renderTelephonyExtensionsPjsipConfig = (extensions: any[]) => { const userExtensions = extensions.filter((extension) => extension.enabled && extension.targetType === "user" ) if (!userExtensions.length) { return [ "; Von FEDEO generiert.", "; Keine Benutzer-Nebenstellen konfiguriert.", "", ].join("\n") } return [ "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", ...userExtensions.flatMap((extension) => { const sipUsername = asteriskValue(extension.sipUsername) || extension.extension const sipPassword = asteriskValue(extension.sipPassword) const displayName = asteriskValue(extension.displayName) || `FEDEO ${extension.extension}` return [ `[${extension.extension}](fedeo-webrtc)`, `auth=${extension.extension}-auth`, `aors=${extension.extension}`, `callerid=${displayName} <${extension.extension}>`, "", `[${extension.extension}-auth]`, "type=auth", "auth_type=userpass", `username=${sipUsername}`, `password=${sipPassword}`, "", `[${extension.extension}]`, "type=aor", "max_contacts=5", "remove_existing=yes", "support_path=yes", "", ] }), ].join("\n") } const renderTelephonyExtensionsDialplanConfig = (extensions: any[], dialTargetsById: Map) => { const enabledExtensions = extensions.filter((extension) => extension.enabled) if (!enabledExtensions.length) { return [ "; Von FEDEO generiert.", "; Keine Nebenstellen konfiguriert.", "", ].join("\n") } return [ "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", "[fedeo-local]", ...enabledExtensions.map((extension) => extensionDialTarget(extension, dialTargetsById.get(extension.id) || []) ), ].join("\n") } const renderTelekomTransportConfig = (trunk: any) => { const externalSignalingAddress = asteriskValue(trunk?.externalSignalingAddress) const externalMediaAddress = asteriskValue(trunk?.externalMediaAddress || trunk?.externalSignalingAddress) const localNetworks = asteriskValue(trunk?.localNetworks || "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8") .split(/[\s,;]+/) .map((entry) => entry.trim()) .filter(Boolean) if (!externalSignalingAddress && !externalMediaAddress) { return [ "; Von FEDEO generiert.", "; Kein externes Asterisk-NAT-Rewrite konfiguriert.", "", ].join("\n") } return [ "; Von FEDEO generiert. Diese Datei wird innerhalb von [transport-udp] inkludiert.", "; Änderungen am Transport benötigen einen Asterisk-Neustart.", ...(externalSignalingAddress ? [ `external_signaling_address=${externalSignalingAddress}`, "external_signaling_port=5060", ] : []), ...(externalMediaAddress ? [`external_media_address=${externalMediaAddress}`] : []), ...localNetworks.map((network) => `local_net=${network}`), "", ].join("\n") } const writeAsteriskTrunkConfig = async (trunk: any, extensions: any[] = [], dialTargetsById = new Map()) => { const targetDir = asteriskGeneratedDir() await fs.mkdir(targetDir, { recursive: true }) const files = [ { name: "pjsip.telekom.conf", content: renderProviderPjsipConfig(trunk), }, { name: "extensions.telekom.conf", content: renderProviderExtensionsConfig(trunk), }, { name: "pjsip.transport.conf", content: renderTelekomTransportConfig(trunk), }, { name: "pjsip.webrtc.conf", content: renderWebRtcConfig(trunk), }, { name: "pjsip.extensions.conf", content: renderTelephonyExtensionsPjsipConfig(extensions), }, { name: "extensions.fedeo.conf", content: renderTelephonyExtensionsDialplanConfig(extensions, dialTargetsById), }, ] await Promise.all(files.map(async (file) => { const target = path.join(targetDir, file.name) await fs.writeFile(target, `${file.content}\n`, { mode: 0o644 }) await fs.chmod(target, 0o644) })) return files.map((file) => path.join(targetDir, file.name)) } const runAsteriskAmiCommand = async (command: string, timeoutMs = 5000) => { const config = asteriskAmiConfig() if (!config.host) { throw new Error("Asterisk-AMI ist nicht konfiguriert.") } return await new Promise<{ command: string, ok: boolean, raw: string }>((resolve, reject) => { const socket = net.createConnection(config.port, config.host) let raw = "" let settled = false const finish = (ok: boolean) => { if (settled) return settled = true socket.destroy() resolve({ command, ok, raw }) } socket.setTimeout(timeoutMs) socket.on("connect", () => { socket.write([ "Action: Login", `Username: ${config.username}`, `Secret: ${config.password}`, "Events: off", "", "Action: Command", `Command: ${command}`, "", "Action: Logoff", "", ].join("\r\n")) }) socket.on("data", (chunk) => { raw += chunk.toString("utf8") if (raw.includes("Goodbye")) finish(!raw.includes("Authentication failed")) }) socket.on("timeout", () => finish(raw.length > 0)) socket.on("end", () => finish(!raw.includes("Authentication failed"))) socket.on("error", reject) }) } const runAsteriskReload = async (trunk: any) => { const provider = trunkProviderConfig(trunk?.provider) const commands = [ "module reload res_pjsip.so", "dialplan reload", `pjsip send register ${provider.key}-registration`, ] const results = [] for (const command of commands) { results.push(await runAsteriskAmiCommand(command)) } return { ok: results.every((result) => result.ok), commands: results, } } const readAsteriskTrunkStatus = async (trunk?: any) => { const provider = trunkProviderConfig(trunk?.provider) const registrationName = `${provider.key}-registration` const registrations = await runAsteriskAmiCommand("pjsip show registrations") const raw = registrations.raw || "" return { reachable: registrations.ok, provider: provider.key, registered: new RegExp(`${registrationName}[\\s\\S]*Registered`, "i").test(raw), hasRegistration: raw.includes(registrationName), registrations: raw, } } export default async function telephonyRoutes(server: FastifyInstance) { const loadTenantTrunk = async (tenantId: number | null, provider = "telekom") => { if (!tenantId) return null const [trunk] = await server.db .select() .from(telephonyTrunks) .where(and( eq(telephonyTrunks.tenantId, tenantId), eq(telephonyTrunks.provider, provider) )) .limit(1) return trunk || null } const loadActiveTenantTrunk = async (tenantId: number | null) => { if (!tenantId) return null const trunks = await server.db .select() .from(telephonyTrunks) .where(eq(telephonyTrunks.tenantId, tenantId)) return trunks.find((trunk) => trunk.enabled) || trunks.find((trunk) => trunk.provider === "easybell") || trunks.find((trunk) => trunk.provider === "telekom") || trunks[0] || null } const loadTenantExtensions = async (tenantId: number | null) => { if (!tenantId) return [] return await server.db .select() .from(telephonyExtensions) .where(eq(telephonyExtensions.tenantId, tenantId)) } const loadExtensionDialTargets = async (tenantId: number, extensions: any[]) => { const targetsById = new Map() const userExtensionByUserId = new Map() for (const extension of extensions) { if (!extension.enabled) continue if (extension.targetType === "user" && extension.targetUserId) { userExtensionByUserId.set(extension.targetUserId, extension.extension) targetsById.set(extension.id, [extension.extension]) } } const profileRows = await server.db .select({ id: authProfiles.id, userId: authProfiles.user_id, branchId: authProfiles.branch_id, }) .from(authProfiles) .where(and( eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true) )) const profileUserByProfileId = new Map() for (const profile of profileRows) { if (profile.id && profile.userId) { profileUserByProfileId.set(profile.id, profile.userId) } } const profileIds = profileRows.map((profile) => profile.id).filter(Boolean) const teamRows = profileIds.length ? await server.db .select() .from(authProfileTeams) .where(inArray(authProfileTeams.profile_id, profileIds)) : [] const branchRows = profileIds.length ? await server.db .select() .from(authProfileBranches) .where(inArray(authProfileBranches.profile_id, profileIds)) : [] for (const extension of extensions) { if (!extension.enabled || extension.targetType === "user") continue const memberUserIds = new Set() if (extension.targetType === "team" && extension.targetTeamId) { for (const row of teamRows) { if (row.team_id !== extension.targetTeamId) continue const userId = profileUserByProfileId.get(row.profile_id) if (userId) memberUserIds.add(userId) } } if (extension.targetType === "branch" && extension.targetBranchId) { for (const profile of profileRows) { if (profile.branchId === extension.targetBranchId && profile.userId) { memberUserIds.add(profile.userId) } } for (const row of branchRows) { if (row.branch_id !== extension.targetBranchId) continue const userId = profileUserByProfileId.get(row.profile_id) if (userId) memberUserIds.add(userId) } } const dialTargets = Array.from(memberUserIds) .map((userId) => userExtensionByUserId.get(userId)) .filter(Boolean) as string[] targetsById.set(extension.id, Array.from(new Set(dialTargets))) } return targetsById } const sanitizeExtension = (extension: any) => ({ id: extension.id, tenantId: extension.tenantId, targetType: extension.targetType, targetUserId: extension.targetUserId, targetTeamId: extension.targetTeamId, targetBranchId: extension.targetBranchId, extension: extension.extension, displayName: extension.displayName || "", sipUsername: extension.sipUsername || extension.extension, sipPasswordConfigured: Boolean(extension.sipPassword), enabled: Boolean(extension.enabled), createdAt: extension.createdAt, updatedAt: extension.updatedAt, }) const extensionTargetWhere = (tenantId: number, targetType: string, targetId: string | number | null) => { if (targetType === "team") { return and( eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.targetType, "team"), eq(telephonyExtensions.targetTeamId, Number(targetId)) ) } if (targetType === "branch") { return and( eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.targetType, "branch"), eq(telephonyExtensions.targetBranchId, Number(targetId)) ) } return and( eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.targetType, "user"), eq(telephonyExtensions.targetUserId, String(targetId)) ) } const resolveExtensionTargetId = (targetType: string, body: any) => { if (targetType === "team") return bodyNumber(body, "targetTeamId") ?? bodyNumber(body, "targetId") if (targetType === "branch") return bodyNumber(body, "targetBranchId") ?? bodyNumber(body, "targetId") return bodyString(body, "targetUserId") || bodyString(body, "targetId") } const validateExtensionDuplicate = async ( tenantId: number, extension: string, currentExtensionId: string | null = null ) => { const conditions = [ eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.extension, extension), ] if (currentExtensionId) { conditions.push(ne(telephonyExtensions.id, currentExtensionId)) } const existing = await server.db .select({ id: telephonyExtensions.id }) .from(telephonyExtensions) .where(and(...conditions)) .limit(1) return existing[0] ? "Diese Nebenstelle ist bereits vergeben." : null } const externalTelephonyConfig = async (tenantId: number | null) => { const trunk = await loadActiveTenantTrunk(tenantId) if (trunk) { return { enabled: trunk.enabled, provider: trunk.provider, inboundExtension: trunk.inboundExtension, defaultRouteExtensionId: trunk.defaultRouteExtensionId, outboundPrefix: trunk.outboundPrefix, registrar: trunk.registrar, externalSignalingAddress: trunk.externalSignalingAddress, externalMediaAddress: trunk.externalMediaAddress, localNetworks: trunk.localNetworks, sipUserConfigured: Boolean(trunk.sipUser), authUserConfigured: Boolean(trunk.authUser), passwordConfigured: Boolean(trunk.password), callerIdConfigured: Boolean(trunk.callerId), } } return envExternalTelephonyConfig() } const loadUserSipAccounts = async (tenantId: number | null, userId: string | null | undefined) => { if (!tenantId || !userId) return [] const extensions = await server.db .select() .from(telephonyExtensions) .where(and( eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.targetType, "user"), eq(telephonyExtensions.targetUserId, userId), eq(telephonyExtensions.enabled, true) )) return extensions.map((extension) => ({ extension: extension.extension, password: extension.sipPassword, displayName: extension.displayName || `Nebenstelle ${extension.extension}`, })).filter((account) => account.extension && account.password) } server.get("/telephony/config", async (req) => ({ enabled: telephonyEnabled(), provider: "asterisk", mode: "asterisk", sipDomain: sipDomain(), sipWebSocketUrl: publicAsteriskWsUrl(), echoExtension: process.env.TELEPHONY_ECHO_EXTENSION || "600", accounts: await loadUserSipAccounts(req.user?.tenant_id || null, req.user?.user_id), external: await externalTelephonyConfig(req.user?.tenant_id || null), })) server.get("/telephony/status", async () => { const enabled = telephonyEnabled() const urls = asteriskHttpStatusUrls() let lastError: any = null const attempts = [] for (const url of urls) { try { const response = await fetchWithTimeout(url) attempts.push({ url, reachable: true, statusCode: response.status, }) return { enabled, provider: "asterisk", reachable: true, statusCode: response.status, statusUrl: url, attempts, message: enabled ? (response.ok ? "Asterisk ist erreichbar." : `Asterisk-HTTP ist erreichbar (HTTP ${response.status}).`) : "Asterisk ist erreichbar, Telefonie ist aber noch nicht aktiviert.", } } catch (error: any) { lastError = error attempts.push({ url, reachable: false, message: error?.name === "AbortError" ? "Abgelaufen" : (error?.message || "Nicht erreichbar"), }) } } return { enabled, provider: "asterisk", reachable: false, statusUrl: urls[0], attempts, message: !urls.length ? "Asterisk-Status-URL ist nicht konfiguriert." : lastError?.name === "AbortError" ? "Asterisk-Statusabfrage ist abgelaufen." : (lastError?.message || "Asterisk ist nicht erreichbar."), } }) server.get("/telephony/trunk-config", async (req) => { const tenantId = requireTenant(req.user.tenant_id) const provider = normalizeTrunkProvider((req.query as { provider?: string })?.provider) const trunk = await loadTenantTrunk(tenantId, provider) return sanitizeTrunk(trunk || { provider }) }) server.put("/telephony/trunk-config", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const body = (req.body || {}) as any const provider = normalizeTrunkProvider(bodyString(body, "provider")) const providerConfig = trunkProviderConfig(provider) const existing = await loadTenantTrunk(tenantId, provider) const password = bodyString(body, "password") const clearPassword = body?.clearPassword === true const now = new Date() const values = { tenantId, provider, enabled: Boolean(body.enabled), registrar: bodyString(body, "registrar") || providerConfig.defaultRegistrar, sipUser: bodyString(body, "sipUser"), authUser: bodyString(body, "authUser"), callerId: bodyString(body, "callerId"), inboundExtension: bodyString(body, "inboundExtension") || "1001", defaultRouteExtensionId: bodyString(body, "defaultRouteExtensionId"), outboundPrefix: bodyString(body, "outboundPrefix") || "0", externalSignalingAddress: bodyString(body, "externalSignalingAddress"), externalMediaAddress: bodyString(body, "externalMediaAddress"), localNetworks: bodyString(body, "localNetworks") || "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8", password: clearPassword ? null : (password || existing?.password || null), updatedAt: now, updatedBy: req.user.user_id, } if (values.enabled && (!values.sipUser || !values.password)) { return reply.code(400).send({ error: "SIP-ID und Kennwort sind erforderlich, wenn der Trunk aktiviert wird.", }) } if (existing) { const [updated] = await server.db .update(telephonyTrunks) .set(values) .where(and( eq(telephonyTrunks.tenantId, tenantId), eq(telephonyTrunks.provider, provider) )) .returning() if (values.enabled) { await server.db .update(telephonyTrunks) .set({ enabled: false, updatedAt: now, updatedBy: req.user.user_id }) .where(and( eq(telephonyTrunks.tenantId, tenantId), eq(telephonyTrunks.enabled, true), eq(telephonyTrunks.provider, provider === "easybell" ? "telekom" : "easybell") )) } return sanitizeTrunk(updated) } const [created] = await server.db .insert(telephonyTrunks) .values({ ...values, createdAt: now, createdBy: req.user.user_id, }) .returning() if (values.enabled) { await server.db .update(telephonyTrunks) .set({ enabled: false, updatedAt: now, updatedBy: req.user.user_id }) .where(and( eq(telephonyTrunks.tenantId, tenantId), eq(telephonyTrunks.enabled, true), eq(telephonyTrunks.provider, provider === "easybell" ? "telekom" : "easybell") )) } return sanitizeTrunk(created) }) server.post("/telephony/trunk-config/apply", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const trunk = await loadActiveTenantTrunk(tenantId) if (!trunk) { return reply.code(404).send({ error: "Telefonie-Trunk ist noch nicht konfiguriert." }) } if (trunk.enabled && (!trunk.sipUser || !trunk.password)) { return reply.code(400).send({ error: "SIP-ID und Kennwort sind erforderlich, bevor der Trunk angewendet werden kann.", }) } const extensions = await loadTenantExtensions(tenantId) const dialTargetsById = await loadExtensionDialTargets(tenantId, extensions) const defaultRoute = extensions.find((extension) => extension.id === trunk.defaultRouteExtensionId) const files = await writeAsteriskTrunkConfig({ ...trunk, inboundExtension: defaultRoute?.extension || trunk.inboundExtension, }, extensions, dialTargetsById) let reload: any = null let status: any = null let warning: string | null = null try { reload = await runAsteriskReload(trunk) status = await readAsteriskTrunkStatus(trunk) } catch (error: any) { warning = error?.message || "Asterisk konnte nicht neu geladen werden." } return { generated: true, files, trunk: sanitizeTrunk(trunk), reload, status, warning, } }) server.get("/telephony/trunk-status", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const trunk = await loadActiveTenantTrunk(tenantId) try { return await readAsteriskTrunkStatus(trunk) } catch (error: any) { return reply.code(200).send({ reachable: false, registered: false, hasRegistration: false, registrations: "", message: error?.message || "Asterisk-AMI ist nicht erreichbar.", }) } }) server.get("/telephony/extensions/options", async (req) => { const tenantId = requireTenant(req.user.tenant_id) const tenantUsers = await server.db .select({ id: authUsers.id, email: authUsers.email, firstName: authProfiles.first_name, lastName: authProfiles.last_name, }) .from(authProfiles) .innerJoin(authUsers, eq(authUsers.id, authProfiles.user_id)) .where(and( eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true) )) const tenantTeams = await server.db .select({ id: teams.id, name: teams.name, }) .from(teams) .where(and( eq(teams.tenant, tenantId), eq(teams.archived, false) )) const tenantBranches = await server.db .select({ id: branches.id, name: branches.name, number: branches.number, }) .from(branches) .where(and( eq(branches.tenant, tenantId), eq(branches.archived, false) )) return { users: tenantUsers.map((user) => ({ id: user.id, label: [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email, description: user.email, })), teams: tenantTeams.map((team) => ({ id: team.id, label: team.name, })), branches: tenantBranches.map((branch) => ({ id: branch.id, label: branch.number ? `${branch.number} - ${branch.name}` : branch.name, })), } }) server.get("/telephony/extensions", async (req) => { const tenantId = requireTenant(req.user.tenant_id) const extensions = await loadTenantExtensions(tenantId) const dialTargetsById = await loadExtensionDialTargets(tenantId, extensions) const options = await (async () => { const tenantUsers = await server.db .select({ id: authUsers.id, email: authUsers.email, firstName: authProfiles.first_name, lastName: authProfiles.last_name, }) .from(authProfiles) .innerJoin(authUsers, eq(authUsers.id, authProfiles.user_id)) .where(and( eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true) )) const tenantTeams = await server.db .select({ id: teams.id, name: teams.name }) .from(teams) .where(and(eq(teams.tenant, tenantId), eq(teams.archived, false))) const tenantBranches = await server.db .select({ id: branches.id, name: branches.name, number: branches.number }) .from(branches) .where(and(eq(branches.tenant, tenantId), eq(branches.archived, false))) return { users: new Map(tenantUsers.map((user) => [ user.id, [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email, ])), teams: new Map(tenantTeams.map((team) => [team.id, team.name])), branches: new Map(tenantBranches.map((branch) => [ branch.id, branch.number ? `${branch.number} - ${branch.name}` : branch.name, ])), } })() return { rows: extensions .sort((a, b) => a.extension.localeCompare(b.extension, "de")) .map((extension) => ({ ...sanitizeExtension(extension), targetLabel: extension.targetType === "team" ? options.teams.get(extension.targetTeamId) : extension.targetType === "branch" ? options.branches.get(extension.targetBranchId) : options.users.get(extension.targetUserId), dialTargets: dialTargetsById.get(extension.id) || [], })), } }) server.get("/telephony/extensions/target", async (req) => { const tenantId = requireTenant(req.user.tenant_id) const query = req.query as { targetType?: string; targetId?: string } const targetType = normalizeExtensionTargetType(query.targetType) const targetId = targetType === "user" ? query.targetId : Number(query.targetId) if (!targetId || (targetType !== "user" && !Number.isFinite(Number(targetId)))) { return { extension: null } } const existing = await server.db .select() .from(telephonyExtensions) .where(extensionTargetWhere(tenantId, targetType, targetId)) .limit(1) return { extension: existing[0] ? sanitizeExtension(existing[0]) : null } }) server.put("/telephony/extensions/target", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const body = (req.body || {}) as any const targetType = normalizeExtensionTargetType(bodyString(body, "targetType")) const targetId = resolveExtensionTargetId(targetType, body) const extension = normalizeExtension(bodyString(body, "extension")) if (!targetId) { return reply.code(400).send({ error: "Ziel der Nebenstelle ist erforderlich." }) } if (!extension) { return reply.code(400).send({ error: "Nebenstelle ist erforderlich." }) } const existing = await server.db .select() .from(telephonyExtensions) .where(extensionTargetWhere(tenantId, targetType, targetId)) .limit(1) const duplicateError = await validateExtensionDuplicate(tenantId, extension, existing[0]?.id || null) if (duplicateError) { return reply.code(409).send({ error: duplicateError }) } const displayName = bodyString(body, "displayName") const now = new Date() if (existing[0]) { const [updated] = await server.db .update(telephonyExtensions) .set({ extension, displayName, sipUsername: extension, enabled: body?.enabled !== false, updatedAt: now, updatedBy: req.user.user_id, }) .where(and( eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.id, existing[0].id) )) .returning() return sanitizeExtension(updated) } const [created] = await server.db .insert(telephonyExtensions) .values({ tenantId, targetType, targetUserId: targetType === "user" ? String(targetId) : null, targetTeamId: targetType === "team" ? Number(targetId) : null, targetBranchId: targetType === "branch" ? Number(targetId) : null, extension, displayName, sipUsername: extension, sipPassword: generateSipPassword(), enabled: body?.enabled !== false, createdBy: req.user.user_id, updatedAt: now, updatedBy: req.user.user_id, }) .returning() return reply.code(201).send(sanitizeExtension(created)) }) server.delete("/telephony/extensions/target", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const query = req.query as { targetType?: string; targetId?: string } const targetType = normalizeExtensionTargetType(query.targetType) const targetId = targetType === "user" ? query.targetId : Number(query.targetId) if (!targetId || (targetType !== "user" && !Number.isFinite(Number(targetId)))) { return reply.code(400).send({ error: "Ziel der Nebenstelle ist erforderlich." }) } await server.db .delete(telephonyExtensions) .where(extensionTargetWhere(tenantId, targetType, targetId)) return { deleted: true } }) server.post("/telephony/extensions", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const body = (req.body || {}) as any const targetType = normalizeExtensionTargetType(bodyString(body, "targetType")) const extension = normalizeExtension(bodyString(body, "extension")) if (!extension) { return reply.code(400).send({ error: "Nebenstelle ist erforderlich." }) } const duplicateError = await validateExtensionDuplicate(tenantId, extension) if (duplicateError) { return reply.code(409).send({ error: duplicateError }) } const targetUserId = targetType === "user" ? bodyString(body, "targetUserId") : null const targetTeamId = targetType === "team" ? bodyNumber(body, "targetTeamId") : null const targetBranchId = targetType === "branch" ? bodyNumber(body, "targetBranchId") : null if ( (targetType === "user" && !targetUserId) || (targetType === "team" && !targetTeamId) || (targetType === "branch" && !targetBranchId) ) { return reply.code(400).send({ error: "Ziel der Nebenstelle ist erforderlich." }) } const sipPassword = bodyString(body, "sipPassword") || generateSipPassword() const now = new Date() const [created] = await server.db .insert(telephonyExtensions) .values({ tenantId, targetType, targetUserId, targetTeamId, targetBranchId, extension, displayName: bodyString(body, "displayName"), sipUsername: bodyString(body, "sipUsername") || extension, sipPassword, enabled: body?.enabled !== false, createdBy: req.user.user_id, updatedAt: now, updatedBy: req.user.user_id, }) .returning() return reply.code(201).send(sanitizeExtension(created)) }) server.put("/telephony/extensions/:id", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const params = req.params as { id: string } const body = (req.body || {}) as any const targetType = normalizeExtensionTargetType(bodyString(body, "targetType")) const extension = normalizeExtension(bodyString(body, "extension")) if (!extension) { return reply.code(400).send({ error: "Nebenstelle ist erforderlich." }) } const existing = await server.db .select() .from(telephonyExtensions) .where(and( eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.id, params.id) )) .limit(1) if (!existing[0]) { return reply.code(404).send({ error: "Nebenstelle nicht gefunden." }) } const duplicateError = await validateExtensionDuplicate(tenantId, extension, params.id) if (duplicateError) { return reply.code(409).send({ error: duplicateError }) } const sipPassword = bodyString(body, "sipPassword") const [updated] = await server.db .update(telephonyExtensions) .set({ targetType, targetUserId: targetType === "user" ? bodyString(body, "targetUserId") : null, targetTeamId: targetType === "team" ? bodyNumber(body, "targetTeamId") : null, targetBranchId: targetType === "branch" ? bodyNumber(body, "targetBranchId") : null, extension, displayName: bodyString(body, "displayName"), sipUsername: bodyString(body, "sipUsername") || extension, ...(sipPassword ? { sipPassword } : {}), enabled: body?.enabled !== false, updatedAt: new Date(), updatedBy: req.user.user_id, }) .where(and( eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.id, params.id) )) .returning() return sanitizeExtension(updated) }) server.delete("/telephony/extensions/:id", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const params = req.params as { id: string } const deleted = await server.db .delete(telephonyExtensions) .where(and( eq(telephonyExtensions.tenantId, tenantId), eq(telephonyExtensions.id, params.id) )) .returning() if (!deleted[0]) { return reply.code(404).send({ error: "Nebenstelle nicht gefunden." }) } return { deleted: true } }) server.get("/telephony/calls", async (req) => { const tenantId = requireTenant(req.user.tenant_id) const query = req.query as { limit?: string direction?: string status?: string search?: string localExtension?: string } const limit = Math.min( Math.max(Number(query?.limit || 25), 1), 100 ) const filters = [eq(telephonyCalls.tenantId, tenantId)] if (query.direction === "incoming" || query.direction === "outgoing") { filters.push(eq(telephonyCalls.direction, query.direction)) } if (query.status && query.status !== "all") { filters.push(eq(telephonyCalls.status, query.status)) } if (query.localExtension) { filters.push(eq(telephonyCalls.localExtension, query.localExtension)) } if (query.search?.trim()) { const search = `%${query.search.trim()}%` filters.push(or( ilike(telephonyCalls.remoteNumber, search), ilike(telephonyCalls.remoteDisplayName, search), ilike(telephonyCalls.localExtension, search) )!) } return await server.db .select() .from(telephonyCalls) .where(and(...filters)) .orderBy(desc(telephonyCalls.startedAt)) .limit(limit) }) server.post("/telephony/calls", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const body = (req.body || {}) as any const now = new Date() const startedAt = bodyDate(body, "startedAt") || now const direction = bodyString(body, "direction") === "incoming" ? "incoming" : "outgoing" const status = bodyString(body, "status") || (direction === "incoming" ? "ringing" : "dialing") const [created] = await server.db .insert(telephonyCalls) .values({ tenantId, direction, status, localExtension: bodyString(body, "localExtension"), remoteNumber: bodyString(body, "remoteNumber"), remoteDisplayName: bodyString(body, "remoteDisplayName"), sipCallId: bodyString(body, "sipCallId"), startedAt, createdBy: req.user.user_id, }) .returning() return reply.code(201).send(created) }) server.patch("/telephony/calls/:id", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const params = req.params as { id: string } const body = (req.body || {}) as any const [existing] = await server.db .select() .from(telephonyCalls) .where(and( eq(telephonyCalls.tenantId, tenantId), eq(telephonyCalls.id, params.id) )) .limit(1) if (!existing) { return reply.code(404).send({ error: "Anruf nicht gefunden" }) } const answeredAt = bodyDate(body, "answeredAt") const endedAt = bodyDate(body, "endedAt") const startedAt = existing.startedAt ? new Date(existing.startedAt) : null const computedDuration = endedAt ? durationSeconds(answeredAt || startedAt, endedAt) : null const [updated] = await server.db .update(telephonyCalls) .set({ status: bodyString(body, "status") || existing.status, localExtension: bodyString(body, "localExtension") || existing.localExtension, remoteNumber: bodyString(body, "remoteNumber") || existing.remoteNumber, remoteDisplayName: bodyString(body, "remoteDisplayName") || existing.remoteDisplayName, sipCallId: bodyString(body, "sipCallId") || existing.sipCallId, answeredAt: answeredAt || existing.answeredAt, endedAt: endedAt || existing.endedAt, durationSeconds: computedDuration ?? existing.durationSeconds, updatedAt: new Date(), updatedBy: req.user.user_id, }) .where(and( eq(telephonyCalls.tenantId, tenantId), eq(telephonyCalls.id, params.id) )) .returning() return updated }) }