From 1d518155424c9ace91e4b0cb459c7b55b93bb469 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 19:09:03 +0000 Subject: [PATCH 1/2] Add third-party conversation import support Co-authored-by: Guy Nirpaz --- .../conversations/[conversationId]/route.ts | 102 +++++++ .../boards/[boardId]/conversations/route.ts | 83 ++++++ .../boards/[boardId]/conversations/page.tsx | 261 ++++++++++++++++++ src/app/boards/[boardId]/layout.tsx | 4 +- src/components/board/board-data-provider.tsx | 3 +- src/components/board/board-nav.tsx | 8 +- src/hooks/use-board-data.ts | 23 +- src/lib/storage/fs-storage.ts | 181 +++++++++++- src/lib/storage/index.ts | 50 ++++ src/lib/types.ts | 29 ++ 10 files changed, 735 insertions(+), 9 deletions(-) create mode 100644 src/app/api/boards/[boardId]/conversations/[conversationId]/route.ts create mode 100644 src/app/api/boards/[boardId]/conversations/route.ts create mode 100644 src/app/boards/[boardId]/conversations/page.tsx diff --git a/src/app/api/boards/[boardId]/conversations/[conversationId]/route.ts b/src/app/api/boards/[boardId]/conversations/[conversationId]/route.ts new file mode 100644 index 0000000..85caa17 --- /dev/null +++ b/src/app/api/boards/[boardId]/conversations/[conversationId]/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getStorage } from "@/lib/storage/fs-storage"; +import { sseHub } from "@/lib/sse/hub"; +import { parseJsonBody, internalError } from "@/lib/api-utils"; + +type Params = { params: Promise<{ boardId: string; conversationId: string }> }; + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean); +} + +function asOptionalString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { boardId, conversationId } = await params; + const storage = getStorage(); + const conversation = await storage.getConversation(boardId, conversationId); + if (!conversation) { + return NextResponse.json( + { ok: false, error: { code: "NOT_FOUND", message: "Conversation not found" } }, + { status: 404 }, + ); + } + + return NextResponse.json({ ok: true, data: conversation }); + } catch (err) { + console.error("GET /conversations/[conversationId]:", err); + return internalError(); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { boardId, conversationId } = await params; + const parsed = await parseJsonBody(request); + if (!parsed.ok) return parsed.response; + const body = parsed.data as Record; + + const updates: Record = {}; + if ("title" in body && typeof body.title === "string") updates.title = body.title.trim(); + if ("description" in body && typeof body.description === "string") updates.description = body.description; + if ("importType" in body && typeof body.importType === "string") updates.importType = body.importType; + if ("sourceSystem" in body && typeof body.sourceSystem === "string") updates.sourceSystem = body.sourceSystem; + if ("sourceReference" in body) updates.sourceReference = asOptionalString(body.sourceReference); + if ("transcript" in body && typeof body.transcript === "string") updates.transcript = body.transcript; + if ("recordingUrls" in body) updates.recordingUrls = asStringArray(body.recordingUrls); + if ("attachments" in body) updates.attachments = asStringArray(body.attachments); + if ("tags" in body) updates.tags = asStringArray(body.tags); + if ("metadata" in body && body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)) { + updates.metadata = body.metadata; + } + if ("importedAt" in body) updates.importedAt = asOptionalString(body.importedAt); + if ("occurredAt" in body) updates.occurredAt = asOptionalString(body.occurredAt); + if ("startedAt" in body) updates.startedAt = asOptionalString(body.startedAt); + if ("endedAt" in body) updates.endedAt = asOptionalString(body.endedAt); + + const storage = getStorage(); + const conversation = await storage.updateConversation(boardId, conversationId, updates); + if (!conversation) { + return NextResponse.json( + { ok: false, error: { code: "NOT_FOUND", message: "Conversation not found" } }, + { status: 404 }, + ); + } + + sseHub.broadcast(boardId, "conversation:updated", conversation); + return NextResponse.json({ ok: true, data: conversation }); + } catch (err) { + console.error("PATCH /conversations/[conversationId]:", err); + return internalError(); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { boardId, conversationId } = await params; + const storage = getStorage(); + const conversation = await storage.getConversation(boardId, conversationId); + const deleted = await storage.deleteConversation(boardId, conversationId); + if (!deleted) { + return NextResponse.json( + { ok: false, error: { code: "NOT_FOUND", message: "Conversation not found" } }, + { status: 404 }, + ); + } + + if (conversation) { + sseHub.broadcast(boardId, "conversation:removed", conversation); + } + + return NextResponse.json({ ok: true, data: { deleted: true } }); + } catch (err) { + console.error("DELETE /conversations/[conversationId]:", err); + return internalError(); + } +} diff --git a/src/app/api/boards/[boardId]/conversations/route.ts b/src/app/api/boards/[boardId]/conversations/route.ts new file mode 100644 index 0000000..f71b4b1 --- /dev/null +++ b/src/app/api/boards/[boardId]/conversations/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getStorage } from "@/lib/storage/fs-storage"; +import { sseHub } from "@/lib/sse/hub"; +import { parseJsonBody, internalError } from "@/lib/api-utils"; +import type { Conversation } from "@/lib/types"; + +type Params = { params: Promise<{ boardId: string }> }; + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean); +} + +function asOptionalString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { boardId } = await params; + const storage = getStorage(); + const conversations = await storage.listConversations(boardId); + return NextResponse.json({ ok: true, data: conversations }); + } catch (err) { + console.error("GET /conversations:", err); + return internalError(); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { boardId } = await params; + const parsed = await parseJsonBody(request); + if (!parsed.ok) return parsed.response; + const body = parsed.data as Record; + + const title = typeof body.title === "string" ? body.title.trim() : ""; + if (!title) { + return NextResponse.json( + { ok: false, error: { code: "MISSING_TITLE", message: "Conversation title is required" } }, + { status: 400 }, + ); + } + + const storage = getStorage(); + const board = await storage.getBoard(boardId); + if (!board) { + return NextResponse.json( + { ok: false, error: { code: "NOT_FOUND", message: "Board not found" } }, + { status: 404 }, + ); + } + + const conversation = await storage.createConversation(boardId, { + title, + description: typeof body.description === "string" ? body.description : "", + sourceType: (body.sourceType as Conversation["sourceType"]) || "imported", + importType: (body.importType as Conversation["importType"]) || "transcript", + sourceSystem: typeof body.sourceSystem === "string" ? body.sourceSystem : "unknown", + sourceReference: asOptionalString(body.sourceReference), + transcript: typeof body.transcript === "string" ? body.transcript : "", + recordingUrls: asStringArray(body.recordingUrls), + attachments: asStringArray(body.attachments), + tags: asStringArray(body.tags), + metadata: + body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) + ? (body.metadata as Record) + : {}, + importedAt: asOptionalString(body.importedAt), + occurredAt: asOptionalString(body.occurredAt), + startedAt: asOptionalString(body.startedAt), + endedAt: asOptionalString(body.endedAt), + }); + + sseHub.broadcast(boardId, "conversation:created", conversation); + return NextResponse.json({ ok: true, data: conversation }, { status: 201 }); + } catch (err) { + console.error("POST /conversations:", err); + return internalError(); + } +} diff --git a/src/app/boards/[boardId]/conversations/page.tsx b/src/app/boards/[boardId]/conversations/page.tsx new file mode 100644 index 0000000..30ba597 --- /dev/null +++ b/src/app/boards/[boardId]/conversations/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { FormEvent, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import { useBoardContext } from "@/components/board/board-data-provider"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { TimeAgo } from "@/components/common/time-ago"; +import type { ConversationImportType } from "@/lib/types"; + +type FormState = { + title: string; + sourceSystem: string; + importType: ConversationImportType; + occurredAt: string; + importedAt: string; + recordingUrls: string; + transcript: string; + sourceReference: string; + metadataJson: string; +}; + +const initialForm: FormState = { + title: "", + sourceSystem: "", + importType: "transcript", + occurredAt: "", + importedAt: "", + recordingUrls: "", + transcript: "", + sourceReference: "", + metadataJson: "", +}; + +export default function ConversationsPage() { + const params = useParams<{ boardId: string }>(); + const boardId = params.boardId; + const { conversations, refresh } = useBoardContext(); + const [form, setForm] = useState(initialForm); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const sortedConversations = useMemo( + () => + [...conversations].sort((a, b) => + (b.occurredAt || b.createdAt).localeCompare(a.occurredAt || a.createdAt), + ), + [conversations], + ); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setError(null); + + let metadata: Record = {}; + if (form.metadataJson.trim()) { + try { + metadata = JSON.parse(form.metadataJson) as Record; + } catch { + setError("Metadata must be valid JSON"); + setSaving(false); + return; + } + } + + const recordingUrls = form.recordingUrls + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const payload = { + title: form.title, + sourceType: "imported" as const, + sourceSystem: form.sourceSystem || "manual-import", + importType: form.importType, + occurredAt: form.occurredAt || null, + importedAt: form.importedAt || null, + sourceReference: form.sourceReference || null, + transcript: form.transcript, + recordingUrls, + metadata, + }; + + const response = await fetch(`/api/boards/${boardId}/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const json = await response.json(); + + if (!json.ok) { + setError(json.error?.message || "Unable to import conversation"); + setSaving(false); + return; + } + + setForm(initialForm); + setSaving(false); + refresh(); + } + + return ( +
+ + + Import third-party conversation + + Bring transcript and recording data from Zoom, Gong, Google Meet, or manual notes. + + + +
+
+ + setForm((prev) => ({ ...prev, title: event.target.value }))} + placeholder="Customer discovery interview - ACME" + required + /> +
+
+
+ + setForm((prev) => ({ ...prev, sourceSystem: event.target.value }))} + placeholder="zoom / gong / google-meet" + /> +
+
+ + + setForm((prev) => ({ ...prev, importType: event.target.value as ConversationImportType })) + } + placeholder="transcript | recording | mixed" + /> +
+
+ + setForm((prev) => ({ ...prev, sourceReference: event.target.value }))} + placeholder="Call ID / meeting URL" + /> +
+
+
+
+ + setForm((prev) => ({ ...prev, occurredAt: event.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, importedAt: event.target.value }))} + /> +
+
+
+ +