From ecae70c06a1ebd65ea640d75430db04764ec33c0 Mon Sep 17 00:00:00 2001 From: Giovanni Carvelli Date: Fri, 6 Feb 2026 15:02:14 -0500 Subject: [PATCH 1/2] Migrate from better-sqlite3 to Cloudflare D1 Replace all filesystem and better-sqlite3 imports in src/lib/db/index.ts with D1 API. All database functions are now async. Removed better-sqlite3-specific setup (fs reads, schema loading, WAL pragmas) and all sync-only functions used only by scripts. Updated all 11 consumers (3 server components, 8 API routes) to use await for DB calls. Co-Authored-By: Claude Haiku 4.5 --- src/app/api/clips/[clipId]/route.ts | 6 +- src/app/api/clips/route.ts | 2 +- src/app/api/participants/route.ts | 2 +- src/app/api/recordings/[id]/clips/route.ts | 8 +- src/app/api/recordings/[id]/route.ts | 4 +- src/app/api/recordings/[id]/summary/route.ts | 6 +- src/app/api/recordings/paginated/route.ts | 6 +- src/app/api/speakers/route.ts | 2 +- src/app/c/[clipId]/page.tsx | 6 +- src/app/page.tsx | 18 +- src/app/recordings/[id]/page.tsx | 22 +- src/lib/db/d1.ts | 396 -------- src/lib/db/index.ts | 939 ++++++++----------- wrangler.toml | 16 +- 14 files changed, 430 insertions(+), 1003 deletions(-) delete mode 100644 src/lib/db/d1.ts diff --git a/src/app/api/clips/[clipId]/route.ts b/src/app/api/clips/[clipId]/route.ts index f41505b..358db03 100644 --- a/src/app/api/clips/[clipId]/route.ts +++ b/src/app/api/clips/[clipId]/route.ts @@ -8,13 +8,13 @@ export async function GET( const { clipId } = await params; try { - const clipRow = getClipById(clipId); + const clipRow = await getClipById(clipId); if (!clipRow) { return NextResponse.json({ error: "Clip not found" }, { status: 404 }); } - const recording = getRecordingById(clipRow.recording_id); + const recording = await getRecordingById(clipRow.recording_id); const clip = { ...dbRowToClip(clipRow), recordingTitle: recording?.title ?? "Unknown Recording", @@ -37,7 +37,7 @@ export async function DELETE( const { clipId } = await params; try { - const deleted = deleteClip(clipId); + const deleted = await deleteClip(clipId); if (!deleted) { return NextResponse.json({ error: "Clip not found" }, { status: 404 }); diff --git a/src/app/api/clips/route.ts b/src/app/api/clips/route.ts index c4497ea..18fd41a 100644 --- a/src/app/api/clips/route.ts +++ b/src/app/api/clips/route.ts @@ -3,7 +3,7 @@ import { getAllClipsWithRecordingTitle, dbRowToClip } from "@/lib/db"; export async function GET() { try { - const clipRows = getAllClipsWithRecordingTitle(); + const clipRows = await getAllClipsWithRecordingTitle(); const clips = clipRows.map((row) => ({ ...dbRowToClip(row), recordingTitle: row.recording_title, diff --git a/src/app/api/participants/route.ts b/src/app/api/participants/route.ts index 3727d48..250a7ae 100644 --- a/src/app/api/participants/route.ts +++ b/src/app/api/participants/route.ts @@ -3,7 +3,7 @@ import { getAllUniqueParticipants } from "@/lib/db"; export async function GET() { try { - const participants = getAllUniqueParticipants(); + const participants = await getAllUniqueParticipants(); return NextResponse.json(participants); } catch (error) { console.error("Failed to fetch participants:", error); diff --git a/src/app/api/recordings/[id]/clips/route.ts b/src/app/api/recordings/[id]/clips/route.ts index 2309498..40a74d6 100644 --- a/src/app/api/recordings/[id]/clips/route.ts +++ b/src/app/api/recordings/[id]/clips/route.ts @@ -14,7 +14,7 @@ export async function GET( const { id: recordingId } = await params; try { - const recording = getRecordingById(recordingId); + const recording = await getRecordingById(recordingId); if (!recording) { return NextResponse.json( { error: "Recording not found" }, @@ -22,7 +22,7 @@ export async function GET( ); } - const clipRows = getClipsByRecordingId(recordingId); + const clipRows = await getClipsByRecordingId(recordingId); const clips = clipRows.map(dbRowToClip); return NextResponse.json(clips); @@ -42,7 +42,7 @@ export async function POST( const { id: recordingId } = await params; try { - const recording = getRecordingById(recordingId); + const recording = await getRecordingById(recordingId); if (!recording) { return NextResponse.json( { error: "Recording not found" }, @@ -82,7 +82,7 @@ export async function POST( } const clipId = nanoid(8); - const clipRow = insertClip({ + const clipRow = await insertClip({ id: clipId, recordingId, title: title || undefined, diff --git a/src/app/api/recordings/[id]/route.ts b/src/app/api/recordings/[id]/route.ts index ad0739c..a6b54d6 100644 --- a/src/app/api/recordings/[id]/route.ts +++ b/src/app/api/recordings/[id]/route.ts @@ -49,7 +49,7 @@ export async function PATCH( const { customTitle } = body; // Verify recording exists - const recording = getRecordingById(id); + const recording = await getRecordingById(id); if (!recording) { return NextResponse.json( { error: "Recording not found" }, @@ -58,7 +58,7 @@ export async function PATCH( } // Update custom title (null to revert to original) - updateRecordingCustomTitle(id, customTitle ?? null); + await updateRecordingCustomTitle(id, customTitle ?? null); return NextResponse.json({ success: true, customTitle: customTitle ?? null }); } catch (error) { diff --git a/src/app/api/recordings/[id]/summary/route.ts b/src/app/api/recordings/[id]/summary/route.ts index e2fe193..e81eed9 100644 --- a/src/app/api/recordings/[id]/summary/route.ts +++ b/src/app/api/recordings/[id]/summary/route.ts @@ -14,7 +14,7 @@ export async function GET( const { id } = await params; try { - const summaryRow = getSummaryByRecordingId(id); + const summaryRow = await getSummaryByRecordingId(id); if (!summaryRow) { return NextResponse.json( @@ -46,7 +46,7 @@ export async function POST( try { // Get transcript segments - const segments = getSegmentsByRecordingId(id); + const segments = await getSegmentsByRecordingId(id); if (segments.length === 0) { return NextResponse.json( @@ -68,7 +68,7 @@ export async function POST( const summary = await generateTranscriptSummary(transcriptSegments); // Save to database - upsertSummary({ + await upsertSummary({ recordingId: id, content: JSON.stringify(summary), model: SUMMARY_MODEL, diff --git a/src/app/api/recordings/paginated/route.ts b/src/app/api/recordings/paginated/route.ts index c421c0e..f81f0b0 100644 --- a/src/app/api/recordings/paginated/route.ts +++ b/src/app/api/recordings/paginated/route.ts @@ -16,15 +16,15 @@ export async function GET(request: Request) { const sourceFilter = source === "zoom" || source === "gong" ? source : "all"; - const result = getRecordingsPaginated(sourceFilter, limit, cursor); + const result = await getRecordingsPaginated(sourceFilter, limit, cursor); - const speakersByRecording = getSpeakersByRecordingIds( + const speakersByRecording = await getSpeakersByRecordingIds( result.items.map((r) => r.id) ); // Fetch summaries if requested (for grid view) const summariesByRecording = includeSummaries - ? getSummariesByRecordingIds(result.items.map((r) => r.id)) + ? await getSummariesByRecordingIds(result.items.map((r) => r.id)) : {}; const recordingsWithMeta = result.items.map((recording) => { diff --git a/src/app/api/speakers/route.ts b/src/app/api/speakers/route.ts index 6ecd871..b0917d1 100644 --- a/src/app/api/speakers/route.ts +++ b/src/app/api/speakers/route.ts @@ -2,6 +2,6 @@ import { NextResponse } from "next/server"; import { getAllUniqueSpeakers } from "@/lib/db"; export async function GET() { - const speakers = getAllUniqueSpeakers(); + const speakers = await getAllUniqueSpeakers(); return NextResponse.json(speakers); } diff --git a/src/app/c/[clipId]/page.tsx b/src/app/c/[clipId]/page.tsx index eade47c..f9634a1 100644 --- a/src/app/c/[clipId]/page.tsx +++ b/src/app/c/[clipId]/page.tsx @@ -15,13 +15,13 @@ export async function generateMetadata({ params: Promise<{ clipId: string }>; }): Promise { const { clipId } = await params; - const clipRow = getClipById(clipId); + const clipRow = await getClipById(clipId); if (!clipRow) { return { title: "Clip Not Found" }; } - const recording = getRecordingById(clipRow.recording_id); + const recording = await getRecordingById(clipRow.recording_id); const clip = dbRowToClip(clipRow); const duration = formatDuration(clip.endTime - clip.startTime); const recordingTitle = recording?.custom_title ?? recording?.title ?? "Recording"; @@ -51,7 +51,7 @@ export default async function ClipRedirectPage({ params: Promise<{ clipId: string }>; }) { const { clipId } = await params; - const clipRow = getClipById(clipId); + const clipRow = await getClipById(clipId); if (!clipRow) { notFound(); diff --git a/src/app/page.tsx b/src/app/page.tsx index bdd7ab3..75cb1ce 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -50,7 +50,7 @@ export default async function HomePage({ // If clips view, fetch clips instead of recordings if (isClipsView) { - const clipRows = getAllClipsWithRecordingTitle(); + const clipRows = await getAllClipsWithRecordingTitle(); const clipsWithRecordings = clipRows.map((row) => ({ ...dbRowToClip(row), recordingTitle: row.recording_title, @@ -76,21 +76,21 @@ export default async function HomePage({ if (speakers.length > 0) { // Speaker search - filter by all selected speakers (AND logic) - const results = searchRecordingsWithSpeaker(q ?? "", speakers, sourceFilter); + const results = await searchRecordingsWithSpeaker(q ?? "", speakers, sourceFilter); recordings = results.map((r) => ({ ...r, match_type: "speaker" as const, match_text: null, match_time: null })); } else if (participant) { // Participant search by email - const results = searchRecordingsWithParticipant(q ?? "", participant, sourceFilter); + const results = await searchRecordingsWithParticipant(q ?? "", participant, sourceFilter); recordings = results.map((r) => ({ ...r, match_type: "speaker" as const, match_text: null, match_time: null })); } else if (q) { - recordings = searchRecordingsWithContext(q, sourceFilter); + recordings = await searchRecordingsWithContext(q, sourceFilter); } else if (isCalendarView) { // Calendar view needs all recordings to display the full timeline - const allRecordings = getRecordingsBySource(sourceFilter); + const allRecordings = await getRecordingsBySource(sourceFilter); recordings = allRecordings.map((r) => ({ ...r, match_type: "title" as const, match_text: null, match_time: null })); } else { // Use paginated query for initial load (faster) - const result = getRecordingsPaginated(sourceFilter, 20); + const result = await getRecordingsPaginated(sourceFilter, 20); paginatedResult = { items: result.items.map((r) => ({ ...r, match_type: "title" as const, match_text: null, match_time: null })), hasMore: result.hasMore, @@ -107,13 +107,13 @@ export default async function HomePage({ if (!gongConfigured) missingCredentials.push("Gong"); // Prepare recordings data for client components - const speakersByRecording = getSpeakersByRecordingIds( + const speakersByRecording = await getSpeakersByRecordingIds( recordings.map((r) => r.id) ); // Fetch summaries for grid view const summariesByRecording = isGridView - ? getSummariesByRecordingIds(recordings.map((r) => r.id)) + ? await getSummariesByRecordingIds(recordings.map((r) => r.id)) : {}; const recordingsWithMeta = recordings.map((recording) => { @@ -151,7 +151,7 @@ export default async function HomePage({ - {getTotalRecordingsCount(sourceFilter)} videos + {await getTotalRecordingsCount(sourceFilter)} videos diff --git a/src/app/recordings/[id]/page.tsx b/src/app/recordings/[id]/page.tsx index 6a77175..bcd4aa8 100644 --- a/src/app/recordings/[id]/page.tsx +++ b/src/app/recordings/[id]/page.tsx @@ -50,26 +50,26 @@ export default async function RecordingPage({ return ; } - // Try SQLite database - const row = getRecordingById(id); + // Try D1 database + const row = await getRecordingById(id); if (!row) { notFound(); } - const segments = getSegmentsByRecordingId(id); - const speakers = getSpeakersByRecordingId(id); - const participants = getParticipantsByRecordingId(id); - const relatedRecordings = getRelatedRecordings(row.title, id); - const videoFiles = getVideoFilesByRecordingId(id); - const chatMessages = getChatMessagesByRecordingId(id); - const summaryRow = getSummaryByRecordingId(id); - const clipRows = getClipsByRecordingId(id); + const segments = await getSegmentsByRecordingId(id); + const speakers = await getSpeakersByRecordingId(id); + const participants = await getParticipantsByRecordingId(id); + const relatedRecordings = await getRelatedRecordings(row.title, id); + const videoFiles = await getVideoFilesByRecordingId(id); + const chatMessages = await getChatMessagesByRecordingId(id); + const summaryRow = await getSummaryByRecordingId(id); + const clipRows = await getClipsByRecordingId(id); const clips = clipRows.map(dbRowToClip); // Get active clip if specified let activeClip: Clip | null = null; if (clipId) { - const clipRow = getClipById(clipId); + const clipRow = await getClipById(clipId); if (clipRow && clipRow.recording_id === id) { activeClip = dbRowToClip(clipRow); } diff --git a/src/lib/db/d1.ts b/src/lib/db/d1.ts deleted file mode 100644 index eeb996c..0000000 --- a/src/lib/db/d1.ts +++ /dev/null @@ -1,396 +0,0 @@ -// Cloudflare D1 Database Layer -// This replaces better-sqlite3 with D1 bindings for Cloudflare Workers - -import type { - RecordingRow, - SegmentRow, - SpeakerRow, - VideoFileRow, - ChatMessageRow, - SummaryRow, - ClipRow, - ParticipantRow, - PaginatedResult, - ClipWithRecordingRow, -} from "./index"; - -// Get D1 database from Cloudflare bindings -export function getDb(): D1Database { - // In Cloudflare Workers, the DB binding is available on the request context - // This will be populated by the Next.js route handlers - if (typeof process !== "undefined" && process.env.DB) { - return (process.env as { DB: D1Database }).DB; - } - throw new Error("D1 database not available. Make sure DB binding is configured in wrangler.toml"); -} - -// Escape SQL LIKE wildcards in user input -function escapeLikeWildcards(str: string): string { - return str.replace(/[%_\\]/g, "\\$&"); -} - -// Query functions -export async function getAllRecordings(): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM recordings WHERE duration >= 60 ORDER BY created_at DESC`) - .all(); - return result.results ?? []; -} - -export async function getRecordingsBySource( - source: "zoom" | "gong" | "all" -): Promise { - const db = getDb(); - if (source === "all") { - return getAllRecordings(); - } - const result = await db - .prepare( - `SELECT * FROM recordings WHERE duration >= 60 AND source = ? ORDER BY created_at DESC` - ) - .bind(source) - .all(); - return result.results ?? []; -} - -export async function getTotalRecordingsCount( - source: "zoom" | "gong" | "all" = "all" -): Promise { - const db = getDb(); - let result; - if (source === "all") { - result = await db - .prepare(`SELECT COUNT(*) as count FROM recordings WHERE duration >= 60`) - .first(); - } else { - result = await db - .prepare( - `SELECT COUNT(*) as count FROM recordings WHERE duration >= 60 AND source = ?` - ) - .bind(source) - .first(); - } - return (result as unknown as { count: number }).count; -} - -export async function getRecordingsPaginated( - source: "zoom" | "gong" | "all", - limit: number = 20, - cursor?: string -): Promise> { - const db = getDb(); - const sourceFilter = source !== "all" ? "AND source = ?" : ""; - - let cursorCreatedAt: string | null = null; - let cursorId: string | null = null; - if (cursor) { - const parts = cursor.split("|"); - cursorCreatedAt = parts[0]; - cursorId = parts[1] || null; - } - - const cursorFilter = cursorCreatedAt - ? cursorId - ? "AND (created_at < ? OR (created_at = ? AND id < ?))" - : "AND created_at < ?" - : ""; - - let query = db.prepare( - `SELECT * FROM recordings - WHERE duration >= 60 ${sourceFilter} ${cursorFilter} - ORDER BY created_at DESC, id DESC - LIMIT ?` - ); - - if (source !== "all" && cursorCreatedAt && cursorId) { - query = query.bind(source, cursorCreatedAt, cursorCreatedAt, cursorId, limit + 1); - } else if (source !== "all" && cursorCreatedAt) { - query = query.bind(source, cursorCreatedAt, limit + 1); - } else if (source !== "all") { - query = query.bind(source, limit + 1); - } else if (cursorCreatedAt && cursorId) { - query = query.bind(cursorCreatedAt, cursorCreatedAt, cursorId, limit + 1); - } else if (cursorCreatedAt) { - query = query.bind(cursorCreatedAt, limit + 1); - } else { - query = query.bind(limit + 1); - } - - const result = await query.all(); - const rows = result.results ?? []; - - const hasMore = rows.length > limit; - const items = hasMore ? rows.slice(0, limit) : rows; - const lastItem = items[items.length - 1]; - const nextCursor = hasMore && lastItem ? `${lastItem.created_at}|${lastItem.id}` : null; - - return { items, hasMore, nextCursor }; -} - -export function isMediaUrlExpired(expiresAt: string | null): boolean { - if (!expiresAt) return false; - return new Date(expiresAt) < new Date(); -} - -export async function updateMediaUrl( - id: string, - videoUrl: string, - expiresAt: string -): Promise { - const db = getDb(); - await db - .prepare( - `UPDATE recordings SET video_url = ?, media_url_expires_at = ?, synced_at = ? WHERE id = ?` - ) - .bind(videoUrl, expiresAt, new Date().toISOString(), id) - .run(); -} - -export async function updateRecordingCustomTitle( - id: string, - customTitle: string | null -): Promise { - const db = getDb(); - await db - .prepare(`UPDATE recordings SET custom_title = ? WHERE id = ?`) - .bind(customTitle, id) - .run(); -} - -export async function searchRecordings( - query: string, - source?: "zoom" | "gong" | "all" -): Promise { - const db = getDb(); - const searchTerm = `%${escapeLikeWildcards(query)}%`; - - let stmt; - if (source && source !== "all") { - stmt = db - .prepare( - `SELECT DISTINCT r.* FROM recordings r - LEFT JOIN segments s ON r.id = s.recording_id - WHERE r.duration >= 60 AND r.source = ? AND (r.title LIKE ? ESCAPE '\\' OR r.custom_title LIKE ? ESCAPE '\\' OR s.text LIKE ? ESCAPE '\\' OR s.speaker LIKE ? ESCAPE '\\') - ORDER BY r.created_at DESC` - ) - .bind(source, searchTerm, searchTerm, searchTerm, searchTerm); - } else { - stmt = db - .prepare( - `SELECT DISTINCT r.* FROM recordings r - LEFT JOIN segments s ON r.id = s.recording_id - WHERE r.duration >= 60 AND (r.title LIKE ? ESCAPE '\\' OR r.custom_title LIKE ? ESCAPE '\\' OR s.text LIKE ? ESCAPE '\\' OR s.speaker LIKE ? ESCAPE '\\') - ORDER BY r.created_at DESC` - ) - .bind(searchTerm, searchTerm, searchTerm, searchTerm); - } - - const result = await stmt.all(); - return result.results ?? []; -} - -export async function getRecordingById(id: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM recordings WHERE id = ?`) - .bind(id) - .first(); - return result ?? undefined; -} - -export async function getSegmentsByRecordingId(recordingId: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM segments WHERE recording_id = ? ORDER BY start_time`) - .bind(recordingId) - .all(); - return result.results ?? []; -} - -export async function getSpeakersByRecordingId(recordingId: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM speakers WHERE recording_id = ?`) - .bind(recordingId) - .all(); - return result.results ?? []; -} - -export async function getVideoFilesByRecordingId(recordingId: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM video_files WHERE recording_id = ?`) - .bind(recordingId) - .all(); - return result.results ?? []; -} - -export async function getChatMessagesByRecordingId(recordingId: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM chat_messages WHERE recording_id = ? ORDER BY timestamp`) - .bind(recordingId) - .all(); - return result.results ?? []; -} - -export async function getSummaryByRecordingId(recordingId: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM summaries WHERE recording_id = ?`) - .bind(recordingId) - .first(); - return result ?? undefined; -} - -export async function upsertSummary(summary: { - recordingId: string; - content: string; - model: string; -}): Promise { - const db = getDb(); - const id = `summary-${summary.recordingId}`; - await db - .prepare( - `INSERT OR REPLACE INTO summaries (id, recording_id, content, model, generated_at) - VALUES (?, ?, ?, ?, ?)` - ) - .bind( - id, - summary.recordingId, - summary.content, - summary.model, - new Date().toISOString() - ) - .run(); -} - -export async function getParticipantsByRecordingId(recordingId: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM participants WHERE recording_id = ?`) - .bind(recordingId) - .all(); - return result.results ?? []; -} - -export async function getAllUniqueSpeakers(): Promise<{ name: string; color: string; count: number }[]> { - const db = getDb(); - const result = await db - .prepare( - `SELECT name, color, COUNT(DISTINCT recording_id) as count - FROM speakers - GROUP BY name - ORDER BY count DESC, name ASC` - ) - .all<{ name: string; color: string; count: number }>(); - return result.results ?? []; -} - -export async function getAllUniqueParticipants(): Promise<{ email: string; name: string; count: number }[]> { - const db = getDb(); - const result = await db - .prepare( - `SELECT email, name, COUNT(DISTINCT recording_id) as count - FROM participants - WHERE email IS NOT NULL AND email != '' - GROUP BY email - ORDER BY count DESC, name ASC` - ) - .all<{ email: string; name: string; count: number }>(); - return result.results ?? []; -} - -// Clip functions -export async function getAllClipsWithRecordingTitle(): Promise { - const db = getDb(); - const result = await db - .prepare( - `SELECT c.*, COALESCE(r.custom_title, r.title) as recording_title - FROM clips c - INNER JOIN recordings r ON c.recording_id = r.id - ORDER BY c.created_at DESC` - ) - .all(); - return result.results ?? []; -} - -export async function getClipById(id: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM clips WHERE id = ?`) - .bind(id) - .first(); - return result ?? undefined; -} - -export async function getClipsByRecordingId(recordingId: string): Promise { - const db = getDb(); - const result = await db - .prepare(`SELECT * FROM clips WHERE recording_id = ? ORDER BY start_time`) - .bind(recordingId) - .all(); - return result.results ?? []; -} - -export async function insertClip(clip: { - id: string; - recordingId: string; - title?: string; - startTime: number; - endTime: number; -}): Promise { - const db = getDb(); - const title = clip.title || null; - const createdAt = new Date().toISOString(); - - await db - .prepare( - `INSERT INTO clips (id, recording_id, title, start_time, end_time, created_at) - VALUES (?, ?, ?, ?, ?, ?)` - ) - .bind(clip.id, clip.recordingId, title, clip.startTime, clip.endTime, createdAt) - .run(); - - return { - id: clip.id, - recording_id: clip.recordingId, - title, - start_time: clip.startTime, - end_time: clip.endTime, - created_at: createdAt, - }; -} - -export async function deleteClip(id: string): Promise { - const db = getDb(); - const result = await db - .prepare(`DELETE FROM clips WHERE id = ?`) - .bind(id) - .run(); - return result.meta.changes > 0; -} - -export async function getRelatedRecordings(title: string, excludeId: string): Promise { - const GENERIC_TITLES = [ - "Google Calendar Meeting (not synced)", - "Zoom Meeting", - "Personal Meeting Room", - ]; - - if (GENERIC_TITLES.includes(title)) { - return []; - } - - const db = getDb(); - const result = await db - .prepare( - `SELECT * FROM recordings - WHERE title = ? AND id != ? AND duration >= 60 - ORDER BY created_at DESC` - ) - .bind(title, excludeId) - .all(); - return result.results ?? []; -} diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index eecc2b7..03ff1bc 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,78 +1,16 @@ -import Database from "better-sqlite3"; -import { readFileSync, existsSync, mkdirSync } from "fs"; -import { join } from "path"; +// Cloudflare D1 Database Layer import type { Recording, TranscriptSegment, Speaker, Clip, Participant } from "@/types/video"; -const DB_PATH = join(process.cwd(), "data", "recordings.db"); - -let db: Database.Database | null = null; - -function runMigrations(database: Database.Database): void { - // Get existing columns in recordings table - const columns = database - .prepare("PRAGMA table_info(recordings)") - .all() as { name: string }[]; - const columnNames = new Set(columns.map((c) => c.name)); - - // Add poster_url column if it doesn't exist - if (!columnNames.has("poster_url")) { - database.exec("ALTER TABLE recordings ADD COLUMN poster_url TEXT"); - } - - // Add preview_gif_url column if it doesn't exist - if (!columnNames.has("preview_gif_url")) { - database.exec("ALTER TABLE recordings ADD COLUMN preview_gif_url TEXT"); +// Get D1 database from Cloudflare bindings +// In Cloudflare Workers via OpenNext, the DB binding is available on process.env +function getDb(): D1Database { + if (typeof process !== "undefined" && process.env.DB) { + return (process.env as { DB: D1Database }).DB; } + throw new Error("D1 database not available. Make sure DB binding is configured in wrangler.toml"); } -export function getDb(): Database.Database { - if (!db) { - const dataDir = join(process.cwd(), "data"); - if (!existsSync(dataDir)) { - mkdirSync(dataDir, { recursive: true }); - } - - db = new Database(DB_PATH); - db.pragma("journal_mode = WAL"); - db.pragma("foreign_keys = ON"); - - // Initialize schema - const schemaPath = join(process.cwd(), "src", "lib", "db", "schema.sql"); - const schema = readFileSync(schemaPath, "utf-8"); - - // Separate main schema from migration comments - const migrationRegex = - /-- MIGRATION:ADD_COLUMN:(\w+):(\w+):(.+)/g; - const migrations: { table: string; column: string; definition: string }[] = - []; - let match; - while ((match = migrationRegex.exec(schema)) !== null) { - migrations.push({ - table: match[1], - column: match[2], - definition: match[3], - }); - } - - // Run main schema (CREATE TABLE IF NOT EXISTS statements are safe) - db.exec(schema); - - // Run migrations with error handling (column may already exist) - for (const migration of migrations) { - try { - db.exec( - `ALTER TABLE ${migration.table} ADD COLUMN ${migration.column} ${migration.definition}` - ); - } catch { - // Column already exists, ignore - } - } - - // Run additional migrations for preview GIFs - runMigrations(db); - } - return db; -} +// --- Type exports --- export interface RecordingRow { id: string; @@ -150,61 +88,138 @@ export interface ParticipantRow { duration: number | null; } -// Query functions -export function getAllRecordings(): RecordingRow[] { +export interface PaginatedResult { + items: T[]; + hasMore: boolean; + nextCursor: string | null; +} + +export interface SearchResultRow extends RecordingRow { + match_type: "title" | "custom_title" | "transcript" | "speaker"; + match_text: string | null; + match_time: number | null; +} + +export interface ClipWithRecordingRow extends ClipRow { + recording_title: string; +} + +// --- Utility functions --- + +function escapeLikeWildcards(str: string): string { + return str.replace(/[%_\\]/g, "\\$&"); +} + +export function isMediaUrlExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); +} + +// --- Pure transform functions --- + +export function dbRowToRecording( + row: RecordingRow, + segments: SegmentRow[], + speakers: SpeakerRow[], + accessToken?: string +): Recording { + const videoUrl = + accessToken && row.source === "zoom" + ? `${row.video_url}?access_token=${accessToken}` + : row.video_url; + + return { + id: row.id, + title: row.title, + customTitle: row.custom_title ?? undefined, + description: row.description ?? undefined, + videoUrl, + posterUrl: undefined, + duration: row.duration, + space: row.space, + source: row.source || "zoom", + mediaType: (row.media_type as "video" | "audio") || "video", + createdAt: row.created_at, + speakers: speakers.map((s) => ({ + id: s.id, + name: s.name, + color: s.color, + })), + transcript: segments.map((s) => ({ + id: s.id, + startTime: s.start_time, + endTime: s.end_time, + speaker: s.speaker, + text: s.text, + })), + }; +} + +export function dbRowToClip(row: ClipRow): Clip { + return { + id: row.id, + recordingId: row.recording_id, + title: row.title, + startTime: row.start_time, + endTime: row.end_time, + createdAt: row.created_at, + }; +} + +// --- Query functions --- + +export async function getAllRecordings(): Promise { const db = getDb(); - return db + const result = await db .prepare(`SELECT * FROM recordings WHERE duration >= 60 ORDER BY created_at DESC`) - .all() as RecordingRow[]; + .all(); + return result.results ?? []; } -export function getRecordingsBySource( +export async function getRecordingsBySource( source: "zoom" | "gong" | "all" -): RecordingRow[] { - const db = getDb(); +): Promise { if (source === "all") { return getAllRecordings(); } - return db + const db = getDb(); + const result = await db .prepare( `SELECT * FROM recordings WHERE duration >= 60 AND source = ? ORDER BY created_at DESC` ) - .all(source) as RecordingRow[]; + .bind(source) + .all(); + return result.results ?? []; } -export function getTotalRecordingsCount( +export async function getTotalRecordingsCount( source: "zoom" | "gong" | "all" = "all" -): number { +): Promise { const db = getDb(); + let result; if (source === "all") { - const result = db + result = await db .prepare(`SELECT COUNT(*) as count FROM recordings WHERE duration >= 60`) - .get() as { count: number }; - return result.count; + .first<{ count: number }>(); + } else { + result = await db + .prepare( + `SELECT COUNT(*) as count FROM recordings WHERE duration >= 60 AND source = ?` + ) + .bind(source) + .first<{ count: number }>(); } - const result = db - .prepare( - `SELECT COUNT(*) as count FROM recordings WHERE duration >= 60 AND source = ?` - ) - .get(source) as { count: number }; - return result.count; -} - -export interface PaginatedResult { - items: T[]; - hasMore: boolean; - nextCursor: string | null; + return result?.count ?? 0; } -export function getRecordingsPaginated( +export async function getRecordingsPaginated( source: "zoom" | "gong" | "all", limit: number = 20, cursor?: string -): PaginatedResult { +): Promise> { const db = getDb(); const sourceFilter = source !== "all" ? "AND source = ?" : ""; - // Parse compound cursor (created_at|id) for stable pagination let cursorCreatedAt: string | null = null; let cursorId: string | null = null; if (cursor) { @@ -213,7 +228,6 @@ export function getRecordingsPaginated( cursorId = parts[1] || null; } - // Use compound cursor to avoid skipping/duplicating records with same timestamp const cursorFilter = cursorCreatedAt ? cursorId ? "AND (created_at < ? OR (created_at = ? AND id < ?))" @@ -231,15 +245,17 @@ export function getRecordingsPaginated( } params.push(limit + 1); - const rows = db + const result = await db .prepare( `SELECT * FROM recordings WHERE duration >= 60 ${sourceFilter} ${cursorFilter} ORDER BY created_at DESC, id DESC LIMIT ?` ) - .all(...params) as RecordingRow[]; + .bind(...params) + .all(); + const rows = result.results ?? []; const hasMore = rows.length > limit; const items = hasMore ? rows.slice(0, limit) : rows; const lastItem = items[items.length - 1]; @@ -248,82 +264,105 @@ export function getRecordingsPaginated( return { items, hasMore, nextCursor }; } -export function isMediaUrlExpired(expiresAt: string | null): boolean { - if (!expiresAt) return false; - return new Date(expiresAt) < new Date(); +export async function getRecordingById(id: string): Promise { + const db = getDb(); + const result = await db + .prepare(`SELECT * FROM recordings WHERE id = ?`) + .bind(id) + .first(); + return result ?? undefined; +} + +const GENERIC_TITLES = [ + "Google Calendar Meeting (not synced)", + "Zoom Meeting", + "Personal Meeting Room", +]; + +export async function getRelatedRecordings(title: string, excludeId: string): Promise { + if (GENERIC_TITLES.includes(title)) { + return []; + } + + const db = getDb(); + const result = await db + .prepare( + `SELECT * FROM recordings + WHERE title = ? AND id != ? AND duration >= 60 + ORDER BY created_at DESC` + ) + .bind(title, excludeId) + .all(); + return result.results ?? []; } -export function updateMediaUrl( +export async function updateMediaUrl( id: string, videoUrl: string, expiresAt: string -): void { +): Promise { const db = getDb(); - db.prepare( - `UPDATE recordings SET video_url = ?, media_url_expires_at = ?, synced_at = ? WHERE id = ?` - ).run(videoUrl, expiresAt, new Date().toISOString(), id); + await db + .prepare( + `UPDATE recordings SET video_url = ?, media_url_expires_at = ?, synced_at = ? WHERE id = ?` + ) + .bind(videoUrl, expiresAt, new Date().toISOString(), id) + .run(); } -export function updateRecordingCustomTitle( +export async function updateRecordingCustomTitle( id: string, customTitle: string | null -): void { +): Promise { const db = getDb(); - db.prepare(`UPDATE recordings SET custom_title = ? WHERE id = ?`).run( - customTitle, - id - ); + await db + .prepare(`UPDATE recordings SET custom_title = ? WHERE id = ?`) + .bind(customTitle, id) + .run(); } -// Escape SQL LIKE wildcards in user input -function escapeLikeWildcards(str: string): string { - return str.replace(/[%_\\]/g, "\\$&"); -} +// --- Search functions --- -export function searchRecordings( +export async function searchRecordings( query: string, source?: "zoom" | "gong" | "all" -): RecordingRow[] { +): Promise { const db = getDb(); const searchTerm = `%${escapeLikeWildcards(query)}%`; - // Search in titles (including custom titles) and transcript text if (source && source !== "all") { - return db + const result = await db .prepare( `SELECT DISTINCT r.* FROM recordings r LEFT JOIN segments s ON r.id = s.recording_id WHERE r.duration >= 60 AND r.source = ? AND (r.title LIKE ? ESCAPE '\\' OR r.custom_title LIKE ? ESCAPE '\\' OR s.text LIKE ? ESCAPE '\\' OR s.speaker LIKE ? ESCAPE '\\') ORDER BY r.created_at DESC` ) - .all(source, searchTerm, searchTerm, searchTerm, searchTerm) as RecordingRow[]; + .bind(source, searchTerm, searchTerm, searchTerm, searchTerm) + .all(); + return result.results ?? []; } - return db + const result = await db .prepare( `SELECT DISTINCT r.* FROM recordings r LEFT JOIN segments s ON r.id = s.recording_id WHERE r.duration >= 60 AND (r.title LIKE ? ESCAPE '\\' OR r.custom_title LIKE ? ESCAPE '\\' OR s.text LIKE ? ESCAPE '\\' OR s.speaker LIKE ? ESCAPE '\\') ORDER BY r.created_at DESC` ) - .all(searchTerm, searchTerm, searchTerm, searchTerm) as RecordingRow[]; -} - -export interface SearchResultRow extends RecordingRow { - match_type: "title" | "custom_title" | "transcript" | "speaker"; - match_text: string | null; - match_time: number | null; + .bind(searchTerm, searchTerm, searchTerm, searchTerm) + .all(); + return result.results ?? []; } -export function searchRecordingsWithContext( +export async function searchRecordingsWithContext( query: string, source?: "zoom" | "gong" | "all" -): SearchResultRow[] { +): Promise { const db = getDb(); const searchTerm = `%${escapeLikeWildcards(query)}%`; - const sourceFilter = source && source !== "all" ? `AND r.source = '${source}'` : ""; + const sourceFilter = source && source !== "all" ? "AND r.source = ?" : ""; - // Query that determines match type and includes context const sql = ` SELECT DISTINCT r.*, CASE @@ -350,119 +389,39 @@ export function searchRecordingsWithContext( ORDER BY r.created_at DESC `; - return db - .prepare(sql) - .all( - searchTerm, searchTerm, searchTerm, // match_type CASE - searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, // match_text CASE - searchTerm, searchTerm, searchTerm, searchTerm, // match_time CASE - searchTerm, searchTerm, searchTerm, searchTerm // WHERE clause - ) as SearchResultRow[]; -} - -export function getRecordingById(id: string): RecordingRow | undefined { - const db = getDb(); - return db - .prepare(`SELECT * FROM recordings WHERE id = ?`) - .get(id) as RecordingRow | undefined; -} - -// Generic/default meeting titles that shouldn't be grouped as related -const GENERIC_TITLES = [ - "Google Calendar Meeting (not synced)", - "Zoom Meeting", - "Personal Meeting Room", -]; - -export function getRelatedRecordings(title: string, excludeId: string): RecordingRow[] { - // Don't group meetings with generic/default titles - if (GENERIC_TITLES.includes(title)) { - return []; - } - - const db = getDb(); - return db - .prepare( - `SELECT * FROM recordings - WHERE title = ? AND id != ? AND duration >= 60 - ORDER BY created_at DESC` - ) - .all(title, excludeId) as RecordingRow[]; -} - -export function getSegmentsByRecordingId(recordingId: string): SegmentRow[] { - const db = getDb(); - return db - .prepare( - `SELECT * FROM segments WHERE recording_id = ? ORDER BY start_time` - ) - .all(recordingId) as SegmentRow[]; -} - -export function getSpeakersByRecordingId(recordingId: string): SpeakerRow[] { - const db = getDb(); - return db - .prepare(`SELECT * FROM speakers WHERE recording_id = ?`) - .all(recordingId) as SpeakerRow[]; -} + const params = [ + searchTerm, searchTerm, searchTerm, // match_type CASE + searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, // match_text CASE + searchTerm, searchTerm, searchTerm, searchTerm, // match_time CASE + ...(source && source !== "all" ? [source] : []), + searchTerm, searchTerm, searchTerm, searchTerm, // WHERE clause + ]; -export function getSpeakersByRecordingIds( - recordingIds: string[] -): Record { - if (recordingIds.length === 0) return {}; - - const db = getDb(); - const placeholders = recordingIds.map(() => "?").join(", "); - const rows = db - .prepare( - `SELECT * FROM speakers WHERE recording_id IN (${placeholders})` - ) - .all(...recordingIds) as SpeakerRow[]; - - return rows.reduce>((acc, row) => { - if (!acc[row.recording_id]) acc[row.recording_id] = []; - acc[row.recording_id].push(row); - return acc; - }, {}); -} - -export function getAllUniqueSpeakers(): { name: string; color: string; count: number }[] { - const db = getDb(); - return db - .prepare( - `SELECT name, color, COUNT(DISTINCT recording_id) as count - FROM speakers - GROUP BY name - ORDER BY count DESC, name ASC` - ) - .all() as { name: string; color: string; count: number }[]; + const result = await db + .prepare(sql) + .bind(...params) + .all(); + return result.results ?? []; } -export function searchRecordingsWithSpeaker( +export async function searchRecordingsWithSpeaker( query: string, speakerNames: string | string[], source?: "zoom" | "gong" | "all" -): RecordingRow[] { +): Promise { const db = getDb(); const searchTerm = `%${escapeLikeWildcards(query)}%`; const sourceFilter = source && source !== "all" ? source : null; - // Normalize to array const speakers = Array.isArray(speakerNames) ? speakerNames : [speakerNames]; if (speakers.length === 0) return []; - // Build placeholders for IN clause const placeholders = speakers.map(() => "?").join(", "); - - // Speaker filter: recording must have ALL selected speakers (AND logic) - // We count how many of the selected speakers are in each recording - // and only include recordings where the count equals the number we're looking for const speakerFilter = ` (SELECT COUNT(DISTINCT sp.name) FROM speakers sp WHERE sp.recording_id = r.id AND sp.name IN (${placeholders})) = ?`; if (query.trim()) { - // Search with both text query and speaker filter const baseQuery = ` SELECT DISTINCT r.* FROM recordings r LEFT JOIN segments s ON r.id = s.recording_id @@ -476,9 +435,9 @@ export function searchRecordingsWithSpeaker( ? [...speakers, speakers.length, sourceFilter, searchTerm, searchTerm, searchTerm, searchTerm] : [...speakers, speakers.length, searchTerm, searchTerm, searchTerm, searchTerm]; - return db.prepare(baseQuery).all(...params) as RecordingRow[]; + const result = await db.prepare(baseQuery).bind(...params).all(); + return result.results ?? []; } else { - // Just filter by speaker(s) const baseQuery = ` SELECT DISTINCT r.* FROM recordings r WHERE r.duration >= 60 @@ -490,64 +449,23 @@ export function searchRecordingsWithSpeaker( ? [...speakers, speakers.length, sourceFilter] : [...speakers, speakers.length]; - return db.prepare(baseQuery).all(...params) as RecordingRow[]; + const result = await db.prepare(baseQuery).bind(...params).all(); + return result.results ?? []; } } -// Participant functions -export function getParticipantsByRecordingId(recordingId: string): ParticipantRow[] { - const db = getDb(); - return db - .prepare(`SELECT * FROM participants WHERE recording_id = ?`) - .all(recordingId) as ParticipantRow[]; -} - -export function getParticipantsByRecordingIds( - recordingIds: string[] -): Record { - if (recordingIds.length === 0) return {}; - - const db = getDb(); - const placeholders = recordingIds.map(() => "?").join(", "); - const rows = db - .prepare( - `SELECT * FROM participants WHERE recording_id IN (${placeholders})` - ) - .all(...recordingIds) as ParticipantRow[]; - - return rows.reduce>((acc, row) => { - if (!acc[row.recording_id]) acc[row.recording_id] = []; - acc[row.recording_id].push(row); - return acc; - }, {}); -} - -export function getAllUniqueParticipants(): { email: string; name: string; count: number }[] { - const db = getDb(); - return db - .prepare( - `SELECT email, name, COUNT(DISTINCT recording_id) as count - FROM participants - WHERE email IS NOT NULL AND email != '' - GROUP BY email - ORDER BY count DESC, name ASC` - ) - .all() as { email: string; name: string; count: number }[]; -} - -export function searchRecordingsWithParticipant( +export async function searchRecordingsWithParticipant( query: string, participantEmail: string, source?: "zoom" | "gong" | "all" -): RecordingRow[] { +): Promise { const db = getDb(); const searchTerm = `%${escapeLikeWildcards(query)}%`; const sourceFilter = source && source !== "all" ? source : null; if (query.trim()) { - // Search with both text query and participant filter if (sourceFilter) { - return db + const result = await db .prepare( `SELECT DISTINCT r.* FROM recordings r INNER JOIN participants p ON r.id = p.recording_id @@ -558,9 +476,11 @@ export function searchRecordingsWithParticipant( AND (r.title LIKE ? ESCAPE '\\' OR r.custom_title LIKE ? ESCAPE '\\' OR s.text LIKE ? ESCAPE '\\' OR s.speaker LIKE ? ESCAPE '\\') ORDER BY r.created_at DESC` ) - .all(sourceFilter, participantEmail, searchTerm, searchTerm, searchTerm, searchTerm) as RecordingRow[]; + .bind(sourceFilter, participantEmail, searchTerm, searchTerm, searchTerm, searchTerm) + .all(); + return result.results ?? []; } - return db + const result = await db .prepare( `SELECT DISTINCT r.* FROM recordings r INNER JOIN participants p ON r.id = p.recording_id @@ -570,368 +490,258 @@ export function searchRecordingsWithParticipant( AND (r.title LIKE ? ESCAPE '\\' OR r.custom_title LIKE ? ESCAPE '\\' OR s.text LIKE ? ESCAPE '\\' OR s.speaker LIKE ? ESCAPE '\\') ORDER BY r.created_at DESC` ) - .all(participantEmail, searchTerm, searchTerm, searchTerm, searchTerm) as RecordingRow[]; + .bind(participantEmail, searchTerm, searchTerm, searchTerm, searchTerm) + .all(); + return result.results ?? []; } else { - // Just filter by participant if (sourceFilter) { - return db + const result = await db .prepare( `SELECT DISTINCT r.* FROM recordings r INNER JOIN participants p ON r.id = p.recording_id WHERE r.duration >= 60 AND r.source = ? AND p.email = ? ORDER BY r.created_at DESC` ) - .all(sourceFilter, participantEmail) as RecordingRow[]; + .bind(sourceFilter, participantEmail) + .all(); + return result.results ?? []; } - return db + const result = await db .prepare( `SELECT DISTINCT r.* FROM recordings r INNER JOIN participants p ON r.id = p.recording_id WHERE r.duration >= 60 AND p.email = ? ORDER BY r.created_at DESC` ) - .all(participantEmail) as RecordingRow[]; + .bind(participantEmail) + .all(); + return result.results ?? []; } } -export function insertParticipants( - recordingId: string, - participants: Participant[] -): void { - const db = getDb(); - const stmt = db.prepare( - `INSERT OR REPLACE INTO participants (id, recording_id, name, email, user_id, join_time, leave_time, duration) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ); - - const insertMany = db.transaction((parts: Participant[]) => { - for (const p of parts) { - stmt.run( - `${recordingId}-${p.id}`, - recordingId, - p.name, - p.email ?? null, - p.userId ?? null, - p.joinTime ?? null, - p.leaveTime ?? null, - p.duration ?? null - ); - } - }); +// --- Segment functions --- - insertMany(participants); -} - -export function getVideoFilesByRecordingId(recordingId: string): VideoFileRow[] { +export async function getSegmentsByRecordingId(recordingId: string): Promise { const db = getDb(); - return db - .prepare(`SELECT * FROM video_files WHERE recording_id = ?`) - .all(recordingId) as VideoFileRow[]; + const result = await db + .prepare(`SELECT * FROM segments WHERE recording_id = ? ORDER BY start_time`) + .bind(recordingId) + .all(); + return result.results ?? []; } -export function getChatMessagesByRecordingId(recordingId: string): ChatMessageRow[] { +// --- Speaker functions --- + +export async function getSpeakersByRecordingId(recordingId: string): Promise { const db = getDb(); - return db - .prepare(`SELECT * FROM chat_messages WHERE recording_id = ? ORDER BY timestamp`) - .all(recordingId) as ChatMessageRow[]; + const result = await db + .prepare(`SELECT * FROM speakers WHERE recording_id = ?`) + .bind(recordingId) + .all(); + return result.results ?? []; } -// Transform DB rows to app types -export function dbRowToRecording( - row: RecordingRow, - segments: SegmentRow[], - speakers: SpeakerRow[], - accessToken?: string -): Recording { - // Only append access token for Zoom recordings (Gong URLs are pre-signed S3 URLs) - const videoUrl = - accessToken && row.source === "zoom" - ? `${row.video_url}?access_token=${accessToken}` - : row.video_url; +export async function getSpeakersByRecordingIds( + recordingIds: string[] +): Promise> { + if (recordingIds.length === 0) return {}; - return { - id: row.id, - title: row.title, - customTitle: row.custom_title ?? undefined, - description: row.description ?? undefined, - videoUrl, - posterUrl: undefined, - duration: row.duration, - space: row.space, - source: row.source || "zoom", - mediaType: (row.media_type as "video" | "audio") || "video", - createdAt: row.created_at, - speakers: speakers.map((s) => ({ - id: s.id, - name: s.name, - color: s.color, - })), - transcript: segments.map((s) => ({ - id: s.id, - startTime: s.start_time, - endTime: s.end_time, - speaker: s.speaker, - text: s.text, - })), - }; + const db = getDb(); + const placeholders = recordingIds.map(() => "?").join(", "); + const result = await db + .prepare( + `SELECT * FROM speakers WHERE recording_id IN (${placeholders})` + ) + .bind(...recordingIds) + .all(); + + const rows = result.results ?? []; + return rows.reduce>((acc, row) => { + if (!acc[row.recording_id]) acc[row.recording_id] = []; + acc[row.recording_id].push(row); + return acc; + }, {}); } -// Insert/update functions for sync script -export function upsertRecording(recording: { - id: string; - title: string; - description?: string; - videoUrl: string; - duration: number; - space: string; - source?: string; - mediaType?: string; - mediaUrlExpiresAt?: string; - createdAt: string; -}): void { +export async function getAllUniqueSpeakers(): Promise<{ name: string; color: string; count: number }[]> { const db = getDb(); - // Use INSERT ... ON CONFLICT to preserve custom_title when syncing - db.prepare( - `INSERT INTO recordings (id, title, description, video_url, duration, space, source, media_type, media_url_expires_at, created_at, synced_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - title = excluded.title, - description = excluded.description, - video_url = excluded.video_url, - duration = excluded.duration, - space = excluded.space, - source = excluded.source, - media_type = excluded.media_type, - media_url_expires_at = excluded.media_url_expires_at, - synced_at = excluded.synced_at` - ).run( - recording.id, - recording.title, - recording.description ?? null, - recording.videoUrl, - recording.duration, - recording.space, - recording.source ?? "zoom", - recording.mediaType ?? "video", - recording.mediaUrlExpiresAt ?? null, - recording.createdAt, - new Date().toISOString() - ); -} - -export function deleteRecordingData(recordingId: string): void { - const db = getDb(); - db.prepare(`DELETE FROM segments WHERE recording_id = ?`).run(recordingId); - db.prepare(`DELETE FROM speakers WHERE recording_id = ?`).run(recordingId); - db.prepare(`DELETE FROM video_files WHERE recording_id = ?`).run(recordingId); - db.prepare(`DELETE FROM chat_messages WHERE recording_id = ?`).run(recordingId); - db.prepare(`DELETE FROM participants WHERE recording_id = ?`).run(recordingId); + const result = await db + .prepare( + `SELECT name, color, COUNT(DISTINCT recording_id) as count + FROM speakers + GROUP BY name + ORDER BY count DESC, name ASC` + ) + .all<{ name: string; color: string; count: number }>(); + return result.results ?? []; } -export function insertSegments( - recordingId: string, - segments: TranscriptSegment[] -): void { - const db = getDb(); - const stmt = db.prepare( - `INSERT INTO segments (id, recording_id, start_time, end_time, speaker, text) - VALUES (?, ?, ?, ?, ?, ?)` - ); - - const insertMany = db.transaction((segs: TranscriptSegment[]) => { - for (const seg of segs) { - stmt.run( - `${recordingId}-${seg.id}`, - recordingId, - seg.startTime, - seg.endTime, - seg.speaker, - seg.text - ); - } - }); +// --- Participant functions --- - insertMany(segments); +export async function getParticipantsByRecordingId(recordingId: string): Promise { + const db = getDb(); + const result = await db + .prepare(`SELECT * FROM participants WHERE recording_id = ?`) + .bind(recordingId) + .all(); + return result.results ?? []; } -export function insertSpeakers(recordingId: string, speakers: Speaker[]): void { +export async function getParticipantsByRecordingIds( + recordingIds: string[] +): Promise> { + if (recordingIds.length === 0) return {}; + const db = getDb(); - const stmt = db.prepare( - `INSERT INTO speakers (id, recording_id, name, color) - VALUES (?, ?, ?, ?)` - ); - - const insertMany = db.transaction((spks: Speaker[]) => { - for (const spk of spks) { - stmt.run(`${recordingId}-${spk.id}`, recordingId, spk.name, spk.color); - } - }); + const placeholders = recordingIds.map(() => "?").join(", "); + const result = await db + .prepare( + `SELECT * FROM participants WHERE recording_id IN (${placeholders})` + ) + .bind(...recordingIds) + .all(); - insertMany(speakers); + const rows = result.results ?? []; + return rows.reduce>((acc, row) => { + if (!acc[row.recording_id]) acc[row.recording_id] = []; + acc[row.recording_id].push(row); + return acc; + }, {}); } -export function insertVideoFiles( - recordingId: string, - videoFiles: { viewType: string; videoUrl: string }[] -): void { +export async function getAllUniqueParticipants(): Promise<{ email: string; name: string; count: number }[]> { const db = getDb(); - const stmt = db.prepare( - `INSERT INTO video_files (id, recording_id, view_type, video_url) - VALUES (?, ?, ?, ?)` - ); - - const insertMany = db.transaction( - (files: { viewType: string; videoUrl: string }[]) => { - for (const file of files) { - stmt.run( - `${recordingId}-${file.viewType}`, - recordingId, - file.viewType, - file.videoUrl - ); - } - } - ); - - insertMany(videoFiles); + const result = await db + .prepare( + `SELECT email, name, COUNT(DISTINCT recording_id) as count + FROM participants + WHERE email IS NOT NULL AND email != '' + GROUP BY email + ORDER BY count DESC, name ASC` + ) + .all<{ email: string; name: string; count: number }>(); + return result.results ?? []; } -export interface ChatMessage { - id: string; - timestamp: number; - sender: string; - message: string; -} +// --- Video file functions --- -export function insertChatMessages( - recordingId: string, - messages: ChatMessage[] -): void { +export async function getVideoFilesByRecordingId(recordingId: string): Promise { const db = getDb(); - const stmt = db.prepare( - `INSERT INTO chat_messages (id, recording_id, timestamp, sender, message) - VALUES (?, ?, ?, ?, ?)` - ); - - const insertMany = db.transaction((msgs: ChatMessage[]) => { - for (const msg of msgs) { - stmt.run( - `${recordingId}-${msg.id}`, - recordingId, - msg.timestamp, - msg.sender, - msg.message - ); - } - }); + const result = await db + .prepare(`SELECT * FROM video_files WHERE recording_id = ?`) + .bind(recordingId) + .all(); + return result.results ?? []; +} - insertMany(messages); +// --- Chat message functions --- + +export async function getChatMessagesByRecordingId(recordingId: string): Promise { + const db = getDb(); + const result = await db + .prepare(`SELECT * FROM chat_messages WHERE recording_id = ? ORDER BY timestamp`) + .bind(recordingId) + .all(); + return result.results ?? []; } -export function getSummaryByRecordingId(recordingId: string): SummaryRow | undefined { +// --- Summary functions --- + +export async function getSummaryByRecordingId(recordingId: string): Promise { const db = getDb(); - return db + const result = await db .prepare(`SELECT * FROM summaries WHERE recording_id = ?`) - .get(recordingId) as SummaryRow | undefined; + .bind(recordingId) + .first(); + return result ?? undefined; } -export function getSummariesByRecordingIds(recordingIds: string[]): Record { +export async function getSummariesByRecordingIds(recordingIds: string[]): Promise> { if (recordingIds.length === 0) return {}; + const db = getDb(); const placeholders = recordingIds.map(() => "?").join(","); - const rows = db + const result = await db .prepare(`SELECT * FROM summaries WHERE recording_id IN (${placeholders})`) - .all(...recordingIds) as SummaryRow[]; - const result: Record = {}; + .bind(...recordingIds) + .all(); + + const rows = result.results ?? []; + const map: Record = {}; for (const row of rows) { - result[row.recording_id] = row; + map[row.recording_id] = row; } - return result; + return map; } -export function upsertSummary(summary: { +export async function upsertSummary(summary: { recordingId: string; content: string; model: string; -}): void { +}): Promise { const db = getDb(); const id = `summary-${summary.recordingId}`; - db.prepare( - `INSERT OR REPLACE INTO summaries (id, recording_id, content, model, generated_at) - VALUES (?, ?, ?, ?, ?)` - ).run( - id, - summary.recordingId, - summary.content, - summary.model, - new Date().toISOString() - ); -} - -// Clip functions -export function dbRowToClip(row: ClipRow): Clip { - return { - id: row.id, - recordingId: row.recording_id, - title: row.title, - startTime: row.start_time, - endTime: row.end_time, - createdAt: row.created_at, - }; -} - -export function getAllClips(): ClipRow[] { - const db = getDb(); - return db + await db .prepare( - `SELECT c.* FROM clips c - INNER JOIN recordings r ON c.recording_id = r.id - ORDER BY c.created_at DESC` + `INSERT OR REPLACE INTO summaries (id, recording_id, content, model, generated_at) + VALUES (?, ?, ?, ?, ?)` + ) + .bind( + id, + summary.recordingId, + summary.content, + summary.model, + new Date().toISOString() ) - .all() as ClipRow[]; + .run(); } -export interface ClipWithRecordingRow extends ClipRow { - recording_title: string; -} +// --- Clip functions --- -export function getAllClipsWithRecordingTitle(): ClipWithRecordingRow[] { +export async function getAllClipsWithRecordingTitle(): Promise { const db = getDb(); - return db + const result = await db .prepare( `SELECT c.*, COALESCE(r.custom_title, r.title) as recording_title FROM clips c INNER JOIN recordings r ON c.recording_id = r.id ORDER BY c.created_at DESC` ) - .all() as ClipWithRecordingRow[]; + .all(); + return result.results ?? []; } -export function getClipById(id: string): ClipRow | undefined { +export async function getClipById(id: string): Promise { const db = getDb(); - return db + const result = await db .prepare(`SELECT * FROM clips WHERE id = ?`) - .get(id) as ClipRow | undefined; + .bind(id) + .first(); + return result ?? undefined; } -export function getClipsByRecordingId(recordingId: string): ClipRow[] { +export async function getClipsByRecordingId(recordingId: string): Promise { const db = getDb(); - return db + const result = await db .prepare(`SELECT * FROM clips WHERE recording_id = ? ORDER BY start_time`) - .all(recordingId) as ClipRow[]; + .bind(recordingId) + .all(); + return result.results ?? []; } -function generateClipTitle(recordingId: string, startTime: number, endTime: number): string | null { +async function generateClipTitle(recordingId: string, startTime: number, endTime: number): Promise { const db = getDb(); - const segments = db + const result = await db .prepare( - `SELECT text FROM transcript_segments + `SELECT text FROM segments WHERE recording_id = ? AND start_time < ? AND end_time > ? ORDER BY start_time LIMIT 5` ) - .all(recordingId, endTime, startTime) as { text: string }[]; + .bind(recordingId, endTime, startTime) + .all<{ text: string }>(); + const segments = result.results ?? []; if (segments.length === 0) return null; const combinedText = segments.map((s) => s.text).join(" "); @@ -939,21 +749,24 @@ function generateClipTitle(recordingId: string, startTime: number, endTime: numb return combinedText.slice(0, 57) + "..."; } -export function insertClip(clip: { +export async function insertClip(clip: { id: string; recordingId: string; title?: string; startTime: number; endTime: number; -}): ClipRow { +}): Promise { const db = getDb(); - const title = clip.title || generateClipTitle(clip.recordingId, clip.startTime, clip.endTime); + const title = clip.title || await generateClipTitle(clip.recordingId, clip.startTime, clip.endTime); const createdAt = new Date().toISOString(); - db.prepare( - `INSERT INTO clips (id, recording_id, title, start_time, end_time, created_at) - VALUES (?, ?, ?, ?, ?, ?)` - ).run(clip.id, clip.recordingId, title, clip.startTime, clip.endTime, createdAt); + await db + .prepare( + `INSERT INTO clips (id, recording_id, title, start_time, end_time, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind(clip.id, clip.recordingId, title, clip.startTime, clip.endTime, createdAt) + .run(); return { id: clip.id, @@ -965,9 +778,11 @@ export function insertClip(clip: { }; } -export function deleteClip(id: string): boolean { +export async function deleteClip(id: string): Promise { const db = getDb(); - const result = db.prepare(`DELETE FROM clips WHERE id = ?`).run(id); - return result.changes > 0; + const result = await db + .prepare(`DELETE FROM clips WHERE id = ?`) + .bind(id) + .run(); + return result.meta.changes > 0; } - diff --git a/wrangler.toml b/wrangler.toml index cffa5f6..3c2b82d 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,14 +1,22 @@ name = "worktv" -compatibility_date = "2024-09-23" -compatibility_flags = ["nodejs_compat"] -pages_build_output_dir = ".open-next" +main = ".open-next/worker.js" +compatibility_date = "2026-02-06" +compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"] +workers_dev = false + +[assets] +binding = "ASSETS" +directory = ".open-next/assets" -# D1 Database binding [[d1_databases]] binding = "DB" database_name = "worktv" database_id = "b7ceba2a-cbfa-4609-bb95-545164cfe9fe" +[observability.logs] +enabled = true +invocation_logs = true + # Environment variables (set these in Cloudflare dashboard) # ANTHROPIC_API_KEY # ZOOM_ACCOUNT_ID From 6a406a23824acf88667c8bda2623ff2239e92613 Mon Sep 17 00:00:00 2001 From: Giovanni Carvelli Date: Fri, 6 Feb 2026 15:03:38 -0500 Subject: [PATCH 2/2] Add error handling to GET /api/speakers route Co-Authored-By: Claude Haiku 4.5 --- src/app/api/speakers/route.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/api/speakers/route.ts b/src/app/api/speakers/route.ts index b0917d1..2fe7d14 100644 --- a/src/app/api/speakers/route.ts +++ b/src/app/api/speakers/route.ts @@ -2,6 +2,14 @@ import { NextResponse } from "next/server"; import { getAllUniqueSpeakers } from "@/lib/db"; export async function GET() { - const speakers = await getAllUniqueSpeakers(); - return NextResponse.json(speakers); + try { + const speakers = await getAllUniqueSpeakers(); + return NextResponse.json(speakers); + } catch (error) { + console.error("Failed to fetch speakers:", error); + return NextResponse.json( + { error: "Failed to fetch speakers" }, + { status: 500 } + ); + } }