diff --git a/backend/db/migrations/0044_telephony_calls.sql b/backend/db/migrations/0044_telephony_calls.sql new file mode 100644 index 0000000..9692cac --- /dev/null +++ b/backend/db/migrations/0044_telephony_calls.sql @@ -0,0 +1,57 @@ +CREATE TABLE IF NOT EXISTS "telephony_calls" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" bigint NOT NULL, + "direction" text NOT NULL, + "status" text DEFAULT 'ringing' NOT NULL, + "local_extension" text, + "remote_number" text, + "remote_display_name" text, + "sip_call_id" text, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "answered_at" timestamp with time zone, + "ended_at" timestamp with time zone, + "duration_seconds" integer, + "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_calls_tenant_id_tenants_id_fk' + ) THEN + ALTER TABLE "telephony_calls" + ADD CONSTRAINT "telephony_calls_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_calls_created_by_auth_users_id_fk' + ) THEN + ALTER TABLE "telephony_calls" + ADD CONSTRAINT "telephony_calls_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_calls_updated_by_auth_users_id_fk' + ) THEN + ALTER TABLE "telephony_calls" + ADD CONSTRAINT "telephony_calls_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 INDEX IF NOT EXISTS "telephony_calls_tenant_started_idx" + ON "telephony_calls" USING btree ("tenant_id", "started_at"); + +CREATE INDEX IF NOT EXISTS "telephony_calls_created_by_idx" + ON "telephony_calls" USING btree ("tenant_id", "created_by"); + +CREATE INDEX IF NOT EXISTS "telephony_calls_sip_call_idx" + ON "telephony_calls" USING btree ("tenant_id", "sip_call_id"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 7055a4d..5374ef3 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1780156800000, "tag": "0043_communication_rooms", "breakpoints": true + }, + { + "idx": 44, + "version": "7", + "when": 1780160400000, + "tag": "0044_telephony_calls", + "breakpoints": true } ] } diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index c0e3e5c..3cf78ed 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -75,6 +75,7 @@ export * from "./statementallocations" export * from "./tasks" export * from "./teams" export * from "./taxtypes" +export * from "./telephony_calls" export * from "./tenants" export * from "./texttemplates" export * from "./units" diff --git a/backend/db/schema/telephony_calls.ts b/backend/db/schema/telephony_calls.ts new file mode 100644 index 0000000..c0f3e3e --- /dev/null +++ b/backend/db/schema/telephony_calls.ts @@ -0,0 +1,56 @@ +import { + pgTable, + uuid, + bigint, + text, + timestamp, + integer, + index, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const telephonyCalls = pgTable( + "telephony_calls", + { + id: uuid("id").primaryKey().defaultRandom(), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + + direction: text("direction").notNull(), + status: text("status").notNull().default("ringing"), + + localExtension: text("local_extension"), + remoteNumber: text("remote_number"), + remoteDisplayName: text("remote_display_name"), + sipCallId: text("sip_call_id"), + + startedAt: timestamp("started_at", { withTimezone: true }) + .notNull() + .defaultNow(), + answeredAt: timestamp("answered_at", { withTimezone: true }), + endedAt: timestamp("ended_at", { withTimezone: true }), + durationSeconds: integer("duration_seconds"), + + 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) => ({ + tenantStartedIdx: index("telephony_calls_tenant_started_idx") + .on(table.tenantId, table.startedAt), + createdByIdx: index("telephony_calls_created_by_idx") + .on(table.tenantId, table.createdBy), + sipCallIdx: index("telephony_calls_sip_call_idx") + .on(table.tenantId, table.sipCallId), + }) +) + +export type TelephonyCall = typeof telephonyCalls.$inferSelect +export type NewTelephonyCall = typeof telephonyCalls.$inferInsert diff --git a/backend/src/routes/telephony.ts b/backend/src/routes/telephony.ts index 7dc6647..fc9cf19 100644 --- a/backend/src/routes/telephony.ts +++ b/backend/src/routes/telephony.ts @@ -1,4 +1,6 @@ import { FastifyInstance } from "fastify" +import { and, desc, eq } from "drizzle-orm" +import { telephonyCalls } from "../../db/schema" const envFlag = (value: string | undefined, fallback: boolean) => { if (value === undefined || value === "") return fallback @@ -52,6 +54,32 @@ const fetchWithTimeout = async (url: string, timeoutMs = 2500) => { } } +const requireTenant = (tenantId: number | null) => { + if (!tenantId) { + throw Object.assign(new Error("Kein aktiver Mandant"), { statusCode: 400 }) + } + + return tenantId +} + +const bodyString = (body: any, key: string) => { + const value = body?.[key] + return typeof value === "string" && value.trim() ? value.trim() : null +} + +const bodyDate = (body: any, key: string) => { + const value = body?.[key] + if (!value) return null + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +const durationSeconds = (startedAt?: Date | null, endedAt?: Date | null) => { + if (!startedAt || !endedAt) return null + return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 1000)) +} + export default async function telephonyRoutes(server: FastifyInstance) { server.get("/telephony/config", async () => ({ enabled: telephonyEnabled(), @@ -113,4 +141,93 @@ export default async function telephonyRoutes(server: FastifyInstance) { : (lastError?.message || "Asterisk ist nicht erreichbar."), } }) + + server.get("/telephony/calls", async (req) => { + const tenantId = requireTenant(req.user.tenant_id) + const limit = Math.min( + Math.max(Number((req.query as { limit?: string })?.limit || 25), 1), + 100 + ) + + return await server.db + .select() + .from(telephonyCalls) + .where(eq(telephonyCalls.tenantId, tenantId)) + .orderBy(desc(telephonyCalls.startedAt)) + .limit(limit) + }) + + server.post("/telephony/calls", async (req, reply) => { + const tenantId = requireTenant(req.user.tenant_id) + const body = (req.body || {}) as any + const now = new Date() + const startedAt = bodyDate(body, "startedAt") || now + const direction = bodyString(body, "direction") === "incoming" ? "incoming" : "outgoing" + const status = bodyString(body, "status") || (direction === "incoming" ? "ringing" : "dialing") + + const [created] = await server.db + .insert(telephonyCalls) + .values({ + tenantId, + direction, + status, + localExtension: bodyString(body, "localExtension"), + remoteNumber: bodyString(body, "remoteNumber"), + remoteDisplayName: bodyString(body, "remoteDisplayName"), + sipCallId: bodyString(body, "sipCallId"), + startedAt, + createdBy: req.user.user_id, + }) + .returning() + + return reply.code(201).send(created) + }) + + server.patch("/telephony/calls/: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 [existing] = await server.db + .select() + .from(telephonyCalls) + .where(and( + eq(telephonyCalls.tenantId, tenantId), + eq(telephonyCalls.id, params.id) + )) + .limit(1) + + if (!existing) { + return reply.code(404).send({ error: "Anruf nicht gefunden" }) + } + + const answeredAt = bodyDate(body, "answeredAt") + const endedAt = bodyDate(body, "endedAt") + const startedAt = existing.startedAt ? new Date(existing.startedAt) : null + const computedDuration = endedAt + ? durationSeconds(answeredAt || startedAt, endedAt) + : null + + const [updated] = await server.db + .update(telephonyCalls) + .set({ + status: bodyString(body, "status") || existing.status, + localExtension: bodyString(body, "localExtension") || existing.localExtension, + remoteNumber: bodyString(body, "remoteNumber") || existing.remoteNumber, + remoteDisplayName: bodyString(body, "remoteDisplayName") || existing.remoteDisplayName, + sipCallId: bodyString(body, "sipCallId") || existing.sipCallId, + answeredAt: answeredAt || existing.answeredAt, + endedAt: endedAt || existing.endedAt, + durationSeconds: computedDuration ?? existing.durationSeconds, + updatedAt: new Date(), + updatedBy: req.user.user_id, + }) + .where(and( + eq(telephonyCalls.tenantId, tenantId), + eq(telephonyCalls.id, params.id) + )) + .returning() + + return updated + }) } diff --git a/frontend/composables/useTelephonySoftphone.js b/frontend/composables/useTelephonySoftphone.js index b63fb7c..4498991 100644 --- a/frontend/composables/useTelephonySoftphone.js +++ b/frontend/composables/useTelephonySoftphone.js @@ -1,9 +1,11 @@ const loading = ref(false) const statusLoading = ref(false) const websocketTesting = ref(false) +const callHistoryLoading = ref(false) const config = ref(null) const status = ref(null) const websocketResult = ref(null) +const callHistory = ref([]) const lastUpdated = ref(null) const selectedExtension = ref("1001") const dialTarget = ref("600") @@ -24,6 +26,9 @@ const ringtoneTimer = shallowRef(null) const ringtoneAudioContext = shallowRef(null) const callSignalStatus = ref("Bereit") const originalDocumentTitle = ref("") +const activeCallRecordId = ref(null) +const activeCallDirection = ref(null) +const activeCallAnsweredAt = ref(null) export const useTelephonySoftphone = () => { const toast = useToast() @@ -78,6 +83,98 @@ export const useTelephonySoftphone = () => { ].slice(0, 8) } + const sipCallIdFromSession = (session) => + session?.request?.callId + || session?.request?.message?.callId + || session?.incomingInviteRequest?.message?.callId + || session?.outgoingInviteRequest?.message?.callId + || null + + const loadCallHistory = async () => { + callHistoryLoading.value = true + + try { + callHistory.value = await $api("/api/telephony/calls?limit=20") + } catch (error) { + addSipEvent(`Anrufhistorie konnte nicht geladen werden: ${error?.message || "Unbekannter Fehler"}`) + } finally { + callHistoryLoading.value = false + } + } + + const createCallRecord = async ({ direction, status: nextStatus, localExtension, remoteNumber, remoteDisplayName, session }) => { + try { + const call = await $api("/api/telephony/calls", { + method: "POST", + body: { + direction, + status: nextStatus, + localExtension, + remoteNumber, + remoteDisplayName, + sipCallId: sipCallIdFromSession(session), + startedAt: new Date().toISOString(), + }, + }) + + activeCallRecordId.value = call.id + activeCallDirection.value = direction + activeCallAnsweredAt.value = null + await loadCallHistory() + return call + } catch (error) { + addSipEvent(`Anrufhistorie konnte nicht geschrieben werden: ${error?.message || "Unbekannter Fehler"}`) + return null + } + } + + const updateCallRecord = async (id, payload) => { + if (!id) return null + + try { + const call = await $api(`/api/telephony/calls/${id}`, { + method: "PATCH", + body: payload, + }) + + await loadCallHistory() + return call + } catch (error) { + addSipEvent(`Anrufhistorie konnte nicht aktualisiert werden: ${error?.message || "Unbekannter Fehler"}`) + return null + } + } + + const markCallAnswered = async () => { + if (!activeCallRecordId.value || activeCallAnsweredAt.value) return + + activeCallAnsweredAt.value = new Date() + await updateCallRecord(activeCallRecordId.value, { + status: "active", + answeredAt: activeCallAnsweredAt.value.toISOString(), + }) + } + + const finalizeCallRecord = async (statusOverride = null) => { + if (!activeCallRecordId.value) return + + const id = activeCallRecordId.value + const direction = activeCallDirection.value + const answeredAt = activeCallAnsweredAt.value + const endedAt = new Date() + const finalStatus = statusOverride + || (answeredAt ? "completed" : direction === "incoming" ? "missed" : "canceled") + + activeCallRecordId.value = null + activeCallDirection.value = null + activeCallAnsweredAt.value = null + + await updateCallRecord(id, { + status: finalStatus, + endedAt: endedAt.toISOString(), + }) + } + const ensureSipModule = async () => { if (!sipModule.value) { sipModule.value = await import("sip.js") @@ -199,10 +296,12 @@ export const useTelephonySoftphone = () => { callState.value = "active" sipStatus.value = "Im Gespräch" incomingCall.value = null + await markCallAnswered() await attachRemoteAudio(session) } if (state === SessionState.Terminated) { + await finalizeCallRecord() stopCallSignaling() callState.value = "terminated" sipStatus.value = sipRegistered.value ? "Registriert" : "Nicht verbunden" @@ -228,6 +327,8 @@ export const useTelephonySoftphone = () => { if (!selectedAccount.value && configRes?.testAccounts?.length) { selectedExtension.value = configRes.testAccounts[0].extension } + + await loadCallHistory() } catch (error) { toast.add({ title: "Telefonie-Status konnte nicht geladen werden", @@ -346,9 +447,18 @@ export const useTelephonySoftphone = () => { if (!uri) throw new Error("SIP-URI konnte nicht erstellt werden.") const handleIncomingInvite = (invitation) => { + const remoteNumber = invitation.remoteIdentity?.uri?.user || null addSipEvent(`INVITE von ${invitation.remoteIdentity?.uri?.user || "unbekannt"} empfangen`) incomingCall.value = invitation setupSession(invitation, "incoming") + void createCallRecord({ + direction: "incoming", + status: "ringing", + localExtension: account.extension, + remoteNumber, + remoteDisplayName: invitation.remoteIdentity?.displayName || remoteNumber, + session: invitation, + }) startCallSignaling() toast.add({ @@ -477,9 +587,18 @@ export const useTelephonySoftphone = () => { }, }) + await createCallRecord({ + direction: "outgoing", + status: "dialing", + localExtension: selectedAccount.value?.extension || selectedExtension.value, + remoteNumber: dialTarget.value.trim(), + remoteDisplayName: dialTarget.value.trim(), + session: inviter, + }) setupSession(inviter, "outgoing") await inviter.invite() } catch (error) { + await finalizeCallRecord("failed") activeSession.value = null callState.value = "idle" sipError.value = error?.message || "Anruf konnte nicht gestartet werden." @@ -517,6 +636,7 @@ export const useTelephonySoftphone = () => { try { stopCallSignaling() await incomingCall.value.reject() + await finalizeCallRecord("rejected") } finally { incomingCall.value = null activeSession.value = null @@ -541,6 +661,7 @@ export const useTelephonySoftphone = () => { session.dispose() } } finally { + await finalizeCallRecord() stopCallSignaling() activeSession.value = null incomingCall.value = null @@ -554,9 +675,11 @@ export const useTelephonySoftphone = () => { loading, statusLoading, websocketTesting, + callHistoryLoading, config, status, websocketResult, + callHistory, lastUpdated, selectedExtension, dialTarget, @@ -580,6 +703,7 @@ export const useTelephonySoftphone = () => { statusIcon, websocketColor, loadTelephony, + loadCallHistory, refreshStatus, testWebSocket, registerSip, diff --git a/frontend/pages/communication/phone.vue b/frontend/pages/communication/phone.vue index 802daf8..6a1a3c8 100644 --- a/frontend/pages/communication/phone.vue +++ b/frontend/pages/communication/phone.vue @@ -1,7 +1,9 @@ @@ -178,6 +221,84 @@ onMounted(loadTelephony) + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
ZeitRichtungTeilnehmerNebenstelleStatusDauer
+ {{ formatCallTime(call.startedAt) }} + + + +
+ {{ call.remoteDisplayName || call.remoteNumber || "Unbekannt" }} +
+
+ {{ call.remoteNumber }} +
+
+ {{ call.localExtension || "-" }} + + + {{ callStatusLabel(call.status) }} + + + {{ formatDuration(call.durationSeconds) }} +
+
+ +
+ Noch keine Anrufe in der Historie. +
+