KI-AGENT: Anrufhistorie für Telefonie ergänzen

This commit is contained in:
2026-05-21 15:54:24 +02:00
parent ba12c46c88
commit 9e7b5bc0b9
7 changed files with 483 additions and 0 deletions

View File

@@ -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");

View File

@@ -309,6 +309,13 @@
"when": 1780156800000,
"tag": "0043_communication_rooms",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1780160400000,
"tag": "0044_telephony_calls",
"breakpoints": true
}
]
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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
})
}