1466 lines
54 KiB
TypeScript
1466 lines
54 KiB
TypeScript
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<string, string[]>) => {
|
|
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<string, string[]>()) => {
|
|
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<string, string[]>()
|
|
const userExtensionByUserId = new Map<string, string>()
|
|
|
|
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<string, string>()
|
|
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<string>()
|
|
|
|
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
|
|
})
|
|
}
|