From f6fb60700837dbd7c819bd5ac318b4d723f7c5e0 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Thu, 21 May 2026 16:19:56 +0200 Subject: [PATCH] KI-AGENT: Telefonie-Trunk in Firmeneinstellungen verschieben --- .../db/migrations/0045_telephony_trunks.sql | 50 ++++++ backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/index.ts | 1 + backend/db/schema/telephony_trunks.ts | 48 +++++ backend/src/routes/telephony.ts | 131 ++++++++++++-- docs/telekom-telefonie.md | 19 +- frontend/pages/settings/tenant.vue | 167 +++++++++++++++++- 7 files changed, 408 insertions(+), 15 deletions(-) create mode 100644 backend/db/migrations/0045_telephony_trunks.sql create mode 100644 backend/db/schema/telephony_trunks.ts diff --git a/backend/db/migrations/0045_telephony_trunks.sql b/backend/db/migrations/0045_telephony_trunks.sql new file mode 100644 index 0000000..d236c53 --- /dev/null +++ b/backend/db/migrations/0045_telephony_trunks.sql @@ -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"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 5374ef3..95fe709 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1780160400000, "tag": "0044_telephony_calls", "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1780164000000, + "tag": "0045_telephony_trunks", + "breakpoints": true } ] } diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 3cf78ed..178d529 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -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" diff --git a/backend/db/schema/telephony_trunks.ts b/backend/db/schema/telephony_trunks.ts new file mode 100644 index 0000000..4287c19 --- /dev/null +++ b/backend/db/schema/telephony_trunks.ts @@ -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 diff --git a/backend/src/routes/telephony.ts b/backend/src/routes/telephony.ts index 3acd0af..e607eaf 100644 --- a/backend/src/routes/telephony.ts +++ b/backend/src/routes/telephony.ts @@ -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( diff --git a/docs/telekom-telefonie.md b/docs/telekom-telefonie.md index a9f33a4..14f7b5c 100644 --- a/docs/telekom-telefonie.md +++ b/docs/telekom-telefonie.md @@ -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=#@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 diff --git a/frontend/pages/settings/tenant.vue b/frontend/pages/settings/tenant.vue index 8bce630..bcbdc92 100644 --- a/frontend/pages/settings/tenant.vue +++ b/frontend/pages/settings/tenant.vue @@ -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() +})