From 91369bcdf0407425eb56e21517885181df6dcf08 Mon Sep 17 00:00:00 2001 From: DebdipWritesCode Date: Fri, 12 Jun 2026 06:37:31 +0000 Subject: [PATCH] Add call recording playback URLs --- .../src/components/calls/AudioPlayer.tsx | 58 +++++++++++++++++-- .../src/modules/calllogs/calllog.service.ts | 34 ++++++++++- .../tests/calllogs/calllog.service.test.ts | 45 ++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 apps/server/tests/calllogs/calllog.service.test.ts diff --git a/apps/console/src/components/calls/AudioPlayer.tsx b/apps/console/src/components/calls/AudioPlayer.tsx index a2a04d6..79a848e 100644 --- a/apps/console/src/components/calls/AudioPlayer.tsx +++ b/apps/console/src/components/calls/AudioPlayer.tsx @@ -1,8 +1,33 @@ "use client"; -import { Music } from "lucide-react"; +import { useRef, useState } from "react"; +import { Download, Music, Pause, Play } from "lucide-react"; + +import { Button } from "@/src/components/ui/button"; export function AudioPlayer({ src }: { src: string | null }) { + const audioRef = useRef(null); + const [playingSrc, setPlayingSrc] = useState(null); + const playing = playingSrc === src; + + async function togglePlayback() { + const audio = audioRef.current; + if (!audio) return; + + if (playing) { + audio.pause(); + setPlayingSrc(null); + return; + } + + try { + await audio.play(); + setPlayingSrc(src); + } catch { + setPlayingSrc(null); + } + } + if (!src) { return (
@@ -13,12 +38,37 @@ export function AudioPlayer({ src }: { src: string | null }) { } return ( -
+
+
+
+ + Recording +
+
+ + +
+
); diff --git a/apps/server/src/modules/calllogs/calllog.service.ts b/apps/server/src/modules/calllogs/calllog.service.ts index 95f6ed2..f40cd0c 100644 --- a/apps/server/src/modules/calllogs/calllog.service.ts +++ b/apps/server/src/modules/calllogs/calllog.service.ts @@ -1,4 +1,5 @@ import { NotFoundError } from "../../common/errors/notFound.js"; +import { generateDownloadUrl } from "../../config/s3.js"; import * as calllogRepository from "./calllog.repository.js"; import type { IngestCallLogArgs, @@ -6,6 +7,32 @@ import type { ListTranscriptsArgs, } from "./calllog.schema.js"; +type RecordingSigner = (key: string) => Promise; +type CallWithRecording = { audioRecordingPath: string | null }; + +const isHttpUrl = (value: string) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +}; + +export const signCallRecordingUrl = async ( + call: T, + signer: RecordingSigner = generateDownloadUrl +): Promise => { + if (!call.audioRecordingPath || isHttpUrl(call.audioRecordingPath)) { + return call; + } + + return { + ...call, + audioRecordingPath: await signer(call.audioRecordingPath), + }; +}; + export const ingestCallLog = (args: IngestCallLogArgs) =>{ return calllogRepository.saveCallLog(args); }; @@ -17,7 +44,10 @@ export const listCallLogs = async (args: ListCallLogsArgs) => { const hasMore = rows.length > args.limit; const items = hasMore ? rows.slice(0, args.limit) : rows; const nextCursor = hasMore ? items[items.length - 1]!.callId : null; - return { items, nextCursor }; + return { + items: await Promise.all(items.map((item) => signCallRecordingUrl(item))), + nextCursor, + }; }; export const getCallLog = async (organizationId: string, callId: string) => { @@ -25,7 +55,7 @@ export const getCallLog = async (organizationId: string, callId: string) => { if (!row) { throw new NotFoundError("Call log not found"); } - return row; + return signCallRecordingUrl(row); }; export const getTranscripts = async (args: ListTranscriptsArgs) => { diff --git a/apps/server/tests/calllogs/calllog.service.test.ts b/apps/server/tests/calllogs/calllog.service.test.ts new file mode 100644 index 0000000..1acb3e6 --- /dev/null +++ b/apps/server/tests/calllogs/calllog.service.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { signCallRecordingUrl } from "../../src/modules/calllogs/calllog.service.js"; + +test("signCallRecordingUrl replaces a stored S3 key with a signed playback URL", async () => { + const call = { + callId: "SCL_recording123", + audioRecordingPath: "Voice-agents/Recordings/recording-123.ogg", + }; + + const signed = await signCallRecordingUrl(call, async (key) => { + return `https://recordings.quickvoice.test/${encodeURIComponent(key)}?signature=test`; + }); + + assert.equal( + signed.audioRecordingPath, + "https://recordings.quickvoice.test/Voice-agents%2FRecordings%2Frecording-123.ogg?signature=test" + ); + assert.equal(call.audioRecordingPath, "Voice-agents/Recordings/recording-123.ogg"); +}); + +test("signCallRecordingUrl leaves missing recordings and existing URLs unchanged", async () => { + const nullRecording = await signCallRecordingUrl( + { callId: "SCL_no_recording", audioRecordingPath: null }, + async () => { + throw new Error("should not sign empty recording path"); + } + ); + assert.equal(nullRecording.audioRecordingPath, null); + + const existingUrl = await signCallRecordingUrl( + { + callId: "SCL_url_recording", + audioRecordingPath: "https://cdn.quickvoice.test/recording.ogg", + }, + async () => { + throw new Error("should not sign existing URL"); + } + ); + assert.equal( + existingUrl.audioRecordingPath, + "https://cdn.quickvoice.test/recording.ogg" + ); +});