KI-AGENT: Telefonie-Trunk in Firmeneinstellungen verschieben

This commit is contained in:
2026-05-21 16:19:56 +02:00
parent ee6c2d7420
commit f6fb607008
7 changed files with 408 additions and 15 deletions

View File

@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS "telephony_trunks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"provider" text DEFAULT 'telekom' NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"registrar" text DEFAULT 'tel.t-online.de' NOT NULL,
"sip_user" text,
"auth_user" text,
"password" text,
"caller_id" text,
"inbound_extension" text DEFAULT '1001' NOT NULL,
"outbound_prefix" text DEFAULT '0' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "telephony_trunks_tenant_provider_idx"
ON "telephony_trunks" USING btree ("tenant_id", "provider");

View File

@@ -316,6 +316,13 @@
"when": 1780160400000,
"tag": "0044_telephony_calls",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1780164000000,
"tag": "0045_telephony_trunks",
"breakpoints": true
}
]
}

View File

@@ -76,6 +76,7 @@ export * from "./tasks"
export * from "./teams"
export * from "./taxtypes"
export * from "./telephony_calls"
export * from "./telephony_trunks"
export * from "./tenants"
export * from "./texttemplates"
export * from "./units"

View File

@@ -0,0 +1,48 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
boolean,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const telephonyTrunks = pgTable(
"telephony_trunks",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
provider: text("provider").notNull().default("telekom"),
enabled: boolean("enabled").notNull().default(false),
registrar: text("registrar").notNull().default("tel.t-online.de"),
sipUser: text("sip_user"),
authUser: text("auth_user"),
password: text("password"),
callerId: text("caller_id"),
inboundExtension: text("inbound_extension").notNull().default("1001"),
outboundPrefix: text("outbound_prefix").notNull().default("0"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => authUsers.id),
updatedBy: uuid("updated_by").references(() => authUsers.id),
},
(table) => ({
tenantProviderIdx: uniqueIndex("telephony_trunks_tenant_provider_idx")
.on(table.tenantId, table.provider),
})
)
export type TelephonyTrunk = typeof telephonyTrunks.$inferSelect
export type NewTelephonyTrunk = typeof telephonyTrunks.$inferInsert

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"
import { and, desc, eq } from "drizzle-orm"
import { telephonyCalls } from "../../db/schema"
import { telephonyCalls, telephonyTrunks } from "../../db/schema"
const envFlag = (value: string | undefined, fallback: boolean) => {
if (value === undefined || value === "") return fallback
@@ -43,7 +43,20 @@ const testAccounts = () => [
},
]
const externalTelephonyConfig = () => {
const sanitizeTrunk = (trunk: any) => ({
id: trunk?.id || null,
provider: trunk?.provider || "telekom",
enabled: Boolean(trunk?.enabled),
registrar: trunk?.registrar || "tel.t-online.de",
sipUser: trunk?.sipUser || "",
authUser: trunk?.authUser || "",
passwordConfigured: Boolean(trunk?.password),
callerId: trunk?.callerId || "",
inboundExtension: trunk?.inboundExtension || "1001",
outboundPrefix: trunk?.outboundPrefix || "0",
})
const envExternalTelephonyConfig = () => {
const provider = process.env.TELEPHONY_EXTERNAL_PROVIDER || (
envFlag(process.env.TELEPHONY_TELEKOM_ENABLED, false) ? "telekom" : ""
)
@@ -107,16 +120,50 @@ const durationSeconds = (startedAt?: Date | null, endedAt?: Date | null) => {
}
export default async function telephonyRoutes(server: FastifyInstance) {
server.get("/telephony/config", async () => ({
enabled: telephonyEnabled(),
provider: "asterisk",
mode: "local-test",
sipDomain: sipDomain(),
sipWebSocketUrl: publicAsteriskWsUrl(),
echoExtension: process.env.TELEPHONY_ECHO_EXTENSION || "600",
testAccounts: testAccounts(),
external: externalTelephonyConfig(),
}))
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 externalTelephonyConfig = async (tenantId: number | null) => {
const trunk = await loadTenantTrunk(tenantId)
if (trunk) {
return {
enabled: trunk.enabled,
provider: trunk.provider,
inboundExtension: trunk.inboundExtension,
outboundPrefix: trunk.outboundPrefix,
registrar: trunk.registrar,
sipUserConfigured: Boolean(trunk.sipUser),
authUserConfigured: Boolean(trunk.authUser),
passwordConfigured: Boolean(trunk.password),
callerIdConfigured: Boolean(trunk.callerId),
}
}
return envExternalTelephonyConfig()
}
server.get("/telephony/config", async (req) => ({
enabled: telephonyEnabled(),
provider: "asterisk",
mode: "local-test",
sipDomain: sipDomain(),
sipWebSocketUrl: publicAsteriskWsUrl(),
echoExtension: process.env.TELEPHONY_ECHO_EXTENSION || "600",
testAccounts: testAccounts(),
external: await externalTelephonyConfig(req.user?.tenant_id || null),
}))
server.get("/telephony/status", async () => {
const enabled = telephonyEnabled()
@@ -169,6 +216,66 @@ export default async function telephonyRoutes(server: FastifyInstance) {
}
})
server.get("/telephony/trunk-config", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const trunk = await loadTenantTrunk(tenantId)
return sanitizeTrunk(trunk)
})
server.put("/telephony/trunk-config", async (req, reply) => {
const tenantId = requireTenant(req.user.tenant_id)
const body = (req.body || {}) as any
const existing = await loadTenantTrunk(tenantId)
const password = bodyString(body, "password")
const clearPassword = body?.clearPassword === true
const now = new Date()
const values = {
tenantId,
provider: "telekom",
enabled: Boolean(body.enabled),
registrar: bodyString(body, "registrar") || "tel.t-online.de",
sipUser: bodyString(body, "sipUser"),
authUser: bodyString(body, "authUser"),
callerId: bodyString(body, "callerId"),
inboundExtension: bodyString(body, "inboundExtension") || "1001",
outboundPrefix: bodyString(body, "outboundPrefix") || "0",
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, "telekom")
))
.returning()
return sanitizeTrunk(updated)
}
const [created] = await server.db
.insert(telephonyTrunks)
.values({
...values,
createdAt: now,
createdBy: req.user.user_id,
})
.returning()
return sanitizeTrunk(created)
})
server.get("/telephony/calls", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const limit = Math.min(

View File

@@ -1,6 +1,6 @@
# Telekom-Telefonie in FEDEO
FEDEO kann den lokalen Asterisk-Stack als Übergang zur externen Telefonie nutzen. Die Telekom-Anbindung wird aus Umgebungsvariablen erzeugt, damit Zugangsdaten nicht im Repository landen.
FEDEO kann den lokalen Asterisk-Stack als Übergang zur externen Telefonie nutzen. Die Telekom-Anbindung wird mandantenbezogen in FEDEO unter **Firmeneinstellungen -> Integrationen -> Telefonie-Trunk** gepflegt.
## Zugangsdaten
@@ -12,7 +12,22 @@ Wenn dein Anschluss statt einer separaten SIP-ID die klassischen Zugangsdaten nu
TELEPHONY_TELEKOM_AUTH_USER=<anschlusskennung><zugangsnummer>#<mitbenutzernummer>@t-online.de
```
## `.env`
## FEDEO Einstellungen
Öffne **Firmeneinstellungen -> Integrationen** und fülle den Bereich **Telefonie-Trunk** aus:
- `SIP-ID / Rufnummer`: Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen
- `Kennwort`: persönliches Kennwort oder SIP-Kennwort
- `Auth-User`: optional, falls dein Anschluss nicht direkt mit der SIP-ID authentifiziert
- `Absendernummer`: optional, meist identisch zur SIP-ID
- `Eingehende Nebenstelle`: lokale FEDEO/Asterisk-Nebenstelle, z. B. `1001`
- `Ausgehender Prefix`: standardmäßig `0`
Das Kennwort wird nicht wieder an das Frontend zurückgegeben. In der Oberfläche siehst du nur, ob ein Kennwort gespeichert ist.
## `.env` Fallback
Die `.env`-Werte bleiben nur als lokaler Fallback für den Asterisk-Teststack erhalten, falls keine mandantenbezogene Konfiguration gepflegt ist.
```env
TELEPHONY_ENABLED=true

View File

@@ -132,6 +132,20 @@ const mcpTokenForm = reactive({
name: "Codex MCP Token",
expiresAt: ""
})
const telephonyTrunkLoading = ref(false)
const telephonyTrunkSaving = ref(false)
const telephonyTrunkForm = reactive({
enabled: false,
registrar: "tel.t-online.de",
sipUser: "",
authUser: "",
password: "",
passwordConfigured: false,
clearPassword: false,
callerId: "",
inboundExtension: "1001",
outboundPrefix: "0"
})
const setupPage = async () => {
itemInfo.value = auth.activeTenantData
@@ -193,6 +207,71 @@ const loadMcpTokens = async () => {
}
}
const loadTelephonyTrunk = async () => {
telephonyTrunkLoading.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config")
telephonyTrunkForm.enabled = Boolean(res?.enabled)
telephonyTrunkForm.registrar = res?.registrar || "tel.t-online.de"
telephonyTrunkForm.sipUser = res?.sipUser || ""
telephonyTrunkForm.authUser = res?.authUser || ""
telephonyTrunkForm.password = ""
telephonyTrunkForm.passwordConfigured = Boolean(res?.passwordConfigured)
telephonyTrunkForm.clearPassword = false
telephonyTrunkForm.callerId = res?.callerId || ""
telephonyTrunkForm.inboundExtension = res?.inboundExtension || "1001"
telephonyTrunkForm.outboundPrefix = res?.outboundPrefix || "0"
} catch (error) {
toast.add({ title: "Telefonie-Trunk konnte nicht geladen werden", color: "error" })
} finally {
telephonyTrunkLoading.value = false
}
}
const saveTelephonyTrunk = async () => {
if (telephonyTrunkForm.enabled && (!telephonyTrunkForm.sipUser?.trim() || (!telephonyTrunkForm.password?.trim() && !telephonyTrunkForm.passwordConfigured))) {
toast.add({
title: "Telekom-Zugang unvollständig",
description: "Bitte gib mindestens SIP-ID und Kennwort an.",
color: "orange"
})
return
}
telephonyTrunkSaving.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config", {
method: "PUT",
body: {
enabled: telephonyTrunkForm.enabled,
registrar: telephonyTrunkForm.registrar,
sipUser: telephonyTrunkForm.sipUser,
authUser: telephonyTrunkForm.authUser,
password: telephonyTrunkForm.password,
clearPassword: telephonyTrunkForm.clearPassword,
callerId: telephonyTrunkForm.callerId,
inboundExtension: telephonyTrunkForm.inboundExtension,
outboundPrefix: telephonyTrunkForm.outboundPrefix
}
})
telephonyTrunkForm.password = ""
telephonyTrunkForm.passwordConfigured = Boolean(res?.passwordConfigured)
telephonyTrunkForm.clearPassword = false
toast.add({ title: "Telefonie-Trunk gespeichert", color: "success" })
} catch (error) {
toast.add({
title: "Telefonie-Trunk konnte nicht gespeichert werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
telephonyTrunkSaving.value = false
}
}
const createMcpToken = async () => {
if (!mcpTokenForm.name?.trim()) {
toast.add({ title: "Name fehlt", description: "Bitte gib einen Namen für den Token an.", color: "orange" })
@@ -246,7 +325,10 @@ const deactivateMcpToken = async (token) => {
}
setupPage()
onMounted(loadMcpTokens)
onMounted(() => {
loadMcpTokens()
loadTelephonyTrunk()
})
</script>
<template>
@@ -356,6 +438,89 @@ onMounted(loadMcpTokens)
</div>
<div v-else-if="item.label === 'Integrationen'">
<UCard class="mt-5">
<div class="mb-8 space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 class="text-base font-semibold text-highlighted">Telefonie-Trunk</h3>
<p class="text-sm text-muted">Konfiguriere den Telekom-Anschluss für externe Anrufe über Asterisk.</p>
</div>
<UBadge :color="telephonyTrunkForm.enabled ? 'success' : 'neutral'" variant="soft">
{{ telephonyTrunkForm.enabled ? "Aktiv" : "Inaktiv" }}
</UBadge>
</div>
<UAlert
title="Telekom Zugangsdaten"
color="neutral"
variant="outline"
>
<template #description>
<p class="text-sm">
Die SIP-ID ist meistens deine Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen.
Falls dein Anschluss die klassischen Zugangsdaten nutzt, kannst du den Auth-User aus
Anschlusskennung, Zugangsnummer, #, Mitbenutzernummer und @t-online.de bilden.
</p>
</template>
</UAlert>
<div class="grid gap-4 md:grid-cols-2">
<UFormField label="Trunk aktivieren">
<USwitch v-model="telephonyTrunkForm.enabled" />
</UFormField>
<UFormField label="Registrar">
<UInput v-model="telephonyTrunkForm.registrar" placeholder="tel.t-online.de" />
</UFormField>
<UFormField label="SIP-ID / Rufnummer">
<UInput v-model="telephonyTrunkForm.sipUser" placeholder="0301234567" />
</UFormField>
<UFormField label="Auth-User">
<UInput v-model="telephonyTrunkForm.authUser" placeholder="Optional" />
</UFormField>
<UFormField :label="telephonyTrunkForm.passwordConfigured ? 'Kennwort ersetzen' : 'Kennwort'">
<UInput
v-model="telephonyTrunkForm.password"
type="password"
:placeholder="telephonyTrunkForm.passwordConfigured ? 'Bereits gespeichert' : 'Persönliches Kennwort'"
/>
</UFormField>
<UFormField label="Gespeichertes Kennwort löschen">
<USwitch
v-model="telephonyTrunkForm.clearPassword"
:disabled="!telephonyTrunkForm.passwordConfigured"
/>
</UFormField>
<UFormField label="Absendernummer">
<UInput v-model="telephonyTrunkForm.callerId" placeholder="Optional, z. B. 0301234567" />
</UFormField>
<UFormField label="Eingehende Nebenstelle">
<UInput v-model="telephonyTrunkForm.inboundExtension" placeholder="1001" />
</UFormField>
<UFormField label="Ausgehender Prefix">
<UInput v-model="telephonyTrunkForm.outboundPrefix" placeholder="0" />
</UFormField>
</div>
<div class="flex flex-wrap gap-2">
<UButton
icon="i-heroicons-arrow-path"
variant="outline"
:loading="telephonyTrunkLoading"
@click="loadTelephonyTrunk"
>
Laden
</UButton>
<UButton
icon="i-heroicons-check"
:loading="telephonyTrunkSaving"
@click="saveTelephonyTrunk"
>
Telefonie-Trunk speichern
</UButton>
</div>
</div>
<USeparator class="mb-8" />
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>