From b44c8d453aacc1cd634a38dfd82bbd86a420b219 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Fri, 22 May 2026 15:24:10 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Nebenstellen=20und=20Standardroute?= =?UTF-8?q?=20f=C3=BCr=20Telefonie=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0047_telephony_extensions.sql | 92 ++++ backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/index.ts | 1 + backend/db/schema/telephony_extensions.ts | 53 +++ backend/db/schema/telephony_trunks.ts | 2 + backend/src/routes/telephony.ts | 425 +++++++++++++++++- frontend/pages/settings/tenant.vue | 363 +++++++++++++++ telephony/asterisk/extensions.conf | 1 + telephony/asterisk/pjsip.conf | 1 + 9 files changed, 941 insertions(+), 4 deletions(-) create mode 100644 backend/db/migrations/0047_telephony_extensions.sql create mode 100644 backend/db/schema/telephony_extensions.ts diff --git a/backend/db/migrations/0047_telephony_extensions.sql b/backend/db/migrations/0047_telephony_extensions.sql new file mode 100644 index 0000000..a5ddc53 --- /dev/null +++ b/backend/db/migrations/0047_telephony_extensions.sql @@ -0,0 +1,92 @@ +CREATE TABLE IF NOT EXISTS "telephony_extensions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" bigint NOT NULL, + "target_type" text NOT NULL, + "target_user_id" uuid, + "target_team_id" bigint, + "target_branch_id" bigint, + "extension" text NOT NULL, + "display_name" text, + "sip_username" text, + "sip_password" text, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone, + "created_by" uuid, + "updated_by" uuid +); + +ALTER TABLE "telephony_trunks" + ADD COLUMN IF NOT EXISTS "default_route_extension_id" uuid; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_tenant_id_tenants_id_fk' + ) THEN + ALTER TABLE "telephony_extensions" + ADD CONSTRAINT "telephony_extensions_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_extensions_target_user_id_auth_users_id_fk' + ) THEN + ALTER TABLE "telephony_extensions" + ADD CONSTRAINT "telephony_extensions_target_user_id_auth_users_id_fk" + FOREIGN KEY ("target_user_id") REFERENCES "public"."auth_users"("id") + ON DELETE cascade ON UPDATE no action; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_team_id_teams_id_fk' + ) THEN + ALTER TABLE "telephony_extensions" + ADD CONSTRAINT "telephony_extensions_target_team_id_teams_id_fk" + FOREIGN KEY ("target_team_id") REFERENCES "public"."teams"("id") + ON DELETE cascade ON UPDATE no action; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_branch_id_branches_id_fk' + ) THEN + ALTER TABLE "telephony_extensions" + ADD CONSTRAINT "telephony_extensions_target_branch_id_branches_id_fk" + FOREIGN KEY ("target_branch_id") REFERENCES "public"."branches"("id") + ON DELETE cascade ON UPDATE no action; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_created_by_auth_users_id_fk' + ) THEN + ALTER TABLE "telephony_extensions" + ADD CONSTRAINT "telephony_extensions_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_extensions_updated_by_auth_users_id_fk' + ) THEN + ALTER TABLE "telephony_extensions" + ADD CONSTRAINT "telephony_extensions_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; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_default_route_extension_id_telephony_extensions_id_fk' + ) THEN + ALTER TABLE "telephony_trunks" + ADD CONSTRAINT "telephony_trunks_default_route_extension_id_telephony_extensions_id_fk" + FOREIGN KEY ("default_route_extension_id") REFERENCES "public"."telephony_extensions"("id") + ON DELETE set null ON UPDATE no action; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS "telephony_extensions_tenant_extension_idx" + ON "telephony_extensions" USING btree ("tenant_id", "extension"); + +CREATE INDEX IF NOT EXISTS "telephony_extensions_tenant_target_idx" + ON "telephony_extensions" USING btree ("tenant_id", "target_type"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 68522a1..ab3a019 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -330,6 +330,13 @@ "when": 1780167600000, "tag": "0046_telephony_trunk_nat", "breakpoints": true + }, + { + "idx": 47, + "version": "7", + "when": 1780171200000, + "tag": "0047_telephony_extensions", + "breakpoints": true } ] } diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 178d529..b082860 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_extensions" export * from "./telephony_trunks" export * from "./tenants" export * from "./texttemplates" diff --git a/backend/db/schema/telephony_extensions.ts b/backend/db/schema/telephony_extensions.ts new file mode 100644 index 0000000..c7a57df --- /dev/null +++ b/backend/db/schema/telephony_extensions.ts @@ -0,0 +1,53 @@ +import { + pgTable, + uuid, + bigint, + text, + timestamp, + boolean, + index, + uniqueIndex, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" +import { teams } from "./teams" +import { branches } from "./branches" + +export const telephonyExtensions = pgTable( + "telephony_extensions", + { + id: uuid("id").primaryKey().defaultRandom(), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + + targetType: text("target_type").notNull(), + targetUserId: uuid("target_user_id").references(() => authUsers.id, { onDelete: "cascade" }), + targetTeamId: bigint("target_team_id", { mode: "number" }).references(() => teams.id, { onDelete: "cascade" }), + targetBranchId: bigint("target_branch_id", { mode: "number" }).references(() => branches.id, { onDelete: "cascade" }), + + extension: text("extension").notNull(), + displayName: text("display_name"), + sipUsername: text("sip_username"), + sipPassword: text("sip_password"), + enabled: boolean("enabled").notNull().default(true), + + 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) => ({ + tenantExtensionIdx: uniqueIndex("telephony_extensions_tenant_extension_idx") + .on(table.tenantId, table.extension), + tenantTargetIdx: index("telephony_extensions_tenant_target_idx") + .on(table.tenantId, table.targetType), + }) +) + +export type TelephonyExtension = typeof telephonyExtensions.$inferSelect +export type NewTelephonyExtension = typeof telephonyExtensions.$inferInsert diff --git a/backend/db/schema/telephony_trunks.ts b/backend/db/schema/telephony_trunks.ts index c20f5bf..c088a00 100644 --- a/backend/db/schema/telephony_trunks.ts +++ b/backend/db/schema/telephony_trunks.ts @@ -10,6 +10,7 @@ import { import { tenants } from "./tenants" import { authUsers } from "./auth_users" +import { telephonyExtensions } from "./telephony_extensions" export const telephonyTrunks = pgTable( "telephony_trunks", @@ -29,6 +30,7 @@ export const telephonyTrunks = pgTable( password: text("password"), callerId: text("caller_id"), inboundExtension: text("inbound_extension").notNull().default("1001"), + defaultRouteExtensionId: uuid("default_route_extension_id").references(() => telephonyExtensions.id, { onDelete: "set null" }), outboundPrefix: text("outbound_prefix").notNull().default("0"), externalSignalingAddress: text("external_signaling_address"), externalMediaAddress: text("external_media_address"), diff --git a/backend/src/routes/telephony.ts b/backend/src/routes/telephony.ts index 330df83..6710ba0 100644 --- a/backend/src/routes/telephony.ts +++ b/backend/src/routes/telephony.ts @@ -2,8 +2,19 @@ import { FastifyInstance } from "fastify" import { promises as fs } from "node:fs" import net from "node:net" import path from "node:path" -import { and, desc, eq } from "drizzle-orm" -import { telephonyCalls, telephonyTrunks } from "../../db/schema" +import { randomBytes } from "node:crypto" +import { and, desc, eq, inArray } 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 @@ -99,6 +110,7 @@ const sanitizeTrunk = (trunk: any) => ({ 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 || "", @@ -155,6 +167,26 @@ const bodyString = (body: any, key: string) => { 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 @@ -319,6 +351,82 @@ const renderWebRtcConfig = (trunk: any) => { ].join("\n") } +const extensionDialTarget = (extension: any, dialTargets: string[]) => { + const dial = dialTargets.length + ? `Dial(${dialTargets.map((target) => `PJSIP/${target}`).join("&")},30)` + : "Hangup()" + + return [ + `exten => ${extension.extension},1,NoOp(FEDEO Nebenstelle ${extension.extension}: ${extension.displayName || extension.targetType})`, + ` same => n,${dial}`, + " same => n,Hangup()", + "", + ].join("\n") +} + +const renderTelephonyExtensionsPjsipConfig = (extensions: any[]) => { + const userExtensions = extensions.filter((extension) => + extension.enabled && extension.targetType === "user" + ) + + if (!userExtensions.length) { + return [ + "; Von FEDEO generiert.", + "; Keine Benutzer-Nebenstellen konfiguriert.", + "", + ].join("\n") + } + + return [ + "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", + ...userExtensions.flatMap((extension) => { + const sipUsername = asteriskValue(extension.sipUsername) || extension.extension + const sipPassword = asteriskValue(extension.sipPassword) + const displayName = asteriskValue(extension.displayName) || `FEDEO ${extension.extension}` + + return [ + `[${extension.extension}](fedeo-webrtc)`, + `auth=${extension.extension}-auth`, + `aors=${extension.extension}`, + `callerid=${displayName} <${extension.extension}>`, + "", + `[${extension.extension}-auth]`, + "type=auth", + "auth_type=userpass", + `username=${sipUsername}`, + `password=${sipPassword}`, + "", + `[${extension.extension}]`, + "type=aor", + "max_contacts=5", + "remove_existing=yes", + "support_path=yes", + "", + ] + }), + ].join("\n") +} + +const renderTelephonyExtensionsDialplanConfig = (extensions: any[], dialTargetsById: Map) => { + const enabledExtensions = extensions.filter((extension) => extension.enabled) + + if (!enabledExtensions.length) { + return [ + "; Von FEDEO generiert.", + "; Keine Nebenstellen konfiguriert.", + "", + ].join("\n") + } + + return [ + "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", + "[fedeo-local]", + ...enabledExtensions.map((extension) => + extensionDialTarget(extension, dialTargetsById.get(extension.id) || []) + ), + ].join("\n") +} + const renderTelekomTransportConfig = (trunk: any) => { const externalSignalingAddress = asteriskValue(trunk?.externalSignalingAddress) const externalMediaAddress = asteriskValue(trunk?.externalMediaAddress || trunk?.externalSignalingAddress) @@ -348,7 +456,7 @@ const renderTelekomTransportConfig = (trunk: any) => { ].join("\n") } -const writeAsteriskTrunkConfig = async (trunk: any) => { +const writeAsteriskTrunkConfig = async (trunk: any, extensions: any[] = [], dialTargetsById = new Map()) => { const targetDir = asteriskGeneratedDir() await fs.mkdir(targetDir, { recursive: true }) @@ -369,6 +477,14 @@ const writeAsteriskTrunkConfig = async (trunk: any) => { 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) => { @@ -485,6 +601,113 @@ export default async function telephonyRoutes(server: FastifyInstance) { || null } + const loadTenantExtensions = async (tenantId: number | null) => { + if (!tenantId) return [] + + return await server.db + .select() + .from(telephonyExtensions) + .where(eq(telephonyExtensions.tenantId, tenantId)) + } + + const loadExtensionDialTargets = async (tenantId: number, extensions: any[]) => { + const targetsById = new Map() + const userExtensionByUserId = new Map() + + for (const extension of extensions) { + if (!extension.enabled) continue + + if (extension.targetType === "user" && extension.targetUserId) { + userExtensionByUserId.set(extension.targetUserId, extension.extension) + targetsById.set(extension.id, [extension.extension]) + } + } + + const profileRows = await server.db + .select({ + id: authProfiles.id, + userId: authProfiles.user_id, + branchId: authProfiles.branch_id, + }) + .from(authProfiles) + .where(and( + eq(authProfiles.tenant_id, tenantId), + eq(authProfiles.active, true) + )) + + const profileUserByProfileId = new Map() + for (const profile of profileRows) { + if (profile.id && profile.userId) { + profileUserByProfileId.set(profile.id, profile.userId) + } + } + + const profileIds = profileRows.map((profile) => profile.id).filter(Boolean) + const teamRows = profileIds.length + ? await server.db + .select() + .from(authProfileTeams) + .where(inArray(authProfileTeams.profile_id, profileIds)) + : [] + const branchRows = profileIds.length + ? await server.db + .select() + .from(authProfileBranches) + .where(inArray(authProfileBranches.profile_id, profileIds)) + : [] + + for (const extension of extensions) { + if (!extension.enabled || extension.targetType === "user") continue + + const memberUserIds = new Set() + + if (extension.targetType === "team" && extension.targetTeamId) { + for (const row of teamRows) { + if (row.team_id !== extension.targetTeamId) continue + const userId = profileUserByProfileId.get(row.profile_id) + if (userId) memberUserIds.add(userId) + } + } + + if (extension.targetType === "branch" && extension.targetBranchId) { + for (const profile of profileRows) { + if (profile.branchId === extension.targetBranchId && profile.userId) { + memberUserIds.add(profile.userId) + } + } + for (const row of branchRows) { + if (row.branch_id !== extension.targetBranchId) continue + const userId = profileUserByProfileId.get(row.profile_id) + if (userId) memberUserIds.add(userId) + } + } + + const dialTargets = Array.from(memberUserIds) + .map((userId) => userExtensionByUserId.get(userId)) + .filter(Boolean) as string[] + + targetsById.set(extension.id, Array.from(new Set(dialTargets))) + } + + return targetsById + } + + const sanitizeExtension = (extension: any) => ({ + id: extension.id, + tenantId: extension.tenantId, + targetType: extension.targetType, + targetUserId: extension.targetUserId, + targetTeamId: extension.targetTeamId, + targetBranchId: extension.targetBranchId, + extension: extension.extension, + displayName: extension.displayName || "", + sipUsername: extension.sipUsername || extension.extension, + sipPasswordConfigured: Boolean(extension.sipPassword), + enabled: Boolean(extension.enabled), + createdAt: extension.createdAt, + updatedAt: extension.updatedAt, + }) + const externalTelephonyConfig = async (tenantId: number | null) => { const trunk = await loadActiveTenantTrunk(tenantId) if (trunk) { @@ -492,6 +715,7 @@ export default async function telephonyRoutes(server: FastifyInstance) { enabled: trunk.enabled, provider: trunk.provider, inboundExtension: trunk.inboundExtension, + defaultRouteExtensionId: trunk.defaultRouteExtensionId, outboundPrefix: trunk.outboundPrefix, registrar: trunk.registrar, externalSignalingAddress: trunk.externalSignalingAddress, @@ -595,6 +819,7 @@ export default async function telephonyRoutes(server: FastifyInstance) { 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"), @@ -671,7 +896,13 @@ export default async function telephonyRoutes(server: FastifyInstance) { }) } - const files = await writeAsteriskTrunkConfig(trunk) + 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 @@ -710,6 +941,192 @@ export default async function telephonyRoutes(server: FastifyInstance) { } }) + 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) + + return { + rows: extensions + .sort((a, b) => a.extension.localeCompare(b.extension, "de")) + .map((extension) => ({ + ...sanitizeExtension(extension), + dialTargets: dialTargetsById.get(extension.id) || [], + })), + } + }) + + 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 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 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 limit = Math.min( diff --git a/frontend/pages/settings/tenant.vue b/frontend/pages/settings/tenant.vue index 0af6973..1c4ba43 100644 --- a/frontend/pages/settings/tenant.vue +++ b/frontend/pages/settings/tenant.vue @@ -122,6 +122,20 @@ const itemInfo = ref({ projectTypes: [] }) +const planningBoardTimeGridOptions = [ + { label: "15 Minuten", value: 15 }, + { label: "30 Minuten", value: 30 }, + { label: "60 Minuten", value: 60 }, + { label: "120 Minuten", value: 120 }, + { label: "180 Minuten", value: 180 } +] + +const planningBoardConfig = reactive({ + startTime: auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00", + endTime: auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00", + slotMinutes: auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180 +}) + const canManageMcpTokens = computed(() => Boolean(auth.user?.is_admin || auth.hasPermission("mcp.tokens.write"))) const mcpTokens = ref([]) const mcpTokensLoading = ref(false) @@ -135,10 +149,18 @@ const mcpTokenForm = reactive({ const telephonyTrunkLoading = ref(false) const telephonyTrunkSaving = ref(false) const telephonyTrunkApplying = ref(false) +const telephonyExtensionsLoading = ref(false) +const telephonyExtensionSaving = ref(false) +const telephonyExtensionDeletingId = ref(null) const telephonyProviderOptions = [ { label: "Easybell", value: "easybell" }, { label: "Telekom", value: "telekom" } ] +const telephonyExtensionTargetTypes = [ + { label: "Benutzer", value: "user" }, + { label: "Team", value: "team" }, + { label: "Niederlassung", value: "branch" } +] const telephonyProviderDefaults = { easybell: { registrar: "voip.easybell.de", @@ -162,16 +184,43 @@ const telephonyTrunkForm = reactive({ clearPassword: false, callerId: "", inboundExtension: "1001", + defaultRouteExtensionId: null, outboundPrefix: "0", externalSignalingAddress: "", externalMediaAddress: "", localNetworks: "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8" }) +const telephonyExtensions = ref([]) +const telephonyExtensionOptions = ref({ users: [], teams: [], branches: [] }) +const telephonyExtensionForm = reactive({ + id: null, + targetType: "user", + targetUserId: null, + targetTeamId: null, + targetBranchId: null, + extension: "", + displayName: "", + sipUsername: "", + sipPassword: "", + enabled: true +}) const activeTelephonyProvider = computed(() => telephonyProviderDefaults[telephonyTrunkForm.provider] || telephonyProviderDefaults.easybell) +const telephonyRouteOptions = computed(() => telephonyExtensions.value.map((extension) => ({ + label: `${extension.extension} - ${extension.displayName || extension.targetType}`, + value: extension.id +}))) +const activeTelephonyTargetOptions = computed(() => { + if (telephonyExtensionForm.targetType === "team") return telephonyExtensionOptions.value.teams || [] + if (telephonyExtensionForm.targetType === "branch") return telephonyExtensionOptions.value.branches || [] + return telephonyExtensionOptions.value.users || [] +}) const setupPage = async () => { itemInfo.value = auth.activeTenantData console.log(itemInfo.value) + planningBoardConfig.startTime = auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00" + planningBoardConfig.endTime = auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00" + planningBoardConfig.slotMinutes = auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180 } const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) }) @@ -244,6 +293,7 @@ const loadTelephonyTrunk = async () => { telephonyTrunkForm.clearPassword = false telephonyTrunkForm.callerId = res?.callerId || "" telephonyTrunkForm.inboundExtension = res?.inboundExtension || "1001" + telephonyTrunkForm.defaultRouteExtensionId = res?.defaultRouteExtensionId || null telephonyTrunkForm.outboundPrefix = res?.outboundPrefix || "0" telephonyTrunkForm.externalSignalingAddress = res?.externalSignalingAddress || "" telephonyTrunkForm.externalMediaAddress = res?.externalMediaAddress || "" @@ -280,6 +330,7 @@ const saveTelephonyTrunk = async () => { clearPassword: telephonyTrunkForm.clearPassword, callerId: telephonyTrunkForm.callerId, inboundExtension: telephonyTrunkForm.inboundExtension, + defaultRouteExtensionId: telephonyTrunkForm.defaultRouteExtensionId, outboundPrefix: telephonyTrunkForm.outboundPrefix, externalSignalingAddress: telephonyTrunkForm.externalSignalingAddress, externalMediaAddress: telephonyTrunkForm.externalMediaAddress, @@ -302,6 +353,111 @@ const saveTelephonyTrunk = async () => { } } +const loadTelephonyExtensionOptions = async () => { + try { + telephonyExtensionOptions.value = await useNuxtApp().$api("/api/telephony/extensions/options") + } catch (error) { + toast.add({ title: "Nebenstellen-Ziele konnten nicht geladen werden", color: "error" }) + } +} + +const loadTelephonyExtensions = async () => { + telephonyExtensionsLoading.value = true + + try { + const res = await useNuxtApp().$api("/api/telephony/extensions") + telephonyExtensions.value = res?.rows || [] + } catch (error) { + toast.add({ title: "Nebenstellen konnten nicht geladen werden", color: "error" }) + } finally { + telephonyExtensionsLoading.value = false + } +} + +const resetTelephonyExtensionForm = () => { + telephonyExtensionForm.id = null + telephonyExtensionForm.targetType = "user" + telephonyExtensionForm.targetUserId = null + telephonyExtensionForm.targetTeamId = null + telephonyExtensionForm.targetBranchId = null + telephonyExtensionForm.extension = "" + telephonyExtensionForm.displayName = "" + telephonyExtensionForm.sipUsername = "" + telephonyExtensionForm.sipPassword = "" + telephonyExtensionForm.enabled = true +} + +const editTelephonyExtension = (extension) => { + telephonyExtensionForm.id = extension.id + telephonyExtensionForm.targetType = extension.targetType || "user" + telephonyExtensionForm.targetUserId = extension.targetUserId || null + telephonyExtensionForm.targetTeamId = extension.targetTeamId || null + telephonyExtensionForm.targetBranchId = extension.targetBranchId || null + telephonyExtensionForm.extension = extension.extension || "" + telephonyExtensionForm.displayName = extension.displayName || "" + telephonyExtensionForm.sipUsername = extension.sipUsername || extension.extension || "" + telephonyExtensionForm.sipPassword = "" + telephonyExtensionForm.enabled = extension.enabled !== false +} + +const telephonyExtensionPayload = () => ({ + targetType: telephonyExtensionForm.targetType, + targetUserId: telephonyExtensionForm.targetType === "user" ? telephonyExtensionForm.targetUserId : null, + targetTeamId: telephonyExtensionForm.targetType === "team" ? telephonyExtensionForm.targetTeamId : null, + targetBranchId: telephonyExtensionForm.targetType === "branch" ? telephonyExtensionForm.targetBranchId : null, + extension: telephonyExtensionForm.extension, + displayName: telephonyExtensionForm.displayName, + sipUsername: telephonyExtensionForm.sipUsername, + sipPassword: telephonyExtensionForm.sipPassword, + enabled: telephonyExtensionForm.enabled +}) + +const saveTelephonyExtension = async () => { + if (!telephonyExtensionForm.extension?.trim()) { + toast.add({ title: "Nebenstelle fehlt", color: "orange" }) + return + } + + telephonyExtensionSaving.value = true + + try { + await useNuxtApp().$api( + telephonyExtensionForm.id ? `/api/telephony/extensions/${telephonyExtensionForm.id}` : "/api/telephony/extensions", + { + method: telephonyExtensionForm.id ? "PUT" : "POST", + body: telephonyExtensionPayload() + } + ) + + toast.add({ title: "Nebenstelle gespeichert", color: "success" }) + resetTelephonyExtensionForm() + await loadTelephonyExtensions() + } catch (error) { + toast.add({ + title: "Nebenstelle konnte nicht gespeichert werden", + description: error?.data?.error || error?.message, + color: "error" + }) + } finally { + telephonyExtensionSaving.value = false + } +} + +const deleteTelephonyExtension = async (extension) => { + if (!extension?.id || !confirm(`Nebenstelle ${extension.extension} löschen?`)) return + + telephonyExtensionDeletingId.value = extension.id + try { + await useNuxtApp().$api(`/api/telephony/extensions/${extension.id}`, { method: "DELETE" }) + toast.add({ title: "Nebenstelle gelöscht", color: "success" }) + await loadTelephonyExtensions() + } catch (error) { + toast.add({ title: "Nebenstelle konnte nicht gelöscht werden", color: "error" }) + } finally { + telephonyExtensionDeletingId.value = null + } +} + const applyTelephonyTrunk = async () => { telephonyTrunkApplying.value = true @@ -378,9 +534,36 @@ const deactivateMcpToken = async (token) => { } } +const savePlanningBoardConfig = async () => { + if (!planningBoardConfig.startTime || !planningBoardConfig.endTime) { + toast.add({ title: "Rahmenzeiten fehlen", description: "Bitte Start- und Endzeit angeben.", color: "orange" }) + return + } + + if (planningBoardConfig.startTime >= planningBoardConfig.endTime) { + toast.add({ title: "Rahmenzeiten ungültig", description: "Die Endzeit muss nach der Startzeit liegen.", color: "orange" }) + return + } + + const currentCalendarConfig = auth.activeTenantData?.calendarConfig || {} + + await updateTenant({ + calendarConfig: { + ...currentCalendarConfig, + planningBoard: { + startTime: planningBoardConfig.startTime || "06:00", + endTime: planningBoardConfig.endTime || "21:00", + slotMinutes: Number(planningBoardConfig.slotMinutes) || 180 + } + } + }) +} + setupPage() onMounted(() => { loadMcpTokens() + loadTelephonyExtensionOptions() + loadTelephonyExtensions() loadTelephonyTrunk() }) @@ -404,6 +587,8 @@ watch(() => telephonyTrunkForm.provider, async (provider) => { label: 'Dokubox' },{ label: 'Rechnung & Kontakt' + },{ + label: 'Plantafel' },{ label: 'Integrationen' },{ @@ -498,6 +683,48 @@ watch(() => telephonyTrunkForm.provider, async (provider) => { +
+ + +
+
+

Plantafel

+

Lege hier die Rahmenzeiten und den Rasterabstand für die Plantafel fest.

+
+ + + + + + + + + + + + + + + Plantafel speichern + +
+
+
+
@@ -563,6 +790,15 @@ watch(() => telephonyTrunkForm.provider, async (provider) => { + + + @@ -603,6 +839,133 @@ watch(() => telephonyTrunkForm.provider, async (provider) => { In Asterisk anwenden
+ + + +
+
+
+

Nebenstellen

+

Ordne Benutzer, Teams und Niederlassungen routbaren Nebenstellen zu.

+
+ + Aktualisieren + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + {{ telephonyExtensionForm.id ? "Nebenstelle speichern" : "Nebenstelle anlegen" }} + + + Abbrechen + +
+ +
+
+
+
+ {{ extension.extension }} - {{ extension.displayName || extension.targetType }} +
+
+ {{ extension.targetType }} · Ziele: {{ extension.dialTargets?.length ? extension.dialTargets.join(", ") : "noch keine Benutzer-Nebenstelle" }} +
+
+
+ + Bearbeiten + + + Löschen + +
+
+
+ Noch keine Nebenstellen angelegt. +
+
+
diff --git a/telephony/asterisk/extensions.conf b/telephony/asterisk/extensions.conf index 131a58e..a172baa 100644 --- a/telephony/asterisk/extensions.conf +++ b/telephony/asterisk/extensions.conf @@ -14,4 +14,5 @@ exten => 600,1,Answer() same => n,Echo() same => n,Hangup() +#tryinclude generated/extensions.fedeo.conf #tryinclude generated/extensions.telekom.conf diff --git a/telephony/asterisk/pjsip.conf b/telephony/asterisk/pjsip.conf index 1f7efe2..e8b95b9 100644 --- a/telephony/asterisk/pjsip.conf +++ b/telephony/asterisk/pjsip.conf @@ -68,4 +68,5 @@ max_contacts=5 remove_existing=yes support_path=yes +#tryinclude generated/pjsip.extensions.conf #tryinclude generated/pjsip.telekom.conf