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..b4e2297 --- /dev/null +++ b/src/app/boards/[boardId]/conversations/page.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { ChangeEvent, 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, ConversationSourceType } from "@/lib/types"; + +const providerPresets = ["zoom", "gong", "google-meet", "teams", "phone-call", "manual-notes"]; +const importTypeOptions: ConversationImportType[] = ["transcript", "recording", "mixed"]; +const sourceTypeOptions: ConversationSourceType[] = ["imported", "agentic"]; + +type FormState = { + title: string; + sourceType: ConversationSourceType; + sourcePreset: string; + sourceSystem: string; + importType: ConversationImportType; + occurredAt: string; + importedAt: string; + recordingUrls: string; + transcript: string; + sourceReference: string; + metadataJson: string; +}; + +const initialForm: FormState = { + title: "", + sourceType: "imported", + sourcePreset: "zoom", + 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 [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState<"all" | ConversationImportType>("all"); + const [sourceFilter, setSourceFilter] = useState("all"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const sourceSystems = useMemo( + () => Array.from(new Set(conversations.map((conversation) => conversation.sourceSystem))).sort((a, b) => a.localeCompare(b)), + [conversations], + ); + + const sortedConversations = useMemo( + () => { + const query = search.trim().toLowerCase(); + + return [...conversations] + .filter((conversation) => { + if (typeFilter !== "all" && conversation.importType !== typeFilter) return false; + if (sourceFilter !== "all" && conversation.sourceSystem !== sourceFilter) return false; + if (!query) return true; + + const haystack = [ + conversation.title, + conversation.description, + conversation.sourceSystem, + conversation.sourceReference || "", + conversation.transcript, + conversation.tags.join(" "), + ] + .join(" ") + .toLowerCase(); + return haystack.includes(query); + }) + .sort((a, b) => (b.occurredAt || b.createdAt).localeCompare(a.occurredAt || a.createdAt)); + }, + [conversations, search, sourceFilter, typeFilter], + ); + + async function onTranscriptFilePick(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + + const text = await file.text(); + setForm((prev) => { + const nextTranscript = prev.transcript.trim().length > 0 ? `${prev.transcript}\n\n${text}` : text; + return { ...prev, transcript: nextTranscript }; + }); + + event.target.value = ""; + } + + 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: form.sourceType, + sourceSystem: form.sourceSystem || form.sourcePreset || "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, sourceReference: event.target.value }))} + placeholder="Call ID / meeting URL" + /> +
+
+
+
+ + setForm((prev) => ({ ...prev, occurredAt: event.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, importedAt: event.target.value }))} + /> +
+
+
+ +