KI-AGENT: Nebenstellen und Standardroute für Telefonie ergänzen
This commit is contained in:
92
backend/db/migrations/0047_telephony_extensions.sql
Normal file
92
backend/db/migrations/0047_telephony_extensions.sql
Normal file
@@ -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");
|
||||||
@@ -330,6 +330,13 @@
|
|||||||
"when": 1780167600000,
|
"when": 1780167600000,
|
||||||
"tag": "0046_telephony_trunk_nat",
|
"tag": "0046_telephony_trunk_nat",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 47,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780171200000,
|
||||||
|
"tag": "0047_telephony_extensions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export * from "./tasks"
|
|||||||
export * from "./teams"
|
export * from "./teams"
|
||||||
export * from "./taxtypes"
|
export * from "./taxtypes"
|
||||||
export * from "./telephony_calls"
|
export * from "./telephony_calls"
|
||||||
|
export * from "./telephony_extensions"
|
||||||
export * from "./telephony_trunks"
|
export * from "./telephony_trunks"
|
||||||
export * from "./tenants"
|
export * from "./tenants"
|
||||||
export * from "./texttemplates"
|
export * from "./texttemplates"
|
||||||
|
|||||||
53
backend/db/schema/telephony_extensions.ts
Normal file
53
backend/db/schema/telephony_extensions.ts
Normal file
@@ -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
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
|
import { telephonyExtensions } from "./telephony_extensions"
|
||||||
|
|
||||||
export const telephonyTrunks = pgTable(
|
export const telephonyTrunks = pgTable(
|
||||||
"telephony_trunks",
|
"telephony_trunks",
|
||||||
@@ -29,6 +30,7 @@ export const telephonyTrunks = pgTable(
|
|||||||
password: text("password"),
|
password: text("password"),
|
||||||
callerId: text("caller_id"),
|
callerId: text("caller_id"),
|
||||||
inboundExtension: text("inbound_extension").notNull().default("1001"),
|
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"),
|
outboundPrefix: text("outbound_prefix").notNull().default("0"),
|
||||||
externalSignalingAddress: text("external_signaling_address"),
|
externalSignalingAddress: text("external_signaling_address"),
|
||||||
externalMediaAddress: text("external_media_address"),
|
externalMediaAddress: text("external_media_address"),
|
||||||
|
|||||||
@@ -2,8 +2,19 @@ import { FastifyInstance } from "fastify"
|
|||||||
import { promises as fs } from "node:fs"
|
import { promises as fs } from "node:fs"
|
||||||
import net from "node:net"
|
import net from "node:net"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { and, desc, eq } from "drizzle-orm"
|
import { randomBytes } from "node:crypto"
|
||||||
import { telephonyCalls, telephonyTrunks } from "../../db/schema"
|
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) => {
|
const envFlag = (value: string | undefined, fallback: boolean) => {
|
||||||
if (value === undefined || value === "") return fallback
|
if (value === undefined || value === "") return fallback
|
||||||
@@ -99,6 +110,7 @@ const sanitizeTrunk = (trunk: any) => ({
|
|||||||
passwordConfigured: Boolean(trunk?.password),
|
passwordConfigured: Boolean(trunk?.password),
|
||||||
callerId: trunk?.callerId || "",
|
callerId: trunk?.callerId || "",
|
||||||
inboundExtension: trunk?.inboundExtension || "1001",
|
inboundExtension: trunk?.inboundExtension || "1001",
|
||||||
|
defaultRouteExtensionId: trunk?.defaultRouteExtensionId || null,
|
||||||
outboundPrefix: trunk?.outboundPrefix || "0",
|
outboundPrefix: trunk?.outboundPrefix || "0",
|
||||||
externalSignalingAddress: trunk?.externalSignalingAddress || "",
|
externalSignalingAddress: trunk?.externalSignalingAddress || "",
|
||||||
externalMediaAddress: trunk?.externalMediaAddress || "",
|
externalMediaAddress: trunk?.externalMediaAddress || "",
|
||||||
@@ -155,6 +167,26 @@ const bodyString = (body: any, key: string) => {
|
|||||||
return typeof value === "string" && value.trim() ? value.trim() : null
|
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 bodyDate = (body: any, key: string) => {
|
||||||
const value = body?.[key]
|
const value = body?.[key]
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
@@ -319,6 +351,82 @@ const renderWebRtcConfig = (trunk: any) => {
|
|||||||
].join("\n")
|
].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 renderTelekomTransportConfig = (trunk: any) => {
|
||||||
const externalSignalingAddress = asteriskValue(trunk?.externalSignalingAddress)
|
const externalSignalingAddress = asteriskValue(trunk?.externalSignalingAddress)
|
||||||
const externalMediaAddress = asteriskValue(trunk?.externalMediaAddress || trunk?.externalSignalingAddress)
|
const externalMediaAddress = asteriskValue(trunk?.externalMediaAddress || trunk?.externalSignalingAddress)
|
||||||
@@ -348,7 +456,7 @@ const renderTelekomTransportConfig = (trunk: any) => {
|
|||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeAsteriskTrunkConfig = async (trunk: any) => {
|
const writeAsteriskTrunkConfig = async (trunk: any, extensions: any[] = [], dialTargetsById = new Map<string, string[]>()) => {
|
||||||
const targetDir = asteriskGeneratedDir()
|
const targetDir = asteriskGeneratedDir()
|
||||||
await fs.mkdir(targetDir, { recursive: true })
|
await fs.mkdir(targetDir, { recursive: true })
|
||||||
|
|
||||||
@@ -369,6 +477,14 @@ const writeAsteriskTrunkConfig = async (trunk: any) => {
|
|||||||
name: "pjsip.webrtc.conf",
|
name: "pjsip.webrtc.conf",
|
||||||
content: renderWebRtcConfig(trunk),
|
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) => {
|
await Promise.all(files.map(async (file) => {
|
||||||
@@ -485,6 +601,113 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
|| null
|
|| 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 externalTelephonyConfig = async (tenantId: number | null) => {
|
const externalTelephonyConfig = async (tenantId: number | null) => {
|
||||||
const trunk = await loadActiveTenantTrunk(tenantId)
|
const trunk = await loadActiveTenantTrunk(tenantId)
|
||||||
if (trunk) {
|
if (trunk) {
|
||||||
@@ -492,6 +715,7 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
enabled: trunk.enabled,
|
enabled: trunk.enabled,
|
||||||
provider: trunk.provider,
|
provider: trunk.provider,
|
||||||
inboundExtension: trunk.inboundExtension,
|
inboundExtension: trunk.inboundExtension,
|
||||||
|
defaultRouteExtensionId: trunk.defaultRouteExtensionId,
|
||||||
outboundPrefix: trunk.outboundPrefix,
|
outboundPrefix: trunk.outboundPrefix,
|
||||||
registrar: trunk.registrar,
|
registrar: trunk.registrar,
|
||||||
externalSignalingAddress: trunk.externalSignalingAddress,
|
externalSignalingAddress: trunk.externalSignalingAddress,
|
||||||
@@ -595,6 +819,7 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
|||||||
authUser: bodyString(body, "authUser"),
|
authUser: bodyString(body, "authUser"),
|
||||||
callerId: bodyString(body, "callerId"),
|
callerId: bodyString(body, "callerId"),
|
||||||
inboundExtension: bodyString(body, "inboundExtension") || "1001",
|
inboundExtension: bodyString(body, "inboundExtension") || "1001",
|
||||||
|
defaultRouteExtensionId: bodyString(body, "defaultRouteExtensionId"),
|
||||||
outboundPrefix: bodyString(body, "outboundPrefix") || "0",
|
outboundPrefix: bodyString(body, "outboundPrefix") || "0",
|
||||||
externalSignalingAddress: bodyString(body, "externalSignalingAddress"),
|
externalSignalingAddress: bodyString(body, "externalSignalingAddress"),
|
||||||
externalMediaAddress: bodyString(body, "externalMediaAddress"),
|
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 reload: any = null
|
||||||
let status: any = null
|
let status: any = null
|
||||||
let warning: string | null = 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) => {
|
server.get("/telephony/calls", async (req) => {
|
||||||
const tenantId = requireTenant(req.user.tenant_id)
|
const tenantId = requireTenant(req.user.tenant_id)
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
|
|||||||
@@ -122,6 +122,20 @@ const itemInfo = ref({
|
|||||||
projectTypes: []
|
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 canManageMcpTokens = computed(() => Boolean(auth.user?.is_admin || auth.hasPermission("mcp.tokens.write")))
|
||||||
const mcpTokens = ref([])
|
const mcpTokens = ref([])
|
||||||
const mcpTokensLoading = ref(false)
|
const mcpTokensLoading = ref(false)
|
||||||
@@ -135,10 +149,18 @@ const mcpTokenForm = reactive({
|
|||||||
const telephonyTrunkLoading = ref(false)
|
const telephonyTrunkLoading = ref(false)
|
||||||
const telephonyTrunkSaving = ref(false)
|
const telephonyTrunkSaving = ref(false)
|
||||||
const telephonyTrunkApplying = ref(false)
|
const telephonyTrunkApplying = ref(false)
|
||||||
|
const telephonyExtensionsLoading = ref(false)
|
||||||
|
const telephonyExtensionSaving = ref(false)
|
||||||
|
const telephonyExtensionDeletingId = ref(null)
|
||||||
const telephonyProviderOptions = [
|
const telephonyProviderOptions = [
|
||||||
{ label: "Easybell", value: "easybell" },
|
{ label: "Easybell", value: "easybell" },
|
||||||
{ label: "Telekom", value: "telekom" }
|
{ label: "Telekom", value: "telekom" }
|
||||||
]
|
]
|
||||||
|
const telephonyExtensionTargetTypes = [
|
||||||
|
{ label: "Benutzer", value: "user" },
|
||||||
|
{ label: "Team", value: "team" },
|
||||||
|
{ label: "Niederlassung", value: "branch" }
|
||||||
|
]
|
||||||
const telephonyProviderDefaults = {
|
const telephonyProviderDefaults = {
|
||||||
easybell: {
|
easybell: {
|
||||||
registrar: "voip.easybell.de",
|
registrar: "voip.easybell.de",
|
||||||
@@ -162,16 +184,43 @@ const telephonyTrunkForm = reactive({
|
|||||||
clearPassword: false,
|
clearPassword: false,
|
||||||
callerId: "",
|
callerId: "",
|
||||||
inboundExtension: "1001",
|
inboundExtension: "1001",
|
||||||
|
defaultRouteExtensionId: null,
|
||||||
outboundPrefix: "0",
|
outboundPrefix: "0",
|
||||||
externalSignalingAddress: "",
|
externalSignalingAddress: "",
|
||||||
externalMediaAddress: "",
|
externalMediaAddress: "",
|
||||||
localNetworks: "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8"
|
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 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 () => {
|
const setupPage = async () => {
|
||||||
itemInfo.value = auth.activeTenantData
|
itemInfo.value = auth.activeTenantData
|
||||||
console.log(itemInfo.value)
|
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 || {}) })
|
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
|
||||||
@@ -244,6 +293,7 @@ const loadTelephonyTrunk = async () => {
|
|||||||
telephonyTrunkForm.clearPassword = false
|
telephonyTrunkForm.clearPassword = false
|
||||||
telephonyTrunkForm.callerId = res?.callerId || ""
|
telephonyTrunkForm.callerId = res?.callerId || ""
|
||||||
telephonyTrunkForm.inboundExtension = res?.inboundExtension || "1001"
|
telephonyTrunkForm.inboundExtension = res?.inboundExtension || "1001"
|
||||||
|
telephonyTrunkForm.defaultRouteExtensionId = res?.defaultRouteExtensionId || null
|
||||||
telephonyTrunkForm.outboundPrefix = res?.outboundPrefix || "0"
|
telephonyTrunkForm.outboundPrefix = res?.outboundPrefix || "0"
|
||||||
telephonyTrunkForm.externalSignalingAddress = res?.externalSignalingAddress || ""
|
telephonyTrunkForm.externalSignalingAddress = res?.externalSignalingAddress || ""
|
||||||
telephonyTrunkForm.externalMediaAddress = res?.externalMediaAddress || ""
|
telephonyTrunkForm.externalMediaAddress = res?.externalMediaAddress || ""
|
||||||
@@ -280,6 +330,7 @@ const saveTelephonyTrunk = async () => {
|
|||||||
clearPassword: telephonyTrunkForm.clearPassword,
|
clearPassword: telephonyTrunkForm.clearPassword,
|
||||||
callerId: telephonyTrunkForm.callerId,
|
callerId: telephonyTrunkForm.callerId,
|
||||||
inboundExtension: telephonyTrunkForm.inboundExtension,
|
inboundExtension: telephonyTrunkForm.inboundExtension,
|
||||||
|
defaultRouteExtensionId: telephonyTrunkForm.defaultRouteExtensionId,
|
||||||
outboundPrefix: telephonyTrunkForm.outboundPrefix,
|
outboundPrefix: telephonyTrunkForm.outboundPrefix,
|
||||||
externalSignalingAddress: telephonyTrunkForm.externalSignalingAddress,
|
externalSignalingAddress: telephonyTrunkForm.externalSignalingAddress,
|
||||||
externalMediaAddress: telephonyTrunkForm.externalMediaAddress,
|
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 () => {
|
const applyTelephonyTrunk = async () => {
|
||||||
telephonyTrunkApplying.value = true
|
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()
|
setupPage()
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMcpTokens()
|
loadMcpTokens()
|
||||||
|
loadTelephonyExtensionOptions()
|
||||||
|
loadTelephonyExtensions()
|
||||||
loadTelephonyTrunk()
|
loadTelephonyTrunk()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -404,6 +587,8 @@ watch(() => telephonyTrunkForm.provider, async (provider) => {
|
|||||||
label: 'Dokubox'
|
label: 'Dokubox'
|
||||||
},{
|
},{
|
||||||
label: 'Rechnung & Kontakt'
|
label: 'Rechnung & Kontakt'
|
||||||
|
},{
|
||||||
|
label: 'Plantafel'
|
||||||
},{
|
},{
|
||||||
label: 'Integrationen'
|
label: 'Integrationen'
|
||||||
},{
|
},{
|
||||||
@@ -498,6 +683,48 @@ watch(() => telephonyTrunkForm.provider, async (provider) => {
|
|||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="item.label === 'Plantafel'">
|
||||||
|
<UCard class="mt-5">
|
||||||
|
<UForm class="w-1/2">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-highlighted">Plantafel</h3>
|
||||||
|
<p class="text-sm text-muted">Lege hier die Rahmenzeiten und den Rasterabstand für die Plantafel fest.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
label="Rahmenzeit von:"
|
||||||
|
>
|
||||||
|
<UInput type="time" v-model="planningBoardConfig.startTime" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
label="Rahmenzeit bis:"
|
||||||
|
>
|
||||||
|
<UInput type="time" v-model="planningBoardConfig.endTime" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
label="Rasterabstand:"
|
||||||
|
>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="planningBoardConfig.slotMinutes"
|
||||||
|
:items="planningBoardTimeGridOptions"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
class="mt-1"
|
||||||
|
@click="savePlanningBoardConfig"
|
||||||
|
>
|
||||||
|
Plantafel speichern
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UForm>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
<div v-else-if="item.label === 'Integrationen'">
|
<div v-else-if="item.label === 'Integrationen'">
|
||||||
<UCard class="mt-5">
|
<UCard class="mt-5">
|
||||||
<div class="mb-8 space-y-6">
|
<div class="mb-8 space-y-6">
|
||||||
@@ -563,6 +790,15 @@ watch(() => telephonyTrunkForm.provider, async (provider) => {
|
|||||||
<UFormField label="Eingehende Nebenstelle">
|
<UFormField label="Eingehende Nebenstelle">
|
||||||
<UInput v-model="telephonyTrunkForm.inboundExtension" placeholder="1001" />
|
<UInput v-model="telephonyTrunkForm.inboundExtension" placeholder="1001" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
<UFormField label="Standardroute">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="telephonyTrunkForm.defaultRouteExtensionId"
|
||||||
|
:items="telephonyRouteOptions"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
placeholder="Nebenstelle auswählen"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
<UFormField label="Ausgehender Prefix">
|
<UFormField label="Ausgehender Prefix">
|
||||||
<UInput v-model="telephonyTrunkForm.outboundPrefix" placeholder="0" />
|
<UInput v-model="telephonyTrunkForm.outboundPrefix" placeholder="0" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
@@ -603,6 +839,133 @@ watch(() => telephonyTrunkForm.provider, async (provider) => {
|
|||||||
In Asterisk anwenden
|
In Asterisk anwenden
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<USeparator />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-highlighted">Nebenstellen</h4>
|
||||||
|
<p class="text-sm text-muted">Ordne Benutzer, Teams und Niederlassungen routbaren Nebenstellen zu.</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-arrow-path"
|
||||||
|
variant="outline"
|
||||||
|
:loading="telephonyExtensionsLoading"
|
||||||
|
@click="loadTelephonyExtensions"
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<UFormField label="Zieltyp">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="telephonyExtensionForm.targetType"
|
||||||
|
:items="telephonyExtensionTargetTypes"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField v-if="telephonyExtensionForm.targetType === 'user'" label="Benutzer">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="telephonyExtensionForm.targetUserId"
|
||||||
|
:items="activeTelephonyTargetOptions"
|
||||||
|
label-key="label"
|
||||||
|
value-key="id"
|
||||||
|
placeholder="Benutzer auswählen"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField v-else-if="telephonyExtensionForm.targetType === 'team'" label="Team">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="telephonyExtensionForm.targetTeamId"
|
||||||
|
:items="activeTelephonyTargetOptions"
|
||||||
|
label-key="label"
|
||||||
|
value-key="id"
|
||||||
|
placeholder="Team auswählen"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField v-else label="Niederlassung">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="telephonyExtensionForm.targetBranchId"
|
||||||
|
:items="activeTelephonyTargetOptions"
|
||||||
|
label-key="label"
|
||||||
|
value-key="id"
|
||||||
|
placeholder="Niederlassung auswählen"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Nebenstelle">
|
||||||
|
<UInput v-model="telephonyExtensionForm.extension" placeholder="1001" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Anzeigename">
|
||||||
|
<UInput v-model="telephonyExtensionForm.displayName" placeholder="Optional" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="SIP-Benutzername">
|
||||||
|
<UInput v-model="telephonyExtensionForm.sipUsername" placeholder="Leer = Nebenstelle" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="SIP-Kennwort">
|
||||||
|
<UInput v-model="telephonyExtensionForm.sipPassword" type="password" placeholder="Leer = automatisch" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Aktiv">
|
||||||
|
<USwitch v-model="telephonyExtensionForm.enabled" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-check"
|
||||||
|
:loading="telephonyExtensionSaving"
|
||||||
|
@click="saveTelephonyExtension"
|
||||||
|
>
|
||||||
|
{{ telephonyExtensionForm.id ? "Nebenstelle speichern" : "Nebenstelle anlegen" }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="telephonyExtensionForm.id"
|
||||||
|
variant="outline"
|
||||||
|
@click="resetTelephonyExtensionForm"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-default rounded-md border border-default">
|
||||||
|
<div
|
||||||
|
v-for="extension in telephonyExtensions"
|
||||||
|
:key="extension.id"
|
||||||
|
class="flex flex-col gap-3 p-3 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-highlighted">
|
||||||
|
{{ extension.extension }} - {{ extension.displayName || extension.targetType }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted">
|
||||||
|
{{ extension.targetType }} · Ziele: {{ extension.dialTargets?.length ? extension.dialTargets.join(", ") : "noch keine Benutzer-Nebenstelle" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-pencil-square"
|
||||||
|
variant="outline"
|
||||||
|
@click="editTelephonyExtension(extension)"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-trash"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
:loading="telephonyExtensionDeletingId === extension.id"
|
||||||
|
@click="deleteTelephonyExtension(extension)"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!telephonyExtensions.length" class="p-3 text-sm text-muted">
|
||||||
|
Noch keine Nebenstellen angelegt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<USeparator class="mb-8" />
|
<USeparator class="mb-8" />
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ exten => 600,1,Answer()
|
|||||||
same => n,Echo()
|
same => n,Echo()
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
|
#tryinclude generated/extensions.fedeo.conf
|
||||||
#tryinclude generated/extensions.telekom.conf
|
#tryinclude generated/extensions.telekom.conf
|
||||||
|
|||||||
@@ -68,4 +68,5 @@ max_contacts=5
|
|||||||
remove_existing=yes
|
remove_existing=yes
|
||||||
support_path=yes
|
support_path=yes
|
||||||
|
|
||||||
|
#tryinclude generated/pjsip.extensions.conf
|
||||||
#tryinclude generated/pjsip.telekom.conf
|
#tryinclude generated/pjsip.telekom.conf
|
||||||
|
|||||||
Reference in New Issue
Block a user