/** * `dench meetings …` * * A meeting is an ordinary CRM record (`dench crm entries … meeting`), so * this module only adds what the CRM verbs cannot reach: the transcript, * the action-item checklist and the attendance roster, which live in side * tables on the server (`convex/functions/meetings/importer.ts`), plus * `import`, which stitches a CSV/JSONL of calls into all of them at once. * * Every write here goes through the same Convex functions the Meetings tab * uses, so the CLI, `/api/v1/meetings/…` and the UI share one contract. */ import { readFile } from "node:fs/promises"; import { makeFunctionReference } from "convex/server"; import { applyBatchOpsInChunks, assertNoUnknownFlags, type CrmBatchOp, type CrmCliContext, CrmCliError, callMutation, callQuery, countResultStatuses, importSeedKeyForRow, out, parseCsv, parseOnConflictFlag, remapKeys, shift, } from "./crm"; import { getFlag, hasFlag, parseJson } from "./lib/cli-args"; import { applyMeetingFieldDefaults, type ImportedActionItem, type ImportedAttendee, type ImportedSegment, type MeetingImportRow, normalizeActionItems, normalizeAttendees, normalizeTranscript, parseDateMs, parseTranscriptText, splitMeetingImportRow, } from "./lib/meetings-import"; const api = { library: { listMeetings: makeFunctionReference<"query">( "functions/meetings/library:listMeetings", ), }, entries: { get: makeFunctionReference<"query">("functions/crm/entries:get"), }, transcripts: { list: makeFunctionReference<"query">( "functions/meetings/transcripts:listMeetingTranscript", ), }, actionItems: { list: makeFunctionReference<"query">( "functions/meetings/actionItems:listActionItems", ), setDone: makeFunctionReference<"mutation">( "functions/meetings/actionItems:setActionItemDone", ), remove: makeFunctionReference<"mutation">( "functions/meetings/actionItems:deleteActionItem", ), }, participants: { roster: makeFunctionReference<"query">( "functions/meetings/participantLinks:listMeetingParticipantRoster", ), }, sharing: { get: makeFunctionReference<"query">( "functions/meetings/sharing:getMeetingSharing", ), set: makeFunctionReference<"mutation">( "functions/meetings/sharing:setMeetingSharing", ), }, importer: { clearTranscript: makeFunctionReference<"mutation">( "functions/meetings/importer:clearTranscript", ), appendTranscriptSegments: makeFunctionReference<"mutation">( "functions/meetings/importer:appendTranscriptSegments", ), addActionItems: makeFunctionReference<"mutation">( "functions/meetings/importer:addActionItems", ), setParticipants: makeFunctionReference<"mutation">( "functions/meetings/importer:setParticipants", ), }, }; /** Server cap on `appendTranscriptSegments`; keep in sync with importer.ts. */ const SEGMENT_CHUNK = 500; const TRANSCRIPT_PAGE = 500; export async function runMeetingsCommand(opts: { convex: CrmCliContext["convex"]; args: string[]; sessionToken?: string; host?: string; workspaceSlug?: string; }): Promise { const args = [...opts.args]; const jsonOutput = hasFlag(args, "--json"); const ctx: CrmCliContext = { convex: opts.convex, args, jsonOutput, sessionToken: opts.sessionToken, runId: process.env.DENCH_RUN_ID?.trim() || undefined, host: opts.host, workspaceSlug: opts.workspaceSlug, }; const subcommand = args.shift(); if (!subcommand || subcommand === "help" || subcommand === "--help") { meetingsHelp(); return; } switch (subcommand) { case "list": return await runList(ctx); case "get": return await runGet(ctx); case "transcript": return await runTranscript(ctx); case "action-items": case "actions": return await runActionItems(ctx); case "participants": case "attendees": return await runParticipants(ctx); case "share": return await runShare(ctx); case "import": return await runImport(ctx); default: throw new CrmCliError( `Unknown meetings subcommand: ${subcommand}. Run \`dench meetings help\`.`, ); } } // ── list / get ───────────────────────────────────────────────────────────── async function runList(ctx: CrmCliContext): Promise { const limitRaw = getFlag(ctx.args, "--limit"); const sharedOnly = hasFlag(ctx.args, "--shared"); assertNoUnknownFlags(ctx.args, "meetings list"); const numItems = limitRaw ? Number(limitRaw) : 50; if (!Number.isFinite(numItems) || numItems <= 0) { throw new CrmCliError("--limit must be a positive number"); } const result = (await callQuery(ctx, api.library.listMeetings, { paginationOpts: { numItems, cursor: null }, ...(sharedOnly ? { sharedOnly: true } : {}), })) as { page?: unknown[] } | null; out(ctx, result?.page ?? []); } async function runGet(ctx: CrmCliContext): Promise { const entryId = shift(ctx.args, "meeting id"); assertNoUnknownFlags(ctx.args, "meetings get"); const entry = await callQuery(ctx, api.entries.get, { objectName: "meeting", entryId, }); if (!entry) throw new CrmCliError(`Meeting not found: ${entryId}`); out(ctx, entry); } // ── share ────────────────────────────────────────────────────────────────── /** * `dench meetings share [--workspace|--private]` * * With no flag this READS the current state rather than guessing a * direction: "share" is a permission change, and a bare command that * silently published a call would be the wrong default for a verb someone * might run to check what a meeting is. */ async function runShare(ctx: CrmCliContext): Promise { const meetingEntryId = shift(ctx.args, "meeting id"); const toWorkspace = hasFlag(ctx.args, "--workspace"); const toPrivate = hasFlag(ctx.args, "--private"); assertNoUnknownFlags(ctx.args, "meetings share"); if (toWorkspace && toPrivate) { throw new CrmCliError( "Pass either --workspace or --private, not both. With neither, the current sharing is printed.", ); } if (!toWorkspace && !toPrivate) { const current = (await callQuery(ctx, api.sharing.get, { meetingEntryId, })) as { sharedWithWorkspace: boolean; canShare: boolean } | null; if (!current) throw new CrmCliError(`Meeting not found: ${meetingEntryId}`); if (ctx.jsonOutput) { out(ctx, current); return; } console.log( current.sharedWithWorkspace ? "Shared with the workspace — every member can read this meeting." : "Private — the owner plus the members who were on the invite.", ); if (!current.canShare) { console.log( "You cannot change this: only the meeting's owner or a workspace admin can.", ); } return; } const result = (await callMutation(ctx, api.sharing.set, { meetingEntryId, shared: toWorkspace, })) as { sharedWithWorkspace: boolean }; if (ctx.jsonOutput) { out(ctx, result); return; } console.log( result.sharedWithWorkspace ? "Shared with the workspace." : "Made private — the owner plus the members who were on the invite.", ); } // ── transcript ───────────────────────────────────────────────────────────── async function runTranscript(ctx: CrmCliContext): Promise { const verb = shift(ctx.args, "transcript verb (get|set|clear)"); switch (verb) { case "get": return await runTranscriptGet(ctx); case "set": return await runTranscriptSet(ctx); case "clear": return await runTranscriptClear(ctx); default: throw new CrmCliError( `dench meetings transcript … (got "${verb}")`, ); } } async function fetchTranscript( ctx: CrmCliContext, meetingEntryId: string, ): Promise< Array<{ seq: number; speakerName: string | null; startMs: number; endMs: number; text: string; }> > { const rows: Array<{ seq: number; speakerName: string | null; startMs: number; endMs: number; text: string; }> = []; let cursor: string | null = null; for (;;) { const page = (await callQuery(ctx, api.transcripts.list, { meetingEntryId, paginationOpts: { numItems: TRANSCRIPT_PAGE, cursor }, })) as { page: typeof rows; isDone: boolean; continueCursor: string; }; rows.push(...page.page); if (page.isDone || page.page.length === 0) break; cursor = page.continueCursor; } return rows; } function formatOffset(ms: number): string { const total = Math.floor(ms / 1000); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const mm = String(m).padStart(2, "0"); const ss = String(s).padStart(2, "0"); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } async function runTranscriptGet(ctx: CrmCliContext): Promise { const meetingEntryId = shift(ctx.args, "meeting id"); const asText = hasFlag(ctx.args, "--text"); assertNoUnknownFlags(ctx.args, "meetings transcript get"); const rows = await fetchTranscript(ctx, meetingEntryId); if (ctx.jsonOutput && !asText) { out(ctx, rows); return; } const timed = rows.some((row) => row.startMs > 0); for (const row of rows) { const prefix = timed ? `[${formatOffset(row.startMs)}] ` : ""; const speaker = row.speakerName ? `${row.speakerName}: ` : ""; console.log(`${prefix}${speaker}${row.text}`); } if (rows.length === 0) console.log("(no transcript)"); } async function readSegmentsFromFlags( ctx: CrmCliContext, command: string, ): Promise { const jsonlPath = getFlag(ctx.args, "--jsonl"); const jsonPath = getFlag(ctx.args, "--file"); const textPath = getFlag(ctx.args, "--text"); assertNoUnknownFlags(ctx.args, command); const given = [jsonlPath, jsonPath, textPath].filter(Boolean).length; if (given !== 1) { throw new CrmCliError( `${command} needs exactly one of --jsonl , --file (JSON array) or --text `, ); } if (textPath) { return parseTranscriptText(await readFile(textPath, "utf-8")); } if (jsonPath) { const parsed = JSON.parse(await readFile(jsonPath, "utf-8")); const segments = normalizeTranscript(parsed); if (!segments) throw new CrmCliError(`${jsonPath} is not a transcript`); return segments; } const rows: unknown[] = []; for (const line of (await readFile(jsonlPath as string, "utf-8")).split( "\n", )) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; rows.push(JSON.parse(trimmed)); } return normalizeTranscript(rows) ?? []; } async function clearTranscriptFully( ctx: CrmCliContext, meetingEntryId: string, ): Promise { let deleted = 0; for (;;) { const result = (await callMutation(ctx, api.importer.clearTranscript, { meetingEntryId, })) as { deleted: number; done: boolean }; deleted += result.deleted; if (result.done) return deleted; } } async function appendTranscript( ctx: CrmCliContext, meetingEntryId: string, segments: ImportedSegment[], ): Promise<{ inserted: number; linkedPeople: number }> { let inserted = 0; let linkedPeople = 0; for (let index = 0; index < segments.length; index += SEGMENT_CHUNK) { const chunk = segments.slice(index, index + SEGMENT_CHUNK); const result = (await callMutation( ctx, api.importer.appendTranscriptSegments, { meetingEntryId, segments: chunk }, )) as { inserted: number; linkedPeople: number }; inserted += result.inserted; linkedPeople += result.linkedPeople; } return { inserted, linkedPeople }; } async function runTranscriptSet(ctx: CrmCliContext): Promise { const meetingEntryId = shift(ctx.args, "meeting id"); const append = hasFlag(ctx.args, "--append"); const segments = await readSegmentsFromFlags(ctx, "meetings transcript set"); if (segments.length === 0) { throw new CrmCliError("No transcript lines were parsed from the file."); } const deleted = append ? 0 : await clearTranscriptFully(ctx, meetingEntryId); const result = await appendTranscript(ctx, meetingEntryId, segments); out(ctx, { meetingEntryId, parsed: segments.length, replaced: deleted, ...result, timed: segments.some((segment) => (segment.startMs ?? 0) > 0), }); } async function runTranscriptClear(ctx: CrmCliContext): Promise { const meetingEntryId = shift(ctx.args, "meeting id"); assertNoUnknownFlags(ctx.args, "meetings transcript clear"); const deleted = await clearTranscriptFully(ctx, meetingEntryId); out(ctx, { meetingEntryId, deleted }); } // ── action items ─────────────────────────────────────────────────────────── async function runActionItems(ctx: CrmCliContext): Promise { const verb = shift(ctx.args, "action-items verb (list|add|set|done|delete)"); switch (verb) { case "list": { const meetingEntryId = shift(ctx.args, "meeting id"); assertNoUnknownFlags(ctx.args, "meetings action-items list"); out(ctx, await callQuery(ctx, api.actionItems.list, { meetingEntryId })); return; } case "add": { const meetingEntryId = shift(ctx.args, "meeting id"); const text = shift(ctx.args, "action item text"); const ownerName = getFlag(ctx.args, "--owner"); const done = hasFlag(ctx.args, "--done"); assertNoUnknownFlags(ctx.args, "meetings action-items add"); const result = await callMutation(ctx, api.importer.addActionItems, { meetingEntryId, items: [{ text, ...(ownerName ? { ownerName } : {}), done }], }); out(ctx, result); return; } case "set": { const meetingEntryId = shift(ctx.args, "meeting id"); const items = await readItemsFromFlags(ctx, "meetings action-items set"); const replace = !hasFlag(ctx.args, "--append"); assertNoUnknownFlags(ctx.args, "meetings action-items set"); const result = await callMutation(ctx, api.importer.addActionItems, { meetingEntryId, items, replace, }); out(ctx, result); return; } case "done": case "undone": { const itemId = shift(ctx.args, "action item id"); assertNoUnknownFlags(ctx.args, `meetings action-items ${verb}`); await callMutation(ctx, api.actionItems.setDone, { itemId, done: verb === "done", }); out(ctx, { itemId, done: verb === "done" }); return; } case "delete": { const itemId = shift(ctx.args, "action item id"); assertNoUnknownFlags(ctx.args, "meetings action-items delete"); await callMutation(ctx, api.actionItems.remove, { itemId }); out(ctx, { itemId, deleted: true }); return; } default: throw new CrmCliError( `dench meetings action-items … (got "${verb}")`, ); } } async function readItemsFromFlags( ctx: CrmCliContext, command: string, ): Promise { const inline = getFlag(ctx.args, "--items"); const filePath = getFlag(ctx.args, "--file"); if ((inline ? 1 : 0) + (filePath ? 1 : 0) !== 1) { throw new CrmCliError( `${command} needs exactly one of --items '' or --file (JSON, JSONL or markdown checklist)`, ); } const raw = inline ?? (await readFile(filePath as string, "utf-8")); const items = normalizeActionItems( looksLikeJsonl(raw) ? parseJsonl(raw) : raw, ); if (!items || items.length === 0) { throw new CrmCliError("No action items were parsed."); } return items; } // ── participants ─────────────────────────────────────────────────────────── async function runParticipants(ctx: CrmCliContext): Promise { const verb = shift(ctx.args, "participants verb (list|set)"); switch (verb) { case "list": { const meetingEntryId = shift(ctx.args, "meeting id"); assertNoUnknownFlags(ctx.args, "meetings participants list"); const roster = (await callQuery(ctx, api.participants.roster, { meetingEntryId, })) as { participants?: unknown[] } | null; out(ctx, roster?.participants ?? []); return; } case "set": case "add": { const meetingEntryId = shift(ctx.args, "meeting id"); const inline = getFlag(ctx.args, "--people"); const filePath = getFlag(ctx.args, "--file"); const replace = verb === "set" && hasFlag(ctx.args, "--replace"); assertNoUnknownFlags(ctx.args, `meetings participants ${verb}`); if ((inline ? 1 : 0) + (filePath ? 1 : 0) !== 1) { throw new CrmCliError( `dench meetings participants ${verb} --people 'Name ; other@x.com' | --file [--replace]`, ); } const raw = inline ?? (await readFile(filePath as string, "utf-8")); const participants: ImportedAttendee[] | null = normalizeAttendees( looksLikeJsonl(raw) ? parseJsonl(raw) : raw, ); if (!participants || participants.length === 0) { throw new CrmCliError("No participants were parsed."); } const result = await callMutation(ctx, api.importer.setParticipants, { meetingEntryId, participants, replace, }); out(ctx, result); return; } default: throw new CrmCliError( `dench meetings participants … (got "${verb}")`, ); } } function looksLikeJsonl(raw: string): boolean { const lines = raw.split("\n").filter((line) => line.trim()); return ( lines.length > 1 && lines.every((line) => { const t = line.trim(); return t.startsWith("{") && t.endsWith("}"); }) ); } function parseJsonl(raw: string): unknown[] { return raw .split("\n") .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")) .map((line) => JSON.parse(line)); } // ── import ───────────────────────────────────────────────────────────────── type RowOutcome = { row: number; entryId: string | null; status: "created" | "already_existed" | "updated" | "error"; title: string | null; transcriptSegments: number; actionItems: number; attendees: number; linkedPeople: number; warnings: string[]; error?: string; }; /** * `dench meetings import --csv calls.csv | --jsonl calls.jsonl` * * Two phases. First every row's meeting FIELDS go through the same * `batch.apply` path as `dench crm import` — same `--map`, same * `--dedupe-key`, same `--on-conflict` semantics, same seed keys — so the * CRM-IMPORT playbook's verification steps apply unchanged. Then each * created (or, with `--on-conflict update`, updated) meeting receives its * transcript, action items and attendees. A skipped row is left entirely * alone, side tables included. */ async function runImport(ctx: CrmCliContext): Promise { const csvPath = getFlag(ctx.args, "--csv"); const jsonlPath = getFlag(ctx.args, "--jsonl"); const mapJson = getFlag(ctx.args, "--map"); const dedupeColumn = getFlag(ctx.args, "--dedupe-key"); const onConflict = parseOnConflictFlag(getFlag(ctx.args, "--on-conflict")); const allowUnknown = hasFlag(ctx.args, "--allow-unknown"); assertNoUnknownFlags(ctx.args, "meetings import"); if (!csvPath && !jsonlPath) { throw new CrmCliError( "dench meetings import requires --csv or --jsonl ", ); } const colMap = mapJson ? (parseJson(mapJson) as Record) : {}; const sourceRows: Record[] = []; if (csvPath) { sourceRows.push(...parseCsv(await readFile(csvPath, "utf-8"), colMap)); } if (jsonlPath) { for (const parsed of parseJsonl(await readFile(jsonlPath, "utf-8"))) { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { sourceRows.push(remapKeys(parsed as Record, colMap)); } } } const rows: MeetingImportRow[] = sourceRows.map((row) => { const split = splitMeetingImportRow(row); return { ...split, fields: applyMeetingFieldDefaults(split.fields) }; }); const { createHash } = await import("node:crypto"); const sha256 = (input: string) => createHash("sha256").update(input).digest("hex"); // The seed key hashes the meeting fields only: a corrected transcript // must upsert the same meeting, not mint a second one. const ops: CrmBatchOp[] = rows.map((row, index) => ({ type: "entries.create", objectName: "meeting", data: row.fields, seedKey: importSeedKeyForRow({ objectName: "meeting", row: row.fields, rowIndex: index, dedupeColumn, sha256, }), rowIndex: index + 1, })); const { results } = await applyBatchOpsInChunks( ctx, ops, undefined, allowUnknown, onConflict, ); const outcomes: RowOutcome[] = []; for (const [index, row] of rows.entries()) { const result = results[index] as | { entryId?: string; status?: string } | undefined; const entryId = result?.entryId ?? null; const status = (result?.status ?? "created") as RowOutcome["status"]; const title = typeof row.fields.Title === "string" ? row.fields.Title : null; const outcome: RowOutcome = { row: index + 1, entryId, status, title, transcriptSegments: 0, actionItems: 0, attendees: 0, linkedPeople: 0, warnings: [], }; if (parseDateMs(row.fields["Started At"]) === null) { outcome.warnings.push( "no Started At — the meeting will not appear on a day in Meetings", ); } outcomes.push(outcome); if (!entryId || status === "already_existed") continue; const replacing = status === "updated"; try { if (row.transcript && row.transcript.length > 0) { if (replacing) await clearTranscriptFully(ctx, entryId); const appended = await appendTranscript(ctx, entryId, row.transcript); outcome.transcriptSegments = appended.inserted; outcome.linkedPeople += appended.linkedPeople; } if (row.actionItems && row.actionItems.length > 0) { const added = (await callMutation(ctx, api.importer.addActionItems, { meetingEntryId: entryId, items: row.actionItems, replace: replacing, })) as { inserted: number }; outcome.actionItems = added.inserted; } if (row.attendees && row.attendees.length > 0) { const set = (await callMutation(ctx, api.importer.setParticipants, { meetingEntryId: entryId, participants: row.attendees, })) as { upserted: number; linkedPeople: number }; outcome.attendees = set.upserted; outcome.linkedPeople += set.linkedPeople; } } catch (error) { outcome.status = "error"; outcome.error = error instanceof Error ? error.message : String(error); } } const counts = countResultStatuses(results); const errors = outcomes.filter((o) => o.status === "error"); out(ctx, { objectName: "meeting", sourceRowsParsed: rows.length, imported: counts.created, skipped: counts.alreadyExisted, updated: counts.updated, errors: errors.length, onConflict, transcriptSegments: outcomes.reduce((n, o) => n + o.transcriptSegments, 0), actionItems: outcomes.reduce((n, o) => n + o.actionItems, 0), attendees: outcomes.reduce((n, o) => n + o.attendees, 0), linkedPeople: outcomes.reduce((n, o) => n + o.linkedPeople, 0), rows: outcomes, }); } // ── help ─────────────────────────────────────────────────────────────────── function meetingsHelp(): void { console.log(meetingsHelpText()); } export function meetingsHelpText(): string { return `Usage: dench meetings A meeting is an ordinary CRM record on the protected \`meeting\` object — create, update and delete it with \`dench crm entries … meeting\`, query it with \`dench crm query meeting\`, list its fields with \`dench crm fields list meeting\`. These verbs add the parts that live beside the record: dench meetings list [--limit N] [--shared] [--json] dench meetings get [--json] You see a meeting if you own it, if you were on the invite, or if it has been shared with the workspace. \`--shared\` narrows the list to the last of those — the calls anyone on the team can read. Sharing: dench meetings share print who can read it dench meetings share --workspace open it to every member dench meetings share --private back to owner + invitees Only the meeting's owner or a workspace admin can change this. To change the default for NEW recordings, use Settings › Meetings. Transcript (meetingTranscriptSegments): dench meetings transcript get [--json|--text] dench meetings transcript set --text notes.txt | --jsonl segments.jsonl | --file segments.json [--append] --text lines look like "[00:01:05] Speaker: what they said"; the timestamp and the speaker are both optional. JSON rows carry {speaker|speakerName, email?, start|startMs, end|endMs, text} (un-suffixed start/end are seconds). Default REPLACES the transcript. dench meetings transcript clear Action items (meetingActionItems): dench meetings action-items list dench meetings action-items add "Send the deck" [--owner Hank] [--done] dench meetings action-items set --items '- [x] Send deck (Hank)\\n- Book follow-up' | --file items.md [--append] dench meetings action-items done|undone dench meetings action-items delete Attendance (meetingParticipants): dench meetings participants list dench meetings participants set --people 'Hank ; ada@acme.com' [--replace] Known members and existing people are linked by exact email and the meeting's Participants / Companies relations follow. Nobody is created. Import many calls at once: dench meetings import --csv calls.csv [--map '{"Summary":"Overview"}'] [--dedupe-key "Granola ID"] [--on-conflict skip|update|error] [--json] dench meetings import --jsonl calls.jsonl … Each row is meeting fields (Title, Started At, Ended At, Outcome, Overview, Outline, Decisions, Next Actions, Open Questions, Playbook, Platform, Meeting URL, …) plus optional reserved columns: transcript text, JSON array, or JSONL-in-a-cell actionItems lines / markdown checklist / JSON array attendees "Name ; other@x.com" or JSON array Status defaults to Completed. Fields go through the same batch path as \`dench crm import\` (same seed keys, same --on-conflict), then each created/updated meeting gets its transcript, items and attendees. Run it as a signed-in member: meetings are owner-scoped records. Read public/references/CRM-IMPORT.md ("Importing meetings") before a bulk import.`; }