diff --git a/backend/src/routes/telephony.ts b/backend/src/routes/telephony.ts
index 6710ba0..2546378 100644
--- a/backend/src/routes/telephony.ts
+++ b/backend/src/routes/telephony.ts
@@ -3,7 +3,7 @@ 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, inArray } from "drizzle-orm"
+import { and, desc, eq, inArray, ne } from "drizzle-orm"
import {
authProfileBranches,
authProfiles,
@@ -25,21 +25,15 @@ const telephonyEnabled = () =>
envFlag(process.env.TELEPHONY_ENABLED, process.env.NODE_ENV !== "production")
const asteriskHttpStatusUrl = () =>
- process.env.TELEPHONY_ASTERISK_HTTP_URL || "http://asterisk-dev:8088/ws"
+ process.env.TELEPHONY_ASTERISK_HTTP_URL || ""
const asteriskHttpStatusUrls = () => {
const configuredUrl = asteriskHttpStatusUrl()
- return Array.from(new Set([
- configuredUrl,
- "http://asterisk-dev:8088/ws",
- "http://host.docker.internal:8088/ws",
- `http://127.0.0.1:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`,
- `http://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`,
- ]))
+ return configuredUrl ? [configuredUrl] : []
}
const publicAsteriskWsUrl = () =>
- process.env.TELEPHONY_ASTERISK_WS_URL || `ws://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`
+ process.env.TELEPHONY_ASTERISK_WS_URL || ""
const defaultAsteriskGeneratedDir = () => {
const cwd = process.cwd()
@@ -50,7 +44,7 @@ const asteriskGeneratedDir = () =>
process.env.TELEPHONY_ASTERISK_GENERATED_DIR || defaultAsteriskGeneratedDir()
const defaultAsteriskAmiHost = () =>
- process.env.NODE_ENV === "production" ? "asterisk-dev" : "127.0.0.1"
+ process.env.NODE_ENV === "production" ? "" : "127.0.0.1"
const asteriskAmiConfig = () => ({
host: process.env.TELEPHONY_ASTERISK_AMI_HOST || defaultAsteriskAmiHost(),
@@ -62,19 +56,6 @@ const asteriskAmiConfig = () => ({
const sipDomain = () =>
process.env.TELEPHONY_SIP_DOMAIN || "localhost"
-const testAccounts = () => [
- {
- extension: process.env.TELEPHONY_TEST_EXTENSION || "1001",
- password: process.env.TELEPHONY_TEST_PASSWORD || "fedeo-test-1001",
- displayName: "FEDEO Test 1001",
- },
- {
- extension: process.env.TELEPHONY_TEST_EXTENSION_2 || "1002",
- password: process.env.TELEPHONY_TEST_PASSWORD_2 || "fedeo-test-1002",
- displayName: "FEDEO Test 1002",
- },
-]
-
const trunkProviders = {
telekom: {
key: "telekom",
@@ -498,6 +479,9 @@ const writeAsteriskTrunkConfig = async (trunk: any, extensions: any[] = [], dial
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)
@@ -708,6 +692,59 @@ export default async function telephonyRoutes(server: FastifyInstance) {
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) {
@@ -731,14 +768,34 @@ export default async function telephonyRoutes(server: FastifyInstance) {
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: "local-test",
+ mode: "asterisk",
sipDomain: sipDomain(),
sipWebSocketUrl: publicAsteriskWsUrl(),
echoExtension: process.env.TELEPHONY_ECHO_EXTENSION || "600",
- testAccounts: testAccounts(),
+ accounts: await loadUserSipAccounts(req.user?.tenant_id || null, req.user?.user_id),
external: await externalTelephonyConfig(req.user?.tenant_id || null),
}))
@@ -787,7 +844,9 @@ export default async function telephonyRoutes(server: FastifyInstance) {
reachable: false,
statusUrl: urls[0],
attempts,
- message: lastError?.name === "AbortError"
+ message: !urls.length
+ ? "Asterisk-Status-URL ist nicht konfiguriert."
+ : lastError?.name === "AbortError"
? "Asterisk-Statusabfrage ist abgelaufen."
: (lastError?.message || "Asterisk ist nicht erreichbar."),
}
@@ -1002,17 +1061,166 @@ export default async function telephonyRoutes(server: FastifyInstance) {
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
@@ -1023,6 +1231,11 @@ export default async function telephonyRoutes(server: FastifyInstance) {
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
@@ -1083,6 +1296,11 @@ export default async function telephonyRoutes(server: FastifyInstance) {
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)
diff --git a/docker-compose.yml b/docker-compose.yml
index a5d3703..8eddd42 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -58,19 +58,15 @@ services:
- WEB_PUSH_SUBJECT=${WEB_PUSH_SUBJECT:-mailto:admin@example.com}
- NODE_EXPORTER_URL=${NODE_EXPORTER_URL:-http://node-exporter:9100}
- TELEPHONY_ENABLED=${TELEPHONY_ENABLED:-false}
- - TELEPHONY_ASTERISK_HTTP_URL=${TELEPHONY_ASTERISK_HTTP_URL:-http://asterisk-dev:8088/ws}
- - TELEPHONY_ASTERISK_WS_URL=${TELEPHONY_ASTERISK_WS_URL:-ws://localhost:8088/ws}
+ - TELEPHONY_ASTERISK_HTTP_URL=${TELEPHONY_ASTERISK_HTTP_URL:-}
+ - TELEPHONY_ASTERISK_WS_URL=${TELEPHONY_ASTERISK_WS_URL:-}
- TELEPHONY_SIP_DOMAIN=${TELEPHONY_SIP_DOMAIN:-localhost}
- - TELEPHONY_TEST_EXTENSION=${TELEPHONY_TEST_EXTENSION:-1001}
- - TELEPHONY_TEST_PASSWORD=${TELEPHONY_TEST_PASSWORD:-fedeo-test-1001}
- - TELEPHONY_TEST_EXTENSION_2=${TELEPHONY_TEST_EXTENSION_2:-1002}
- - TELEPHONY_TEST_PASSWORD_2=${TELEPHONY_TEST_PASSWORD_2:-fedeo-test-1002}
- TELEPHONY_ECHO_EXTENSION=${TELEPHONY_ECHO_EXTENSION:-600}
- TELEPHONY_EXTERNAL_PROVIDER=${TELEPHONY_EXTERNAL_PROVIDER:-}
- TELEPHONY_EXTERNAL_ENABLED=${TELEPHONY_EXTERNAL_ENABLED:-false}
- TELEPHONY_EXTERNAL_INBOUND_EXTENSION=${TELEPHONY_EXTERNAL_INBOUND_EXTENSION:-1001}
- TELEPHONY_ASTERISK_GENERATED_DIR=${TELEPHONY_ASTERISK_GENERATED_DIR:-/var/lib/fedeo/asterisk/generated}
- - TELEPHONY_ASTERISK_AMI_HOST=${TELEPHONY_ASTERISK_AMI_HOST:-asterisk-dev}
+ - TELEPHONY_ASTERISK_AMI_HOST=${TELEPHONY_ASTERISK_AMI_HOST:-}
- TELEPHONY_ASTERISK_AMI_PORT=${TELEPHONY_ASTERISK_AMI_PORT:-5038}
- TELEPHONY_ASTERISK_AMI_USER=${TELEPHONY_ASTERISK_AMI_USER:-fedeo}
- TELEPHONY_ASTERISK_AMI_PASSWORD=${TELEPHONY_ASTERISK_AMI_PASSWORD:-fedeo-ami-dev}
@@ -111,39 +107,6 @@ services:
networks:
- traefik
- asterisk-dev:
- image: ${ASTERISK_IMAGE:-andrius/asterisk:20}
- restart: unless-stopped
- profiles:
- - telephony-dev
- environment:
- - TELEPHONY_TELEKOM_ENABLED=${TELEPHONY_TELEKOM_ENABLED:-false}
- - TELEPHONY_TELEKOM_REGISTRAR=${TELEPHONY_TELEKOM_REGISTRAR:-tel.t-online.de}
- - TELEPHONY_TELEKOM_SIP_USER=${TELEPHONY_TELEKOM_SIP_USER:-}
- - TELEPHONY_TELEKOM_AUTH_USER=${TELEPHONY_TELEKOM_AUTH_USER:-}
- - TELEPHONY_TELEKOM_PASSWORD=${TELEPHONY_TELEKOM_PASSWORD:-}
- - TELEPHONY_TELEKOM_CALLER_ID=${TELEPHONY_TELEKOM_CALLER_ID:-}
- - TELEPHONY_TELEKOM_INBOUND_EXTENSION=${TELEPHONY_TELEKOM_INBOUND_EXTENSION:-1001}
- - TELEPHONY_TELEKOM_OUTBOUND_PREFIX=${TELEPHONY_TELEKOM_OUTBOUND_PREFIX:-0}
- - TELEPHONY_ASTERISK_EXTERNAL_SIGNALING_ADDRESS=${TELEPHONY_ASTERISK_EXTERNAL_SIGNALING_ADDRESS:-}
- - TELEPHONY_ASTERISK_EXTERNAL_MEDIA_ADDRESS=${TELEPHONY_ASTERISK_EXTERNAL_MEDIA_ADDRESS:-}
- - ASTERISK_GENERATED_DIR=/etc/asterisk/generated
- command:
- - /bin/sh
- - -c
- - /usr/local/bin/render-asterisk-config.sh && asterisk -f
- volumes:
- - ./telephony/asterisk:/etc/asterisk
- - ./telephony/generated:/etc/asterisk/generated
- - ./telephony/render-asterisk-config.sh:/usr/local/bin/render-asterisk-config.sh:ro
- ports:
- - "${TELEPHONY_DEV_WS_PORT:-8088}:8088"
- - "${TELEPHONY_DEV_AMI_PORT:-5038}:5038"
- - "${TELEPHONY_DEV_SIP_PORT:-5060}:5060/udp"
- - "${TELEPHONY_DEV_RTP_MIN_PORT:-10000}-${TELEPHONY_DEV_RTP_MAX_PORT:-10100}:10000-10100/udp"
- networks:
- - traefik
-
matrix-db:
image: postgres:16-alpine
restart: unless-stopped
diff --git a/frontend/components/EntityEdit.vue b/frontend/components/EntityEdit.vue
index ac771bf..98b39c3 100644
--- a/frontend/components/EntityEdit.vue
+++ b/frontend/components/EntityEdit.vue
@@ -400,6 +400,32 @@ const canArchiveItem = computed(() => {
return true
})
+const telephonyExtensionTarget = computed(() => {
+ if (!item.value?.id) return null
+
+ if (type === "teams") {
+ return {
+ targetType: "team",
+ targetId: item.value.id,
+ displayName: item.value.name || "Team",
+ title: "Telefonie",
+ description: "Lege fest, unter welcher Nebenstelle dieses Team erreichbar ist."
+ }
+ }
+
+ if (type === "branches") {
+ return {
+ targetType: "branch",
+ targetId: item.value.id,
+ displayName: item.value.name || "Niederlassung",
+ title: "Telefonie",
+ description: "Lege fest, unter welcher Nebenstelle diese Niederlassung erreichbar ist."
+ }
+ }
+
+ return null
+})
+
const createItem = async () => {
let ret = null
@@ -1036,6 +1062,16 @@ const updateItem = async () => {
+
+
+ {{ description }}
+
- Kommunikation
-
- Asterisk-Status, Browser-Verbindung und lokale Test-Nebenstellen.
-
- SIP-Trunk-Anbindung über den lokalen Asterisk.
-
- {{ config?.external?.provider || "-" }}
-
- {{ config?.external?.registrar || "-" }}
-
- {{ config?.external?.inboundExtension || "-" }}
-
- Prefix {{ config?.external?.outboundPrefix || "0" }}
-
- Backend-Abfrage gegen den lokalen Telefonie-Stack.
-
- {{ status?.reachable ? "Erreichbar" : (status?.enabled ? "Nicht erreichbar" : "Deaktiviert") }}
-
- {{ config?.provider || "asterisk" }}
-
- {{ config?.echoExtension || "600" }}
-
- Geprüfte Status-Ziele
-
- Asterisk läuft getrennt vom normalen Stack im Compose-Profil.
-
- Prüft, ob FEDEO den SIP-WebSocket im Browser öffnen kann.
-
- Für lokale Call-Tests zwischen zwei Browser-Sessions.
-
- Aktuelle Registrierung und letzte SIP-Ereignisse.
-
- Noch keine SIP-Ereignisse vorhanden.
-
- Zuletzt geprüft: {{ lastUpdated.toLocaleString("de-DE") }}
-
+ {{ title }}
+
+
- Telefonie Setup
-
-
- Externe Telefonie
-
-
- Asterisk-Status
-
-
- Lokalen Stack starten
-
-
- Browser-Test
-
-
- Test-Nebenstellen
-
-
- {{ account.displayName }}
-
-
-
-
- SIP-Diagnose
-
-
-
-
+ Für deinen Benutzer ist noch keine Nebenstelle hinterlegt. +
SIP-Trunk, Standardroute und öffentliche Erreichbarkeit.
+Registrierung des externen Anschlusses.
+Die Nebenstellen werden direkt am Benutzer, Team oder an der Niederlassung gepflegt.
+Konfiguriere den SIP-Trunk für externe Anrufe über Asterisk.
-- {{ activeTelephonyProvider.description }} -
- -Ordne Benutzer, Teams und Niederlassungen routbaren Nebenstellen zu.
-