KI-AGENT: Anrufhistorie für Telefonie ergänzen
This commit is contained in:
57
backend/db/migrations/0044_telephony_calls.sql
Normal file
57
backend/db/migrations/0044_telephony_calls.sql
Normal 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");
|
||||
@@ -309,6 +309,13 @@
|
||||
"when": 1780156800000,
|
||||
"tag": "0043_communication_rooms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "7",
|
||||
"when": 1780160400000,
|
||||
"tag": "0044_telephony_calls",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
56
backend/db/schema/telephony_calls.ts
Normal file
56
backend/db/schema/telephony_calls.ts
Normal 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
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user