Skip to content

Episode content workflow#663

Merged
kentcdodds merged 26 commits intomainfrom
cursor/episode-content-workflow-d1ca
Feb 23, 2026
Merged

Episode content workflow#663
kentcdodds merged 26 commits intomainfrom
cursor/episode-content-workflow-d1ca

Conversation

@kentcdodds
Copy link
Owner

@kentcdodds kentcdodds commented Feb 22, 2026

Implement a draft-first Call Kent episode publishing workflow with AI-generated metadata and streamlined caller history.


Open in Web Open in Cursor 


Note

High Risk
Touches call recording/publishing and storage paths, adds background draft processing plus new admin-only endpoints, and changes persistence/cleanup of audio blobs—bugs could break episode creation or leak/orphan stored audio.

Overview
Implements a draft-first “Call Kent” episode workflow: admins now generate an episode draft from a recorded response, poll draft status, preview the stitched episode audio, edit AI-generated title/description/keywords/transcript, undo/re-record, and publish the draft (with better error surfacing and redirect handling).

Migrates call audio from DB base64 to R2/disk-backed blob storage with new range-streaming endpoints (/resources/calls/call-audio, draft-episode-audio), and adds cleanup of stored audio objects on call deletion/publish.

Replaces caller-facing description/keywords with a single optional notes field across record/admin flows, and adds a /me section listing episodes where the user was the caller (persisted via a new caller-episode record on publish).

Written by Cursor Bugbot for commit 931eae9. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • New Features

    • Replaced Description/Keywords with a single Notes field across recording and admin flows.
    • Episode drafting: create drafts, view live draft status (processing/error/ready), edit metadata, undo, and publish from admin screens.
    • Caller episodes now appear on user profile pages.
    • AI-assisted episode metadata generation for title/description/keywords.
  • Refactor

    • Improved audio stitching to include intro/interstitial/outro assets and streamlined draft-to-publish workflow.

@cursor
Copy link

cursor bot commented Feb 22, 2026

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@coderabbitai
Copy link

coderabbitai bot commented Feb 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an episode-draft workflow, shifts call text fields from description/keywords to notes, introduces DB models for drafts and caller episodes, background draft processing (audio → transcript → Cloudflare Workers AI metadata), a Cloudflare AI metadata generator and mocks/tests, FFmpeg/transistor enhancements, UI/editor flows, types/env updates, and many related API/type changes.

Changes

Cohort / File(s) Summary
Env & Config
\.env.example, app/utils/env.server.ts
Add optional Cloudflare AI env vars CLOUDFLARE_AI_TEXT_MODEL and CLOUDFLARE_AI_CALL_KENT_METADATA_MODEL; global ProcessEnv typings extended.
Database / Prisma
prisma/schema.prisma, prisma/migrations/..._call-kent-draft-episodes/migration.sql, prisma/seed.ts
Add CallKentEpisodeDraft and CallKentCallerEpisode models/enums; migrate Call to use optional notes, add indexes, update seed to use notes.
Recording API & Actions
app/routes/resources/calls/save.tsx
Rename fields to notes; remove publish-call intent, add draft intents (create-episode-draft, undo-episode-draft, update-episode-draft, publish-episode-draft); change exported types (RecordingFormData, RecordingSubmitIntent), export getNavigationPathFromResponse, and implement draft lifecycle & publish flows.
Admin UI & Draft Flow
app/routes/calls_.admin/$callId.tsx, app/routes/calls_.admin/_layout.tsx
Add client-side episode-draft creation/editor/pending UI, polling/revalidation while processing, expose episodeDraft and notes from loaders, and surface draft status in lists.
Recording UI, Forms & Tests
app/components/form-elements.tsx, app/components/calls/__tests__/submit-recording-form.test.tsx, app/routes/calls_.record/new.tsx, app/routes/calls_.record/$callId.tsx
Field component now accepts required?: boolean (default true); forms, prefill, UI and tests use notes instead of description/keywords; removed AI disclosure prefix from record prefill; tests/UX updated accordingly.
Draft Processing Engine
app/utils/call-kent-episode-draft.server.ts
New exported startCallKentEpisodeDraftProcessing(draftId, { responseBase64 }) to generate audio, transcribe via Workers AI, generate metadata, update draft steps/status, and persist errors.
Cloudflare AI Metadata & Mocks
app/utils/cloudflare-ai-call-kent-metadata.server.ts, mocks/cloudflare.ts, mocks/__tests__/cloudflare.test.ts
Add generateCallKentEpisodeMetadataWithWorkersAi and CallKentEpisodeMetadata type; implement Workers AI run/chat response parsing and validation; extend mocks to simulate AI chat responses and add test asserting JSON metadata shape.
Call-Kent Helpers & Validation
app/utils/call-kent.ts
Replace description/keywords validators with notes constraint (maxLength 5000) and add getErrorForNotes.
FFmpeg & Transistor
app/utils/ffmpeg.server.ts, app/utils/transistor.server.ts
FFmpeg: add optional asset-aware full-stitch pipeline (intro/interstitial/outro) and fallback two-input graph. Transistor: createEpisode accepts optional transcriptText, conditional transcription/upload logic, returns richer episode metadata including transistorEpisodeId, episode numbering/season handling updated.
User Profile & Helpers
app/routes/me/_layout.tsx, app/utils/prisma.server.ts
Loaders and helpers fetch/expose callKentCallerEpisodes display objects for the You screen and include them in getAllUserData.
Types
types/index.d.ts
Add transistorEpisodeId: string to CallKentEpisode type.
Tests / Mocks
mocks/__tests__/cloudflare.test.ts, mocks/cloudflare.ts
Add Workers AI chat mock response and test ensuring generated JSON has title/description/keywords.
Misc small updates
app/components/calls/__tests__/submit-recording-form.test.tsx, e2e/calls.spec.ts
Tests updated to use notes field, change intent usage to create-call, adjust e2e flow to draft → edit → publish sequence and extend timeouts where needed.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Client as Browser UI
    participant Server as App Server
    participant DB as Database
    participant CloudflareAI as Cloudflare Workers AI
    participant Transistor as Transistor API

    User->>Client: Record & submit audio (notes)
    Client->>Server: POST create-episode-draft (responseBase64, notes)
    Server->>DB: INSERT CallKentEpisodeDraft (status=PROCESSING)
    DB-->>Server: draftId
    Server-->>Client: { draftId }

    loop Polling
      Client->>Server: GET draft status
      Server->>DB: SELECT draft (status, step)
      DB-->>Server: draft state
      Server-->>Client: { status, step }
    end

    par Background processing
      Server->>Server: generate/encode episode audio (if needed)
      Server->>CloudflareAI: request transcription (audio -> transcript)
      CloudflareAI-->>Server: transcript
      Server->>CloudflareAI: request metadata (transcript + notes)
      CloudflareAI-->>Server: metadata (title, description, keywords)
      Server->>DB: UPDATE draft (transcript, metadata, status=READY)
    end

    Client->>Server: POST update-episode-draft (edited metadata)
    Server->>DB: UPDATE draft fields
    DB-->>Server: updated draft
    Client->>Server: POST publish-episode-draft
    Server->>Transistor: createEpisode(transcript, metadata, transcriptText?)
    Transistor-->>Server: episodeUrl, imageUrl, transistorEpisodeId
    Server->>DB: INSERT CallKentCallerEpisode
    Server->>DB: DELETE CallKentEpisodeDraft
    Server-->>Client: redirect to published episode
Loading

Estimated Code Review Effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly Related PRs

  • Cloudflare API mocks #603: updates Cloudflare Workers AI mocking and tests—overlaps mocks/cloudflare.ts and Cloudflare AI behaviors used by the metadata generator.
  • Podcast guest anonymity #656: modifies app/utils/transistor.server.ts and createEpisode behavior/return shape—related to the transistor changes here.
  • Auth password support #654: touches the Field component public props—related to required?: boolean change in app/components/form-elements.tsx.

Poem

🐰 I found a draft beneath the sod,
I stitched the sound and asked the cloud.
The AI named it, notes in tow,
I hopped, I saved, the episode glowed.
Hop—publish! 🎧✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Episode content workflow' directly describes the main objective of the PR, which implements a draft-first workflow for Call Kent episode content with AI-powered processing and admin editing capabilities.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cursor/episode-content-workflow-d1ca

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kentcdodds kentcdodds marked this pull request as ready for review February 22, 2026 18:28
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (15)
mocks/cloudflare.ts (1)

964-977: hasPrompt has type false | number, not boolean — nitpick.

typeof promptRaw === 'string' && promptRaw.trim().length short-circuits to false or returns the length number. Functionally correct (0 is falsy), but naming implies boolean.

🔧 Proposed fix
-			const hasPrompt = typeof promptRaw === 'string' && promptRaw.trim().length
+			const hasPrompt = typeof promptRaw === 'string' && promptRaw.trim().length > 0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mocks/cloudflare.ts` around lines 964 - 977, The variable hasPrompt currently
evaluates to either false or a number because it uses "typeof promptRaw ===
'string' && promptRaw.trim().length"; change it to a boolean expression so the
name matches its type — locate promptRaw/hasPrompt in the mocks/cloudflare.ts
snippet and replace the right-hand side with an explicit boolean check (e.g.,
promptRaw.trim().length > 0 or Boolean(promptRaw.trim().length)) so hasPrompt is
strictly a boolean; keep the surrounding logic that checks hasMessages ||
hasPrompt and return the same jsonOk payload unchanged.
app/utils/call-kent-episode-draft.server.ts (1)

24-33: id and user are selected from call but never used.

draft.call.id and draft.call.user are not referenced anywhere in the function body; only base64, title, and notes are consumed.

🔧 Proposed fix
 			call: {
 				select: {
-					id: true,
 					title: true,
 					notes: true,
 					base64: true,
-					user: { select: { id: true } },
 				},
 			},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/call-kent-episode-draft.server.ts` around lines 24 - 33, The
include for call is selecting call.id and call.user but those properties
(draft.call.id and draft.call.user) are never used in the function; remove id
and user from the select inside the include (leave base64, title, notes) to
avoid fetching unused data, or if you intended to use them, reference
draft.call.id and draft.call.user where needed—update the include in
app/utils/call-kent-episode-draft.server.ts (the block that constructs the
include for call) accordingly.
app/utils/cloudflare-ai-call-kent-metadata.server.ts (2)

7-26: getCloudflareApiBaseUrl() and getCloudflareWorkersAiAuth() are duplicated from cloudflare-ai-transcription.server.ts.

The transcription utility already defines getCloudflareApiBaseUrl() and uses getRequiredEnv() for auth. Extracting shared helpers to a common module (e.g., cloudflare-ai.server.ts) would eliminate the duplication and the divergence in auth strategies.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/cloudflare-ai-call-kent-metadata.server.ts` around lines 7 - 26,
Extract the duplicated helpers getCloudflareApiBaseUrl and
getCloudflareWorkersAiAuth into a shared module (e.g., cloudflare-ai.server.ts),
export getCloudflareApiBaseUrl and a unified auth helper that uses the existing
getRequiredEnv strategy used by cloudflare-ai-transcription.server.ts, and
update both cloudflare-ai-call-kent-metadata.server.ts and
cloudflare-ai-transcription.server.ts to import these shared functions instead
of defining them locally; ensure the shared auth helper preserves the local
MOCKS=true behavior (return mock values and a MOCK_ token prefix) and otherwise
uses getRequiredEnv to fetch CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN.

28-36: isCloudflareCallKentMetadataConfigured() returns false when the generator would actually succeed.

generateCallKentEpisodeMetadataWithWorkersAi falls back to a hardcoded '@cf/meta/llama-3.1-8b-instruct' default when neither model env var is set, but isConfigured() requires at least one of them to be truthy. Any route handler that gates "Generate AI metadata" UI on isConfigured() will wrongly hide the feature whenever both vars are absent — even with MOCKS=true.

🔧 Proposed fix
 export function isCloudflareCallKentMetadataConfigured() {
 	const { accountId, apiToken } = getCloudflareWorkersAiAuth()
-	return Boolean(
-		accountId &&
-			apiToken &&
-			(process.env.CLOUDFLARE_AI_CALL_KENT_METADATA_MODEL ||
-				process.env.CLOUDFLARE_AI_TEXT_MODEL),
-	)
+	// A model always resolves (hardcoded default in the generator), so only
+	// account credentials gate availability.
+	return Boolean(accountId && apiToken)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/cloudflare-ai-call-kent-metadata.server.ts` around lines 28 - 36,
The current isCloudflareCallKentMetadataConfigured() incorrectly requires at
least one of CLOUDFLARE_AI_CALL_KENT_METADATA_MODEL or CLOUDFLARE_AI_TEXT_MODEL
to be set, but generateCallKentEpisodeMetadataWithWorkersAi can succeed using
its hardcoded default or with mocks; update the check in
isCloudflareCallKentMetadataConfigured() (which uses
getCloudflareWorkersAiAuth()) to return true if credentials are present OR if
process.env.MOCKS === 'true' (and don’t require the model env vars), so the UI
gating reflects actual ability to run
generateCallKentEpisodeMetadataWithWorkersAi.
app/routes/me/_layout.tsx (1)

107-116: callTitle and callNotes are selected but never consumed.

Neither entry.callTitle nor entry.callNotes is referenced in the callKentCallerEpisodesDisplay mapping (lines 236–266), so these fields are fetched for nothing.

🔧 Proposed fix
 			select: {
 				id: true,
 				transistorEpisodeId: true,
 				isAnonymous: true,
-				callTitle: true,
-				callNotes: true,
 				createdAt: true,
 			},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/me/_layout.tsx` around lines 107 - 116, The query is selecting
callTitle and callNotes but those fields are never used in the rendering
function callKentCallerEpisodesDisplay; remove callTitle and callNotes from the
select block (or alternatively use them inside callKentCallerEpisodesDisplay
where entry is mapped) so we don't fetch unused data—update the select object
that contains id, transistorEpisodeId, isAnonymous, callTitle, callNotes,
createdAt to omit callTitle and callNotes (or add references to
entry.callTitle/entry.callNotes inside callKentCallerEpisodesDisplay) to resolve
the unused-field issue.
app/routes/calls_.record/new.tsx (1)

148-161: Prefill correctly uses notes in place of description.

Removal of the AI disclosure prefix from the prefill data is consistent with the broader notes-based workflow.

One small simplification: cleanedQuestion ? cleanedQuestion : '' is always a string (from .trim()), so you can just use cleanedQuestion.

Optional simplification
-									notes: cleanedQuestion ? cleanedQuestion : '',
+									notes: cleanedQuestion,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/calls_.record/new.tsx` around lines 148 - 161, The onAcceptAudio
handler in CallKentTextToSpeech sets prefill using a redundant conditional for
notes; replace the ternary expression setPrefill({... notes: cleanedQuestion ?
cleanedQuestion : '' ...}) with notes: cleanedQuestion (since cleanedQuestion is
already a string after .trim()), keeping the rest of the handler (setAudio,
setPrefill, scrollToRouteTop) unchanged to preserve the notes-based workflow and
removal of the AI disclosure prefix.
prisma/schema.prisma (2)

123-128: Redundant @@index([callId]) — the @unique constraint already creates an index.

Line 124 declares callId String @unique``, which implicitly creates a unique index on callId. The explicit `@@index([callId])` on line 127 is therefore redundant and can be removed.

♻️ Suggested fix
   @@index([status, updatedAt])
-  @@index([callId])
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/schema.prisma` around lines 123 - 128, Remove the redundant explicit
index on callId: the field declaration callId String `@unique` already creates a
unique index, so delete the @@index([callId]) line; keep the existing relation
(call Call `@relation`(...)) and other indexes such as @@index([status,
updatedAt]) untouched.

113-115: Storing full episode audio as base64 in the database can bloat the DB significantly.

A stitched MP3 episode encoded as base64 can easily be 10–40 MB per draft row. While this works for SQLite and the data is temporary (deleted after publish), it will increase DB size, backup times, and WAL pressure. Consider storing the audio in a file/blob store (e.g., local disk or S3) with a path reference in the DB instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/schema.prisma` around lines 113 - 115, The schema currently stores
large base64 MP3 blobs in episodeBase64 (prisma/schema.prisma), which will bloat
the DB; instead create a small string column (e.g., episodePath or episodeUrl)
to store a filesystem/S3 object key and move actual MP3 data to a blob store
(local disk, S3, or similar) via your upload path logic (store/upload when
generating the stitched mp3 and delete/replace on publish/delete). Update any
code that reads/writes episodeBase64 to use the new storage service and the
episodePath/episodeUrl field (and implement migration to copy existing base64
blobs out to the chosen storage and populate the new path field, then drop
episodeBase64).
app/routes/resources/calls/save.tsx (4)

776-800: Consider adding a catch-all type for all supported intents.

The RecordingIntent type (line 47) only covers 'create-call' | 'delete-call', but the action handler also dispatches 'create-episode-draft', 'undo-episode-draft', 'update-episode-draft', and 'publish-episode-draft'. While RecordingIntent is scoped to the public form, having a union type for all action intents would improve type safety and documentation:

type ActionIntent =
  | 'create-call'
  | 'delete-call'
  | 'create-episode-draft'
  | 'undo-episode-draft'
  | 'update-episode-draft'
  | 'publish-episode-draft'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 776 - 800, Update the
intent typing to cover every branch handled by the action handler: extend or add
a union type (e.g., ActionIntent) that includes 'create-call' | 'delete-call' |
'create-episode-draft' | 'undo-episode-draft' | 'update-episode-draft' |
'publish-episode-draft', then use that type for the value returned by
getStringFormValue and/or for the intent variable in export async function
action so TypeScript can validate all handled intents in createCall,
createEpisodeDraft, undoEpisodeDraft, updateEpisodeDraft, and publishCall
branches (replace or augment the existing RecordingIntent accordingly).

621-636: Non-atomic publish: episode is live on Transistor before local records are finalized.

After createEpisode succeeds (line 587), if callKentCallerEpisode.create (line 623) or call.delete (line 633) fails, the episode is already published on Transistor but the call record persists. A subsequent retry would attempt to re-publish a duplicate episode. Consider:

  1. Creating the CallKentCallerEpisode record before the Transistor publish (with a provisional state), or
  2. Wrapping lines 623-635 in a Prisma transaction to ensure atomicity of the local DB operations at least.

This is an admin-only flow so the blast radius is small, but worth hardening.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 621 - 636, The publish flow
is non-atomic: after createEpisode returns, creating the CallKentCallerEpisode
record (prisma.callKentCallerEpisode.create) or deleting the original call
(prisma.call.delete) can fail leaving a live Transistor episode with no matching
completed local state; fix by making the local DB changes atomic — either create
a provisional callKentCallerEpisode record before calling createEpisode (marking
it as pending and update it after success), or wrap the post-publish DB
operations (prisma.callKentCallerEpisode.create and prisma.call.delete) in a
Prisma transaction (prisma.$transaction) so both succeed or both roll back;
update the code paths that reference createEpisode,
prisma.callKentCallerEpisode.create, and prisma.call.delete accordingly.

558-568: Draft update before publish may null out fields unintentionally.

When shouldUpdateFromForm is true, the update on lines 559-567 sets any field to null if the trimmed form value is empty. This is intentional for a "save" operation, but during publish, if a single field happens to be submitted as empty (e.g., due to a browser autofill glitch), the corresponding draft field is permanently nulled — even though line 576 will reject the publish. The admin would then need to re-enter the value.

Consider wrapping the draft update in the same validation that gates the publish, or only updating non-empty fields:

♻️ Only update non-empty fields during publish
  if (shouldUpdateFromForm) {
    await prisma.callKentEpisodeDraft.update({
      where: { callId },
      data: {
-       title: formTitle?.trim() || null,
-       description: formDescription?.trim() || null,
-       keywords: formKeywords?.trim() || null,
-       transcript: formTranscript?.trim() || null,
+       ...(formTitle?.trim() ? { title: formTitle.trim() } : {}),
+       ...(formDescription?.trim() ? { description: formDescription.trim() } : {}),
+       ...(formKeywords?.trim() ? { keywords: formKeywords.trim() } : {}),
+       ...(formTranscript?.trim() ? { transcript: formTranscript.trim() } : {}),
      },
    })
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 558 - 568, The current
draft update inside the shouldUpdateFromForm branch unconditionally sets title,
description, keywords, and transcript to trimmed values or null, which can
unintentionally wipe existing draft fields during a publish attempt; modify the
update logic in the shouldUpdateFromForm branch (the
prisma.callKentEpisodeDraft.update call) to only include fields that are
non-empty after trimming (e.g., only add title, description, keywords,
transcript keys to the data object when formTitle?.trim() !== "" etc.), or
alternatively run the same validation used to gate publish before performing the
update so empty/invalid form values are not written as null; locate references
to shouldUpdateFromForm, formTitle/formDescription/formKeywords/formTranscript,
and prisma.callKentEpisodeDraft.update to implement the conditional data
population.

649-690: Fire-and-forget draft processing could leave drafts stuck in PROCESSING.

startCallKentEpisodeDraftProcessing is called with void (line 685), so if the server restarts or the promise rejects unexpectedly, the draft remains in PROCESSING with no recovery mechanism. Consider adding a stale-draft cleanup job or a TTL check in the admin UI that detects drafts stuck in PROCESSING beyond a reasonable timeout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 649 - 690, The current
fire-and-forget call to startCallKentEpisodeDraftProcessing(draft.id, ...) can
leave a draft stuck in PROCESSING if the promise rejects or the server restarts;
change the flow so you await or explicitly handle the promise result (wrap
startCallKentEpisodeDraftProcessing in try/catch) and update the
callKentEpisodeDraft record status on failure, and additionally implement a
stale-draft recovery: add a TTL/timestamp column when creating the draft in
callKentEpisodeDraft.create, mark processing start time in
startCallKentEpisodeDraftProcessing, and add a background cleanup job or admin
UI check that finds drafts still in PROCESSING past a timeout and resets or
marks them failed so admins can retry.
app/routes/calls_.admin/$callId.tsx (3)

191-195: Duplicate helper — extract getNavigationPathFromResponse to a shared module.

This function is duplicated verbatim in app/routes/resources/calls/save.tsx (lines 24-28). Consider extracting it to a shared utility (e.g., app/utils/misc.ts or app/utils/navigation.ts).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/calls_.admin/`$callId.tsx around lines 191 - 195, Extract the
duplicated helper getNavigationPathFromResponse into a shared utility module
(e.g., create app/utils/navigation.ts or app/utils/misc.ts) that exports the
function, then replace the local implementations in both
getNavigationPathFromResponse occurrences with an import from that shared
module; ensure the exported signature and behavior remain identical (accepting a
Response and returning the constructed pathname+search+hash or null) and update
any imports in callers (such as the handlers in routes/calls_.admin/$callId.tsx
and routes/resources/calls/save.tsx) to use the shared utility.

208-218: Side effect inside useMemo.

URL.createObjectURL is a side-effectful call. While the cleanup in the useEffect handles revocation correctly, creating object URLs inside useMemo is discouraged because React may discard and re-run memoized computations without running cleanup. Consider using useState with a lazy initializer or useEffect to create the URL:

♻️ Suggested approach
- const audioURL = React.useMemo(() => URL.createObjectURL(audio), [audio])
- const abortControllerRef = React.useRef<AbortController | null>(null)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [error, setError] = React.useState<string | null>(null)
-
- React.useEffect(() => {
-   return () => {
-     URL.revokeObjectURL(audioURL)
-     abortControllerRef.current?.abort()
-   }
- }, [audioURL])
+ const [audioURL, setAudioURL] = React.useState<string | null>(null)
+ const abortControllerRef = React.useRef<AbortController | null>(null)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+
+ React.useEffect(() => {
+   const url = URL.createObjectURL(audio)
+   setAudioURL(url)
+   return () => {
+     URL.revokeObjectURL(url)
+     abortControllerRef.current?.abort()
+   }
+ }, [audio])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/calls_.admin/`$callId.tsx around lines 208 - 218, The audio URL is
being created via URL.createObjectURL inside the React.useMemo for audioURL
which is side-effectful; change this to create the object URL in a
React.useEffect (or useState with a lazy initializer) and store it in audioURL
state, then revoke it in the existing cleanup along with
abortControllerRef.current?.abort(); update references to the audioURL variable
(previously from useMemo) to use the new state value and keep the current
cleanup logic in the React.useEffect that revokes the URL.

312-318: Consider a fallback for unknown step values.

If the CallKentEpisodeDraftStep enum is extended later, an unrecognized step value will render undefined inside the <H6>. A simple fallback would make this resilient:

- }[step]
+ }[step] ?? 'Processing…'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/calls_.admin/`$callId.tsx around lines 312 - 318, The stepLabel
lookup can return undefined for unknown CallKentEpisodeDraftStep values; update
the mapping that builds stepLabel (the const stepLabel = { ... }[step]) to
provide a safe fallback (e.g., 'Unknown step' or '') using a default expression
or nullish coalescing so rendering in the H6 always gets a string; locate the
mapping near the variable step and ensure the code uses something like
(mapping[step] ?? 'Unknown step') so future enum additions or unexpected values
don't render undefined.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/components/form-elements.tsx`:
- Line 76: The Field component's prop default was changed to required = true
which unintentionally enables native browser validation for all existing Fields;
change the default back to required = false in the Field component (undo the
required = true default assignment) so existing usages remain optional, then
audit usages of Field (e.g., places that previously omitted required) and
explicitly add required={true} where a field must be mandatory or
required={false} where it should remain optional.

In `@app/routes/me/_layout.tsx`:
- Around line 238-249: The placeholder object returned when episode is missing
currently sets seasonNumber: 0 and episodeNumber: 0 which causes the UI to
render "Season 0 Episode 0"; update the fallback in the !episode branch to
either remove seasonNumber and episodeNumber from the returned object or replace
them with a status string (e.g., seasonLabel: 'Unavailable' or episodeLabel:
'Unavailable') so the template (which reads seasonNumber/episodeNumber) can
render a human-friendly message instead; modify the object returned in the
!episode block (the object with id, slug, episodeTitle, episodePath, imageUrl,
isAnonymous, createdAt) and adjust the consuming template logic to use the new
field (or check for presence of seasonNumber/episodeNumber) so unavailable
entries no longer display "Season 0 Episode 0".

In `@app/routes/resources/calls/save.tsx`:
- Around line 487-491: The constructed Discord message (built via message and
notesBlock) can exceed Discord's 2000-char limit because notes may be up to 5000
chars; modify the logic that builds notesBlock so you truncate notes to fit the
2000-char limit (e.g., compute remaining chars after the fixed prefix including
userMention, adminUserId mention, title, isAnonymous flag and URL, then slice
notes to that remaining length and append "… (truncated)" if necessary), still
using the same symbols (notes, notesBlock, message, createdCall.id, adminUserId,
userMention, channelId); additionally, stop fire-and-forget sends to
sendMessageFromDiscordBot—await the promise and handle/log any error instead of
swallowing it so failures are visible.

In `@app/utils/transistor.server.ts`:
- Around line 228-236: The season-overflow branch always sets episodeNumber = 1,
which breaks when number > episodesPerSeason (e.g., 52, 53); update the logic
that computes season and episodeNumber (variables/currentSeason, number,
episodesPerSeason, season, episodeNumber) to derive both from number by
calculating how many full seasons to advance (e.g., add
floor((number-1)/episodesPerSeason) to season) and set episodeNumber to the
remainder within the season (e.g., ((number-1) % episodesPerSeason) + 1) so
overflowed episode numbers map correctly to the new season and proper episode
index.
- Around line 249-253: shortEpisodePath is built using the raw number instead of
the adjusted episodeNumber, causing season/episode mismatches when number >
episodesPerSeason; change the call that constructs shortEpisodePath to pass the
computed episodeNumber (the same value used for the first episodePath) and
season instead of number so it uses the corrected episode index returned by your
episode normalization logic (refer to shortEpisodePath, getEpisodePath,
episodeNumber, number, season, and episodesPerSeason to locate and update the
call).

In `@prisma/migrations/20260222190000_call-kent-draft-episodes/migration.sql`:
- Around line 45-65: The INSERT into "new_Call" is building notes using literal
'\n' sequences which SQLite won't convert to newlines; update the expression
that concatenates Title/Description/Keywords (the SELECT that sets "notes" in
the INSERT INTO "new_Call") to replace each '\n' literal with CHAR(10) (or
repeated CHAR(10) for double newlines) when concatenating TRIM("title"),
TRIM("description"), and TRIM("keywords"), preserving the same CASE conditions
and TRIM calls in the SELECT so notes become actual multi-line text.

---

Nitpick comments:
In `@app/routes/calls_.admin/`$callId.tsx:
- Around line 191-195: Extract the duplicated helper
getNavigationPathFromResponse into a shared utility module (e.g., create
app/utils/navigation.ts or app/utils/misc.ts) that exports the function, then
replace the local implementations in both getNavigationPathFromResponse
occurrences with an import from that shared module; ensure the exported
signature and behavior remain identical (accepting a Response and returning the
constructed pathname+search+hash or null) and update any imports in callers
(such as the handlers in routes/calls_.admin/$callId.tsx and
routes/resources/calls/save.tsx) to use the shared utility.
- Around line 208-218: The audio URL is being created via URL.createObjectURL
inside the React.useMemo for audioURL which is side-effectful; change this to
create the object URL in a React.useEffect (or useState with a lazy initializer)
and store it in audioURL state, then revoke it in the existing cleanup along
with abortControllerRef.current?.abort(); update references to the audioURL
variable (previously from useMemo) to use the new state value and keep the
current cleanup logic in the React.useEffect that revokes the URL.
- Around line 312-318: The stepLabel lookup can return undefined for unknown
CallKentEpisodeDraftStep values; update the mapping that builds stepLabel (the
const stepLabel = { ... }[step]) to provide a safe fallback (e.g., 'Unknown
step' or '') using a default expression or nullish coalescing so rendering in
the H6 always gets a string; locate the mapping near the variable step and
ensure the code uses something like (mapping[step] ?? 'Unknown step') so future
enum additions or unexpected values don't render undefined.

In `@app/routes/calls_.record/new.tsx`:
- Around line 148-161: The onAcceptAudio handler in CallKentTextToSpeech sets
prefill using a redundant conditional for notes; replace the ternary expression
setPrefill({... notes: cleanedQuestion ? cleanedQuestion : '' ...}) with notes:
cleanedQuestion (since cleanedQuestion is already a string after .trim()),
keeping the rest of the handler (setAudio, setPrefill, scrollToRouteTop)
unchanged to preserve the notes-based workflow and removal of the AI disclosure
prefix.

In `@app/routes/me/_layout.tsx`:
- Around line 107-116: The query is selecting callTitle and callNotes but those
fields are never used in the rendering function callKentCallerEpisodesDisplay;
remove callTitle and callNotes from the select block (or alternatively use them
inside callKentCallerEpisodesDisplay where entry is mapped) so we don't fetch
unused data—update the select object that contains id, transistorEpisodeId,
isAnonymous, callTitle, callNotes, createdAt to omit callTitle and callNotes (or
add references to entry.callTitle/entry.callNotes inside
callKentCallerEpisodesDisplay) to resolve the unused-field issue.

In `@app/routes/resources/calls/save.tsx`:
- Around line 776-800: Update the intent typing to cover every branch handled by
the action handler: extend or add a union type (e.g., ActionIntent) that
includes 'create-call' | 'delete-call' | 'create-episode-draft' |
'undo-episode-draft' | 'update-episode-draft' | 'publish-episode-draft', then
use that type for the value returned by getStringFormValue and/or for the intent
variable in export async function action so TypeScript can validate all handled
intents in createCall, createEpisodeDraft, undoEpisodeDraft, updateEpisodeDraft,
and publishCall branches (replace or augment the existing RecordingIntent
accordingly).
- Around line 621-636: The publish flow is non-atomic: after createEpisode
returns, creating the CallKentCallerEpisode record
(prisma.callKentCallerEpisode.create) or deleting the original call
(prisma.call.delete) can fail leaving a live Transistor episode with no matching
completed local state; fix by making the local DB changes atomic — either create
a provisional callKentCallerEpisode record before calling createEpisode (marking
it as pending and update it after success), or wrap the post-publish DB
operations (prisma.callKentCallerEpisode.create and prisma.call.delete) in a
Prisma transaction (prisma.$transaction) so both succeed or both roll back;
update the code paths that reference createEpisode,
prisma.callKentCallerEpisode.create, and prisma.call.delete accordingly.
- Around line 558-568: The current draft update inside the shouldUpdateFromForm
branch unconditionally sets title, description, keywords, and transcript to
trimmed values or null, which can unintentionally wipe existing draft fields
during a publish attempt; modify the update logic in the shouldUpdateFromForm
branch (the prisma.callKentEpisodeDraft.update call) to only include fields that
are non-empty after trimming (e.g., only add title, description, keywords,
transcript keys to the data object when formTitle?.trim() !== "" etc.), or
alternatively run the same validation used to gate publish before performing the
update so empty/invalid form values are not written as null; locate references
to shouldUpdateFromForm, formTitle/formDescription/formKeywords/formTranscript,
and prisma.callKentEpisodeDraft.update to implement the conditional data
population.
- Around line 649-690: The current fire-and-forget call to
startCallKentEpisodeDraftProcessing(draft.id, ...) can leave a draft stuck in
PROCESSING if the promise rejects or the server restarts; change the flow so you
await or explicitly handle the promise result (wrap
startCallKentEpisodeDraftProcessing in try/catch) and update the
callKentEpisodeDraft record status on failure, and additionally implement a
stale-draft recovery: add a TTL/timestamp column when creating the draft in
callKentEpisodeDraft.create, mark processing start time in
startCallKentEpisodeDraftProcessing, and add a background cleanup job or admin
UI check that finds drafts still in PROCESSING past a timeout and resets or
marks them failed so admins can retry.

In `@app/utils/call-kent-episode-draft.server.ts`:
- Around line 24-33: The include for call is selecting call.id and call.user but
those properties (draft.call.id and draft.call.user) are never used in the
function; remove id and user from the select inside the include (leave base64,
title, notes) to avoid fetching unused data, or if you intended to use them,
reference draft.call.id and draft.call.user where needed—update the include in
app/utils/call-kent-episode-draft.server.ts (the block that constructs the
include for call) accordingly.

In `@app/utils/cloudflare-ai-call-kent-metadata.server.ts`:
- Around line 7-26: Extract the duplicated helpers getCloudflareApiBaseUrl and
getCloudflareWorkersAiAuth into a shared module (e.g., cloudflare-ai.server.ts),
export getCloudflareApiBaseUrl and a unified auth helper that uses the existing
getRequiredEnv strategy used by cloudflare-ai-transcription.server.ts, and
update both cloudflare-ai-call-kent-metadata.server.ts and
cloudflare-ai-transcription.server.ts to import these shared functions instead
of defining them locally; ensure the shared auth helper preserves the local
MOCKS=true behavior (return mock values and a MOCK_ token prefix) and otherwise
uses getRequiredEnv to fetch CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN.
- Around line 28-36: The current isCloudflareCallKentMetadataConfigured()
incorrectly requires at least one of CLOUDFLARE_AI_CALL_KENT_METADATA_MODEL or
CLOUDFLARE_AI_TEXT_MODEL to be set, but
generateCallKentEpisodeMetadataWithWorkersAi can succeed using its hardcoded
default or with mocks; update the check in
isCloudflareCallKentMetadataConfigured() (which uses
getCloudflareWorkersAiAuth()) to return true if credentials are present OR if
process.env.MOCKS === 'true' (and don’t require the model env vars), so the UI
gating reflects actual ability to run
generateCallKentEpisodeMetadataWithWorkersAi.

In `@mocks/cloudflare.ts`:
- Around line 964-977: The variable hasPrompt currently evaluates to either
false or a number because it uses "typeof promptRaw === 'string' &&
promptRaw.trim().length"; change it to a boolean expression so the name matches
its type — locate promptRaw/hasPrompt in the mocks/cloudflare.ts snippet and
replace the right-hand side with an explicit boolean check (e.g.,
promptRaw.trim().length > 0 or Boolean(promptRaw.trim().length)) so hasPrompt is
strictly a boolean; keep the surrounding logic that checks hasMessages ||
hasPrompt and return the same jsonOk payload unchanged.

In `@prisma/schema.prisma`:
- Around line 123-128: Remove the redundant explicit index on callId: the field
declaration callId String `@unique` already creates a unique index, so delete the
@@index([callId]) line; keep the existing relation (call Call `@relation`(...))
and other indexes such as @@index([status, updatedAt]) untouched.
- Around line 113-115: The schema currently stores large base64 MP3 blobs in
episodeBase64 (prisma/schema.prisma), which will bloat the DB; instead create a
small string column (e.g., episodePath or episodeUrl) to store a filesystem/S3
object key and move actual MP3 data to a blob store (local disk, S3, or similar)
via your upload path logic (store/upload when generating the stitched mp3 and
delete/replace on publish/delete). Update any code that reads/writes
episodeBase64 to use the new storage service and the episodePath/episodeUrl
field (and implement migration to copy existing base64 blobs out to the chosen
storage and populate the new path field, then drop episodeBase64).

Comment on lines +238 to +249
if (!episode) {
return {
id: entry.id,
seasonNumber: 0,
episodeNumber: 0,
slug: '',
episodeTitle: 'Call Kent episode (unavailable)',
episodePath: '/calls',
imageUrl: null,
isAnonymous: entry.isAnonymous,
createdAt: entry.createdAt,
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unavailable episodes display "Season 0 Episode 0" — misleading to users.

When a transistorEpisodeId can't be resolved to a live episode, the placeholder uses seasonNumber: 0 and episodeNumber: 0, which the template at line 661 renders as "Calls — Season 0 Episode 0". A caller seeing this on their profile will be confused.

Consider omitting the season/episode line for unavailable entries, or substituting a status string:

-					{`Calls — Season ${episode.seasonNumber} Episode ${episode.episodeNumber}`}
+					{episode.seasonNumber > 0
+						? `Calls — Season ${episode.seasonNumber} Episode ${episode.episodeNumber}`
+						: 'Episode pending publication'}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/me/_layout.tsx` around lines 238 - 249, The placeholder object
returned when episode is missing currently sets seasonNumber: 0 and
episodeNumber: 0 which causes the UI to render "Season 0 Episode 0"; update the
fallback in the !episode branch to either remove seasonNumber and episodeNumber
from the returned object or replace them with a status string (e.g.,
seasonLabel: 'Unavailable' or episodeLabel: 'Unavailable') so the template
(which reads seasonNumber/episodeNumber) can render a human-friendly message
instead; modify the object returned in the !episode block (the object with id,
slug, episodeTitle, episodePath, imageUrl, isAnonymous, createdAt) and adjust
the consuming template logic to use the new field (or check for presence of
seasonNumber/episodeNumber) so unavailable entries no longer display "Season 0
Episode 0".

Comment on lines 487 to 491
const notesBlock = notes?.trim()
? `\n\nNotes:\n${notes.trim()}`
: ''
const message = `📳 <@!${adminUserId}> ring ring! New call from ${userMention} ${emoji}: "${title}"${isAnonymous ? ' (anonymous)' : ''}${notesBlock}\n\n${domainUrl}/calls/admin/${createdCall.id}`
void sendMessageFromDiscordBot(channelId, message)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Discord message may exceed 2000-character limit with long notes.

notes can be up to 5,000 characters. Combined with the rest of the message, the total could far exceed Discord's 2,000-character message limit. Since the send is fire-and-forget and errors are swallowed (line 492), the admin notification would silently fail. Consider truncating the notes block:

🛡️ Suggested truncation
  const notesBlock = notes?.trim()
-   ? `\n\nNotes:\n${notes.trim()}`
+   ? `\n\nNotes:\n${notes.trim().slice(0, 500)}${notes.trim().length > 500 ? '…' : ''}`
    : ''
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 487 - 491, The constructed
Discord message (built via message and notesBlock) can exceed Discord's
2000-char limit because notes may be up to 5000 chars; modify the logic that
builds notesBlock so you truncate notes to fit the 2000-char limit (e.g.,
compute remaining chars after the fixed prefix including userMention,
adminUserId mention, title, isAnonymous flag and URL, then slice notes to that
remaining length and append "… (truncated)" if necessary), still using the same
symbols (notes, notesBlock, message, createdCall.id, adminUserId, userMention,
channelId); additionally, stop fire-and-forget sends to
sendMessageFromDiscordBot—await the promise and handle/log any error instead of
swallowing it so failures are visible.

Comment on lines +228 to +236
//reset episode to 1 if it exceeds episodesPerSeason (50)
let season = currentSeason
let episodeNumber = 1
if (typeof number === 'number' && typeof season === 'number') {
//reset episode to 1 if it exceeds episodesPerSeason (50)
if (number > episodesPerSeason) {
season += 1
episodeNumber = 1
} else {
episodeNumber = number
}
if (number > episodesPerSeason) {
season += 1
episodeNumber = 1
} else {
episodeNumber = number
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Season-overflow logic always sets episodeNumber to 1, even for the 2nd, 3rd, … overflow episode.

If getCurrentSeason() returns a stale value (e.g., two publishes in quick succession before the PATCH at Line 293 propagates), number could be 52 or 53, but episodeNumber is still hardcoded to 1. The fix should derive episodeNumber from the overflow:

Proposed fix
 	if (number > episodesPerSeason) {
-		season += 1
-		episodeNumber = 1
+		const extraSeasons = Math.ceil(number / episodesPerSeason) - 1
+		season += extraSeasons
+		episodeNumber = number - extraSeasons * episodesPerSeason
 	} else {
 		episodeNumber = number
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/transistor.server.ts` around lines 228 - 236, The season-overflow
branch always sets episodeNumber = 1, which breaks when number >
episodesPerSeason (e.g., 52, 53); update the logic that computes season and
episodeNumber (variables/currentSeason, number, episodesPerSeason, season,
episodeNumber) to derive both from number by calculating how many full seasons
to advance (e.g., add floor((number-1)/episodesPerSeason) to season) and set
episodeNumber to the remainder within the season (e.g., ((number-1) %
episodesPerSeason) + 1) so overflowed episode numbers map correctly to the new
season and proper episode index.

@cursor

This comment has been minimized.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/routes/resources/calls/save.tsx (1)

587-646: ⚠️ Potential issue | 🟠 Major

Non-idempotent Transistor publish can create duplicate episodes on retry.

createEpisode (line 587) publishes to Transistor before the two DB writes that follow. If callKentCallerEpisode.create (line 623) or call.delete (line 633) throws, execution falls into the catch at line 638 and redirects the admin to an error page. The call record still exists and the draft is still READY, so the admin can (and will) retry — invoking createEpisode a second time and creating a duplicate podcast episode.

A defensive guard is to store the Transistor episode ID in the draft (or a dedicated column) before committing the DB writes, and bail out early on retry if an episode ID is already present:

// Before createEpisode:
if (draft.transistorEpisodeId) {
  // Already published; skip re-upload and go straight to cleanup.
}

Alternatively, wrap the three post-publish DB steps (callKentCallerEpisode.create, call.delete) in a single transaction so that partial failure leaves a consistent state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 587 - 646, createEpisode
publishes to Transistor before the subsequent DB writes (createEpisode,
prisma.callKentCallerEpisode.create, prisma.call.delete), so retries can create
duplicate episodes; update the handler to either (A) check
draft.transistorEpisodeId on the call/draft record and if present skip calling
createEpisode (i.e., bail early and proceed to cleanup), or (B) persist the
returned transistorEpisodeId to the draft before doing the other DB writes and
wrap the remaining DB operations (prisma.callKentCallerEpisode.create and
prisma.call.delete and the draft update) in a single prisma transaction so
partial failures don’t leave the system in a retryable-but-duplicative state.
🧹 Nitpick comments (3)
prisma/migrations/20260222190000_call-kent-draft-episodes/migration.sql (1)

9-9: Operational concern: storing base64 audio as TEXT inline in SQLite may cause performance issues at scale.

An index is an additional data structure that improves the speed of data retrieval operations on a database table at the cost of additional writes and storage space to maintain the index data structure. Beyond indexing, SQLite stores TEXT/BLOB columns inline in the B-tree page by default, so large episodeBase64 values will bloat page reads for all queries on CallKentEpisodeDraft — even those that don't project the column — once rows spill to overflow pages.

If drafts are truly short-lived (deleted after publish), this is probably tolerable for the current traffic. If rows can linger or the table grows large, consider either:

  • Moving the audio to an external store (R2/S3) and keeping only a reference URL here, or
  • Using a separate CallKentEpisodeDraftAudio table to isolate the large column.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/migrations/20260222190000_call-kent-draft-episodes/migration.sql` at
line 9, The CallKentEpisodeDraft table’s large episodeBase64 TEXT column will
bloat SQLite B-tree pages; remove or isolate that large payload by either (A)
replacing episodeBase64 with a small reference_url column and storing the actual
audio in external object storage (R2/S3), or (B) creating a new
CallKentEpisodeDraftAudio table that holds the audio (e.g., id, draft_id FK ->
CallKentEpisodeDraft.id, audio BLOB/TEXT, created_at) and delete episodeBase64
from CallKentEpisodeDraft; update any code paths that read/write episodeBase64
to instead write the external URL or use the new CallKentEpisodeDraftAudio
record when producing/consuming draft audio.
app/routes/resources/calls/save.tsx (1)

552-568: Partial form submission can corrupt draft fields in the DB.

shouldUpdateFromForm uses || — so if any form field is non-null, all four fields are sent to Prisma (including null for those absent from the form). The subsequent publish on lines 570–573 falls back to the in-memory draft snapshot, so the current publish succeeds, but if anything fails after the DB update (e.g., Transistor API error at line 587), the DB draft is left with nulled-out fields. The admin then retries into a corrupted draft.

Fix: only include non-null form fields in the data object.

🛡️ Proposed fix
-  const shouldUpdateFromForm =
-    formTitle !== null ||
-    formDescription !== null ||
-    formKeywords !== null ||
-    formTranscript !== null
-
-  if (shouldUpdateFromForm) {
-    await prisma.callKentEpisodeDraft.update({
-      where: { callId },
-      data: {
-        title: formTitle?.trim() || null,
-        description: formDescription?.trim() || null,
-        keywords: formKeywords?.trim() || null,
-        transcript: formTranscript?.trim() || null,
-      },
-    })
-  }
+  const draftPatch: {
+    title?: string | null
+    description?: string | null
+    keywords?: string | null
+    transcript?: string | null
+  } = {}
+  if (formTitle !== null) draftPatch.title = formTitle.trim() || null
+  if (formDescription !== null) draftPatch.description = formDescription.trim() || null
+  if (formKeywords !== null) draftPatch.keywords = formKeywords.trim() || null
+  if (formTranscript !== null) draftPatch.transcript = formTranscript.trim() || null
+
+  if (Object.keys(draftPatch).length > 0) {
+    await prisma.callKentEpisodeDraft.update({ where: { callId }, data: draftPatch })
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 552 - 568, The DB update
currently sends all four fields (title, description, keywords, transcript) with
nulls when any single form field is present, corrupting drafts; change the
prisma.callKentEpisodeDraft.update call so its data object only includes the
keys whose corresponding form values are non-null (e.g., check formTitle !==
null before adding title: formTitle.trim(), same for formDescription,
formKeywords, formTranscript), preserving the existing fallback/trim logic and
leaving untouched fields out of the update so partial submissions don't
overwrite stored draft values; keep the shouldUpdateFromForm check but build a
conditional data map and pass that to prisma.callKentEpisodeDraft.update.
app/utils/cloudflare-ai-call-kent-metadata.server.ts (1)

56-68: Consider using unknown instead of any for unwrapWorkersAiText's parameter.

The result: any parameter silences all type-checking inside the function. Typing it as unknown and narrowing explicitly is equally concise here and prevents future callers from silently passing the wrong shape.

♻️ Proposed refactor
-function unwrapWorkersAiText(result: any): string | null {
+function unwrapWorkersAiText(result: unknown): string | null {
   if (!result) return null
   if (typeof result === 'string') return result
-  if (typeof result.response === 'string') return result.response
-  if (typeof result.output === 'string') return result.output
-  if (typeof result.text === 'string') return result.text
-
-  // OpenAI-ish shape (some models / gateways).
-  const choiceContent = result?.choices?.[0]?.message?.content
+  if (typeof result !== 'object') return null
+  const r = result as Record<string, unknown>
+  if (typeof r['response'] === 'string') return r['response']
+  if (typeof r['output'] === 'string') return r['output']
+  if (typeof r['text'] === 'string') return r['text']
+
+  // OpenAI-ish shape (some models / gateways).
+  const choices = (r['choices'] as Array<unknown> | undefined)?.[0]
+  const choiceContent =
+    choices && typeof choices === 'object'
+      ? ((choices as Record<string, unknown>)['message'] as Record<string, unknown> | undefined)?.['content']
+      : undefined
   if (typeof choiceContent === 'string') return choiceContent
-
   return null
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/cloudflare-ai-call-kent-metadata.server.ts` around lines 56 - 68,
Change the parameter type of unwrapWorkersAiText from any to unknown and update
the function to narrow the unknown before accessing properties: keep the
existing typeof checks for string cases, and guard the OpenAI-ish path by
verifying result is an object (e.g., typeof result === 'object' && result !==
null) before using optional chaining into result.choices?.[0]?.message?.content
so no property access assumes the wrong shape; leave the return behavior
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/routes/calls_.admin/`$callId.tsx:
- Around line 307-313: The stepLabel mapping for the variable stepLabel (using
step) lacks a fallback, so unrecognized or new backend step values render as
undefined; update the code that builds stepLabel (the stepLabel constant) to
provide a sensible default/fallback string (e.g., "Unknown step…" or
"Processing…") when the lookup returns undefined so the <H6> always shows a
status; implement this by checking the mapped value and using the fallback or by
extending the mapping to include a default case.
- Around line 540-546: The code assumes data.call.base64 is a data URL; instead
update the logic around data.call.base64 parsing (used where we compute meta,
b64, mime, bytes and call setResponseAudio) to handle raw base64: if splitting
on ',' yields undefined b64, treat the whole string as the base64 payload and
set mime to a sensible default ('audio/mpeg'); also wrap atob/Uint8Array
creation in a try/catch and only call setResponseAudio with a Blob when decoding
succeeds (otherwise clear/set an error state or leave responseAudio null) so
admins don't get a silent empty Blob.

In `@app/routes/resources/calls/save.tsx`:
- Line 585: The code assumes episodeBase64 is a data URL when creating
episodeAudio (episodeBase64.split(',')[1] used with Buffer.from), which will
throw at runtime for raw base64; update the logic around episodeBase64 to detect
whether it includes a comma/data URL prefix and extract the payload safely
(e.g., if episodeBase64 contains a comma use the part after the comma, otherwise
treat the whole string as base64), validate the resulting payload is a non-empty
string before calling Buffer.from, and handle the invalid case by
returning/throwing a clear error or skipping processing so Buffer.from is never
called with undefined.

In `@app/utils/cloudflare-ai-call-kent-metadata.server.ts`:
- Around line 112-125: The fetch call that posts to the Workers AI endpoint (the
call that creates const res using fetch(url, { method: 'POST', headers: ...,
body: ... })) lacks a timeout/signal and can hang indefinitely; fix it by
creating an AbortController, starting a timeout (e.g., setTimeout) that calls
controller.abort() after a configurable timeout, pass controller.signal into the
fetch options, and ensure you clear the timeout after fetch completes and handle
the AbortError path when reading res so the async job fails fast instead of
hanging; reference the const res fetch invocation, url, apiToken, and the
message payload (system/user) to find where to add the controller and timeout.

In `@prisma/migrations/20260222190000_call-kent-draft-episodes/migration.sql`:
- Around line 73-79: The migration contains a redundant non-unique index
"CallKentEpisodeDraft_callId_idx" on the same column already covered by the
unique index "CallKentEpisodeDraft_callId_key" for model CallKentEpisodeDraft;
remove the duplicate CREATE INDEX statement for
"CallKentEpisodeDraft_callId_idx" from the migration SQL and update the Prisma
model CallKentEpisodeDraft by removing the @@index([callId]) attribute so Prisma
won’t regenerate the redundant index in future migrations (the `@unique` on callId
is sufficient).

---

Outside diff comments:
In `@app/routes/resources/calls/save.tsx`:
- Around line 587-646: createEpisode publishes to Transistor before the
subsequent DB writes (createEpisode, prisma.callKentCallerEpisode.create,
prisma.call.delete), so retries can create duplicate episodes; update the
handler to either (A) check draft.transistorEpisodeId on the call/draft record
and if present skip calling createEpisode (i.e., bail early and proceed to
cleanup), or (B) persist the returned transistorEpisodeId to the draft before
doing the other DB writes and wrap the remaining DB operations
(prisma.callKentCallerEpisode.create and prisma.call.delete and the draft
update) in a single prisma transaction so partial failures don’t leave the
system in a retryable-but-duplicative state.

---

Duplicate comments:
In `@app/routes/resources/calls/save.tsx`:
- Around line 487-491: The assembled Discord message (built using notesBlock and
message) can exceed Discord's 2,000-character limit because notes may be up to
5,000 chars; update the logic that constructs notesBlock to compute remaining
allowed length (2000 minus the static message prefix/suffix length including
domain URL and createdCall.id) and truncate notes.trim() to that remaining
length (append an ellipsis like "… (truncated)" when shortened) before building
message; also stop fire-and-forget behavior for sendMessageFromDiscordBot by
awaiting the promise or attaching a .catch to surface/log errors (use
adminUserId, createdCall.id, notesBlock, message and sendMessageFromDiscordBot
to locate the changes).

---

Nitpick comments:
In `@app/routes/resources/calls/save.tsx`:
- Around line 552-568: The DB update currently sends all four fields (title,
description, keywords, transcript) with nulls when any single form field is
present, corrupting drafts; change the prisma.callKentEpisodeDraft.update call
so its data object only includes the keys whose corresponding form values are
non-null (e.g., check formTitle !== null before adding title: formTitle.trim(),
same for formDescription, formKeywords, formTranscript), preserving the existing
fallback/trim logic and leaving untouched fields out of the update so partial
submissions don't overwrite stored draft values; keep the shouldUpdateFromForm
check but build a conditional data map and pass that to
prisma.callKentEpisodeDraft.update.

In `@app/utils/cloudflare-ai-call-kent-metadata.server.ts`:
- Around line 56-68: Change the parameter type of unwrapWorkersAiText from any
to unknown and update the function to narrow the unknown before accessing
properties: keep the existing typeof checks for string cases, and guard the
OpenAI-ish path by verifying result is an object (e.g., typeof result ===
'object' && result !== null) before using optional chaining into
result.choices?.[0]?.message?.content so no property access assumes the wrong
shape; leave the return behavior unchanged.

In `@prisma/migrations/20260222190000_call-kent-draft-episodes/migration.sql`:
- Line 9: The CallKentEpisodeDraft table’s large episodeBase64 TEXT column will
bloat SQLite B-tree pages; remove or isolate that large payload by either (A)
replacing episodeBase64 with a small reference_url column and storing the actual
audio in external object storage (R2/S3), or (B) creating a new
CallKentEpisodeDraftAudio table that holds the audio (e.g., id, draft_id FK ->
CallKentEpisodeDraft.id, audio BLOB/TEXT, created_at) and delete episodeBase64
from CallKentEpisodeDraft; update any code paths that read/write episodeBase64
to instead write the external URL or use the new CallKentEpisodeDraftAudio
record when producing/consuming draft audio.

Comment on lines +307 to +313
const stepLabel = {
STARTED: 'Starting…',
GENERATING_AUDIO: 'Generating episode audio…',
TRANSCRIBING: 'Transcribing audio…',
GENERATING_METADATA: 'Writing title/description/keywords…',
DONE: 'Finalizing…',
}[step]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

stepLabel has no fallback for unrecognized step values.

If the backend introduces a new step enum value the map doesn't cover, stepLabel is undefined and the <H6> renders empty with no indication to the admin of what's happening.

🔧 Suggested fix
  const stepLabel = {
    STARTED: 'Starting…',
    GENERATING_AUDIO: 'Generating episode audio…',
    TRANSCRIBING: 'Transcribing audio…',
    GENERATING_METADATA: 'Writing title/description/keywords…',
    DONE: 'Finalizing…',
- }[step]
+ }[step] ?? 'Processing…'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const stepLabel = {
STARTED: 'Starting…',
GENERATING_AUDIO: 'Generating episode audio…',
TRANSCRIBING: 'Transcribing audio…',
GENERATING_METADATA: 'Writing title/description/keywords…',
DONE: 'Finalizing…',
}[step]
const stepLabel = {
STARTED: 'Starting…',
GENERATING_AUDIO: 'Generating episode audio…',
TRANSCRIBING: 'Transcribing audio…',
GENERATING_METADATA: 'Writing title/description/keywords…',
DONE: 'Finalizing…',
}[step] ?? 'Processing…'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/calls_.admin/`$callId.tsx around lines 307 - 313, The stepLabel
mapping for the variable stepLabel (using step) lacks a fallback, so
unrecognized or new backend step values render as undefined; update the code
that builds stepLabel (the stepLabel constant) to provide a sensible
default/fallback string (e.g., "Unknown step…" or "Processing…") when the lookup
returns undefined so the <H6> always shows a status; implement this by checking
the mapped value and using the fallback or by extending the mapping to include a
default case.

Comment on lines +73 to +79
CREATE UNIQUE INDEX "CallKentEpisodeDraft_callId_key" ON "CallKentEpisodeDraft"("callId");

-- CreateIndex
CREATE INDEX "CallKentEpisodeDraft_status_updatedAt_idx" ON "CallKentEpisodeDraft"("status", "updatedAt");

-- CreateIndex
CREATE INDEX "CallKentEpisodeDraft_callId_idx" ON "CallKentEpisodeDraft"("callId");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Redundant non-unique index on callIdCallKentEpisodeDraft_callId_idx is superseded by the unique index above it.

A unique index has the same behavior as a regular index, but with the additional guarantee that duplicate values are not allowed. SQLite maintains each index as a separate B-tree, so both CallKentEpisodeDraft_callId_key (UNIQUE) and CallKentEpisodeDraft_callId_idx (non-unique) on the same column means every write to CallKentEpisodeDraft updates two identical B-tree structures, doubling write overhead and storage for that index. While indexes improve read operations, they add overhead to write operations.

This is typically a Prisma artifact when both @unique and @@index([callId]) are declared simultaneously in the Prisma schema. Remove the redundant @@index([callId]) decorator — the @unique alone is sufficient for both uniqueness enforcement and query optimization.

🐛 Proposed fix (migration SQL)
 -- CreateIndex
 CREATE UNIQUE INDEX "CallKentEpisodeDraft_callId_key" ON "CallKentEpisodeDraft"("callId");
 
 -- CreateIndex
 CREATE INDEX "CallKentEpisodeDraft_status_updatedAt_idx" ON "CallKentEpisodeDraft"("status", "updatedAt");
-
--- CreateIndex
-CREATE INDEX "CallKentEpisodeDraft_callId_idx" ON "CallKentEpisodeDraft"("callId");

Also remove the redundant @@index([callId]) from the CallKentEpisodeDraft model in prisma/schema.prisma to prevent Prisma from regenerating this index in future migrations.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CREATE UNIQUE INDEX "CallKentEpisodeDraft_callId_key" ON "CallKentEpisodeDraft"("callId");
-- CreateIndex
CREATE INDEX "CallKentEpisodeDraft_status_updatedAt_idx" ON "CallKentEpisodeDraft"("status", "updatedAt");
-- CreateIndex
CREATE INDEX "CallKentEpisodeDraft_callId_idx" ON "CallKentEpisodeDraft"("callId");
CREATE UNIQUE INDEX "CallKentEpisodeDraft_callId_key" ON "CallKentEpisodeDraft"("callId");
-- CreateIndex
CREATE INDEX "CallKentEpisodeDraft_status_updatedAt_idx" ON "CallKentEpisodeDraft"("status", "updatedAt");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/migrations/20260222190000_call-kent-draft-episodes/migration.sql`
around lines 73 - 79, The migration contains a redundant non-unique index
"CallKentEpisodeDraft_callId_idx" on the same column already covered by the
unique index "CallKentEpisodeDraft_callId_key" for model CallKentEpisodeDraft;
remove the duplicate CREATE INDEX statement for
"CallKentEpisodeDraft_callId_idx" from the migration SQL and update the Prisma
model CallKentEpisodeDraft by removing the @@index([callId]) attribute so Prisma
won’t regenerate the redundant index in future migrations (the `@unique` on callId
is sufficient).

@cursor

This comment has been minimized.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/routes/resources/calls/save.tsx (1)

596-644: ⚠️ Potential issue | 🟠 Major

Non-idempotent publish: retry after a partial failure duplicates the Transistor episode.

The publish sequence is:

  1. createEpisode(…) → external Transistor API call (line 596)
  2. callKentCallerEpisode.create(…) → DB write (line 632)
  3. call.delete(…) → DB write (line 642)

If step 2 or 3 throws, the catch block (line 647) redirects the admin to the error page. The call and episodeDraft records are still intact, so the admin retries — which reruns step 1 and uploads a second episode to Transistor.

The standard guard is to persist the Transistor episode ID on the draft before attempting any subsequent DB operations, then skip createEpisode if that ID is already set:

🛡️ Proposed idempotency guard
+    // If we already published to Transistor (e.g. after a previous partial failure),
+    // reuse the existing episode rather than creating a duplicate.
+    const existingTransistorId = draft.transistorEpisodeId
+    let published: Awaited<ReturnType<typeof createEpisode>>
+    if (existingTransistorId) {
+      // Reconstruct the minimal shape needed below from the stored draft data.
+      published = { transistorEpisodeId: existingTransistorId, episodeUrl: draft.episodeUrl, imageUrl: draft.imageUrl }
+    } else {
       const episodeAudio = Buffer.from(episodeBase64.split(',')[1]!, 'base64')
       ...
-    const published = await createEpisode({ ... })
+      published = await createEpisode({ ... })
+      // Persist transistorEpisodeId immediately so retries are safe.
+      await prisma.callKentEpisodeDraft.update({
+        where: { callId },
+        data: { transistorEpisodeId: published.transistorEpisodeId, episodeUrl: published.episodeUrl, imageUrl: published.imageUrl },
+      })
+    }

This requires adding transistorEpisodeId, episodeUrl, and imageUrl to the CallKentEpisodeDraft schema, but prevents duplicate episodes on retry.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 596 - 644, The publish flow
calls createEpisode(...) which can create duplicate Transistor episodes if later
DB writes fail; to fix, make publish idempotent by persisting the Transistor
identifiers onto the CallKentEpisodeDraft before any other DB writes and by
skipping createEpisode when those fields exist: add transistorEpisodeId (and
optionally episodeUrl and imageUrl) to the CallKentEpisodeDraft schema, update
the code around createEpisode to first check the draft for transistorEpisodeId
and if absent call createEpisode then immediately update the draft with
transistorEpisodeId/episodeUrl/imageUrl in the database, and only after that
perform prisma.callKentCallerEpisode.create(...) and prisma.call.delete(...);
also ensure error handling treats an existing transistorEpisodeId as success so
retries won’t re-upload.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/routes/resources/calls/save.tsx`:
- Around line 619-620: The markdown currently always renders an image tag using
`${published.imageUrl ?? ''}`, which produces a broken-image link when imageUrl
is null; update the template construction (where `title`, `published`, and
`published.episodeUrl` are used) to conditionally render the image markdown only
when `published.imageUrl` is a non-empty truthy string, otherwise output a plain
link `[${title}](${published.episodeUrl})`; modify the expression that builds
the string so it chooses between `![${title}](${published.imageUrl})` wrapped in
the link and the fallback plain link to avoid empty-src images.
- Around line 559-577: The current flow persists empty strings (trimmed to null)
into updateData via formTitle/formDescription/formKeywords/formTranscript and
then calls prisma.callKentEpisodeDraft.update, which can erase AI-generated
draft content before the publish-required-field guard runs; move the
required-field validation that checks title/description/keywords (the guard
after this block) to run before building/persisting updateData or,
alternatively, skip setting properties on updateData when the submitted value is
an empty string so prisma.callKentEpisodeDraft.update is only called with
genuine updates; ensure the check runs before invoking
prisma.callKentEpisodeDraft.update and reference the existing variables
updateData, formTitle, formDescription, formKeywords, formTranscript, and the
prisma.callKentEpisodeDraft.update call when making the change.

---

Outside diff comments:
In `@app/routes/resources/calls/save.tsx`:
- Around line 596-644: The publish flow calls createEpisode(...) which can
create duplicate Transistor episodes if later DB writes fail; to fix, make
publish idempotent by persisting the Transistor identifiers onto the
CallKentEpisodeDraft before any other DB writes and by skipping createEpisode
when those fields exist: add transistorEpisodeId (and optionally episodeUrl and
imageUrl) to the CallKentEpisodeDraft schema, update the code around
createEpisode to first check the draft for transistorEpisodeId and if absent
call createEpisode then immediately update the draft with
transistorEpisodeId/episodeUrl/imageUrl in the database, and only after that
perform prisma.callKentCallerEpisode.create(...) and prisma.call.delete(...);
also ensure error handling treats an existing transistorEpisodeId as success so
retries won’t re-upload.

---

Duplicate comments:
In `@app/routes/resources/calls/save.tsx`:
- Around line 487-491: The constructed Discord message (built using notesBlock
and message referencing adminUserId and createdCall.id) can exceed Discord's
2000-character limit and you also fire-and-forget sendMessageFromDiscordBot; fix
by truncating the full message to Discord's max length (2000 chars) before
sending and include an explicit await on sendMessageFromDiscordBot, wrapping the
await in a try/catch to log or handle send errors; ensure truncation preserves
the important tail (the admin link /calls/admin/${createdCall.id}) and indicate
truncation (e.g., "…(truncated)") if needed.
- Line 594: The line creating episodeAudio uses a non-null assertion on
episodeBase64.split(',')[1] which is unsafe; in the save handler (look for
variables episodeBase64 and episodeAudio) first ensure the base64 string
actually contains the data portion (e.g., if episodeBase64.includes(',') then
use episodeBase64.split(',', 2)[1] else treat episodeBase64 as the raw base64),
validate that the extracted base64Data is defined and matches expected base64
characters (or throw/return an error response), and only then call
Buffer.from(base64Data, 'base64') to create episodeAudio; this removes the risky
split(...)[1]! assertion and adds proper error handling.

@cursor

This comment has been minimized.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/routes/resources/calls/save.tsx (1)

596-644: ⚠️ Potential issue | 🟠 Major

Non-atomic publish flow: a failure after createEpisode will produce a duplicate Transistor episode on retry.

createEpisode (Transistor external call, line 596) is not reversible. The two DB writes that follow — callKentCallerEpisode.create (line 632) and call.delete (line 642) — are not wrapped in a transaction and are not guarded by any idempotency check:

  • If callKentCallerEpisode.create throws (e.g., a DB constraint or a transient error), call.delete is skipped. The admin sees the call still in the list, retries, and createEpisode is called again → duplicate Transistor episode.
  • If call.delete throws after a successful callKentCallerEpisode.create, retry also leads to a second Transistor episode, plus a second callKentCallerEpisode create attempt (which may fail on a unique constraint, masking the real problem).

The two DB steps should be wrapped in a transaction, and the catch block should log the already-published transistorEpisodeId so it can be recovered manually:

🛡️ Proposed fix — atomic DB post-publish cleanup
-  await prisma.callKentCallerEpisode.create({
-    data: {
-      userId: call.userId,
-      callTitle: call.title,
-      callNotes: call.notes,
-      isAnonymous: call.isAnonymous,
-      transistorEpisodeId: published.transistorEpisodeId,
-    },
-  })
-
-  await prisma.call.delete({
-    where: { id: call.id },
-  })
+  await prisma.$transaction([
+    prisma.callKentCallerEpisode.create({
+      data: {
+        userId: call.userId,
+        callTitle: call.title,
+        callNotes: call.notes,
+        isAnonymous: call.isAnonymous,
+        transistorEpisodeId: published.transistorEpisodeId,
+      },
+    }),
+    prisma.call.delete({ where: { id: call.id } }),
+  ])

In the catch block, log published.transistorEpisodeId when it's already been created so an operator can clean up Transistor manually:

  } catch (error: unknown) {
    const { getErrorMessage } = await import('#app/utils/misc.ts')
+   // If createEpisode already ran, log the episode ID for manual cleanup.
+   if (typeof published !== 'undefined' && published.transistorEpisodeId) {
+     console.error('Transistor episode already created but DB cleanup failed. Episode ID:', published.transistorEpisodeId)
+   }
    const callId = getStringFormValue(formData, 'callId')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 596 - 644, The publish flow
is non-atomic: after createEpisode succeeds, the two DB writes
(prisma.callKentCallerEpisode.create and prisma.call.delete) must be executed
inside a single transaction to avoid duplicate Transistor episodes on retry;
wrap those two operations in a prisma.$transaction call (or equivalent
transaction helper) and catch errors around that transaction, logging
published.transistorEpisodeId (and any error) in the catch so operators can
recover the already-created Transistor episode; ensure the transaction code
references the existing symbols prisma.callKentCallerEpisode.create and
prisma.call.delete and that the catch logs published.transistorEpisodeId.
🧹 Nitpick comments (1)
app/routes/resources/calls/save.tsx (1)

683-689: Non-atomic deleteMany + create in createEpisodeDraft — wrap in a Prisma transaction.

A concurrent admin request (however unlikely) between deleteMany and create leaves the call with no draft at all. A $transaction makes this atomic:

♻️ Proposed refactor
-  await prisma.callKentEpisodeDraft.deleteMany({ where: { callId } })
-  const draft = await prisma.callKentEpisodeDraft.create({
-    data: {
-      callId,
-    },
-  })
+  const [, draft] = await prisma.$transaction([
+    prisma.callKentEpisodeDraft.deleteMany({ where: { callId } }),
+    prisma.callKentEpisodeDraft.create({ data: { callId } }),
+  ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 683 - 689, The deleteMany
followed by create is non-atomic and can leave a call without a draft if
concurrent requests interleave; update the createEpisodeDraft logic to run both
operations inside a Prisma transaction (use prisma.$transaction) so
callKentEpisodeDraft.deleteMany({ where: { callId } }) and
prisma.callKentEpisodeDraft.create({ data: { callId } }) execute atomically;
locate the code around the existing deleteMany/create calls in
createEpisodeDraft (or where those symbols appear) and replace with a single
transaction that performs the deletion then the creation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@app/routes/resources/calls/save.tsx`:
- Around line 596-644: The publish flow is non-atomic: after createEpisode
succeeds, the two DB writes (prisma.callKentCallerEpisode.create and
prisma.call.delete) must be executed inside a single transaction to avoid
duplicate Transistor episodes on retry; wrap those two operations in a
prisma.$transaction call (or equivalent transaction helper) and catch errors
around that transaction, logging published.transistorEpisodeId (and any error)
in the catch so operators can recover the already-created Transistor episode;
ensure the transaction code references the existing symbols
prisma.callKentCallerEpisode.create and prisma.call.delete and that the catch
logs published.transistorEpisodeId.

---

Duplicate comments:
In `@app/routes/resources/calls/save.tsx`:
- Line 594: The code assumes episodeBase64 is a data-URL and uses
episodeBase64.split(',')[1]! which can throw if the string isn't in that format;
update the save logic to validate and safely extract the base64 payload before
calling Buffer.from — e.g., check that episodeBase64 contains a comma and that
split(',')[1] is defined (or match /^data:.*;base64,(.*)$/ and grab the
capture), and handle the invalid case by returning an error or throwing a
controlled exception; update the code that assigns episodeAudio so it only calls
Buffer.from when a valid base64 payload is present.
- Around line 487-491: The Discord message built in save.tsx (variables
notesBlock, message, then sendMessageFromDiscordBot) can exceed Discord's
2000-character limit when notes is long; update the code to enforce a max length
by calculating remaining allowed characters for notes (2000 minus
fixedPartsLength including userMention, emoji, title, domainUrl/admin link, and
any added markup) and truncate notes.trim() to that limit (append "..." when
truncated) before building notesBlock so the final message never exceeds 2000
chars; keep building the message with the truncated notes and call
sendMessageFromDiscordBot as before.
- Around line 559-577: The current update block unconditionally sets fields to
null when the form inputs are empty strings, which can clear AI-generated draft
data before the required-field validation runs; change the checks for formTitle,
formDescription, formKeywords, and formTranscript so you only assign to
updateData when the trimmed value is non-empty (i.e., formX !== null &&
formX.trim() !== ''), otherwise skip adding that key to updateData so
prisma.callKentEpisodeDraft.update does not overwrite existing draft content;
keep using updateData and the same prisma.callKentEpisodeDraft.update call.
- Around line 619-620: The email markdown currently always inserts an image tag
using published.imageUrl (the template `[![${title}](${published.imageUrl ??
''})](${published.episodeUrl})`), which renders a broken image when imageUrl is
null; update the template to conditionally include the image markdown only when
published.imageUrl is truthy (e.g., wrap the
`[![...](${published.imageUrl})](${published.episodeUrl})` piece in a
conditional or ternary based on published.imageUrl) so that when imageUrl is
null the image markdown is omitted and only the episode link/title is included.

---

Nitpick comments:
In `@app/routes/resources/calls/save.tsx`:
- Around line 683-689: The deleteMany followed by create is non-atomic and can
leave a call without a draft if concurrent requests interleave; update the
createEpisodeDraft logic to run both operations inside a Prisma transaction (use
prisma.$transaction) so callKentEpisodeDraft.deleteMany({ where: { callId } })
and prisma.callKentEpisodeDraft.create({ data: { callId } }) execute atomically;
locate the code around the existing deleteMany/create calls in
createEpisodeDraft (or where those symbols appear) and replace with a single
transaction that performs the deletion then the creation.

@cursor cursor bot force-pushed the cursor/episode-content-workflow-d1ca branch from b184722 to 35562c8 Compare February 22, 2026 20:33
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (8)
prisma/schema.prisma (2)

126-128: Index on [callId] is redundant with @unique constraint.

The @unique on callId (line 124) already creates a unique index. The explicit @@index([callId]) at line 127 adds a second, non-unique index on the same column. This wastes storage without providing query benefits.

Suggested diff
   @@index([status, updatedAt])
-  @@index([callId])
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/schema.prisma` around lines 126 - 128, Remove the redundant non-unique
index on callId: since the field has an `@unique` constraint (callId) that already
creates a unique index, delete the explicit @@index([callId]) declaration to
avoid duplicate indexing and wasted storage; update the model by removing the
@@index([callId]) line and leave other indexes (e.g., @@index([status,
updatedAt])) intact.

104-128: Consider the operational impact of storing episodeBase64 in SQLite.

The episodeBase64 field will hold full MP3 audio as base64 data URLs (potentially 20–30MB per draft). In SQLite:

  • This bloats the database file and backups
  • Queries that SELECT * or include this column (e.g., the polling loader) transfer the full blob
  • SQLite's single-writer model means large writes during audio persistence block other writes

For production, consider storing the audio in object storage (R2/S3) and keeping only a URL reference in the DB. For MVP, this works — just be aware of the scaling implications.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/schema.prisma` around lines 104 - 128, The CallKentEpisodeDraft model
is storing full MP3 audio in the episodeBase64 String field which will bloat
SQLite and block writes; instead change the schema to store a lightweight
reference (e.g., episodeUrl or episodeStorageKey on CallKentEpisodeDraft) and
move actual audio persistence to object storage (R2/S3), then update the logic
that writes/reads episodeBase64 (the code paths that create/update
CallKentEpisodeDraft and the polling/loader that SELECTs the draft) to
upload/download audio from object storage and save only the URL/key in the new
DB field.
app/utils/ffmpeg.server.ts (2)

35-71: Full stitch path omits -map — works but is fragile.

In the full stitch branch (line 55), the last acrossfade filter has no named output label, so FFmpeg auto-selects it. This works but is implicit. The fallback branch correctly uses -map '[out]'. Consider naming the final output and adding -map for consistency and resilience against future filter_complex edits.

Suggested diff
 						[a02][response]acrossfade=d=1:c2=nofade[a03];
-						[a03][4]acrossfade=d=1:c1=nofade
+						[a03][4]acrossfade=d=1:c1=nofade[out]
 					`,
+					'-map', '[out]',
 					outputPath,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/ffmpeg.server.ts` around lines 35 - 71, The full-stitch branch in
the args ternary (when hasStitchAssets is true) leaves the final acrossfade
unnamed so FFmpeg implicitly picks it; update the filter_complex used in that
branch (the string fed to filter_complex in args) to assign a named output label
(e.g., append [,out] to the last acrossfade) and then add the explicit '-map',
'[out]' entries before outputPath—mirror the fallback branch pattern used for
[out] mapping to make hasStitchAssets branch robust; modify the code building
args (refer to hasStitchAssets, filter_complex, args, and outputPath)
accordingly.

79-79: Replace deprecated fs.promises.rmdir with fs.promises.rm.

rmdir({ recursive: true }) is deprecated (DEP0147) in Node 16+. Use fs.promises.rm with both recursive: true and force: true options instead.

Suggested diff
-	await fs.promises.rmdir(cacheDir, { recursive: true })
+	await fs.promises.rm(cacheDir, { recursive: true, force: true })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/ffmpeg.server.ts` at line 79, The code currently calls await
fs.promises.rmdir(cacheDir, { recursive: true }) which uses the deprecated rmdir
API; replace that call with await fs.promises.rm(cacheDir, { recursive: true,
force: true }) so removal is non-deprecated and resilient; locate the invocation
of fs.promises.rmdir that references cacheDir and update the options to include
both recursive: true and force: true.
app/utils/call-kent-episode-draft.server.ts (2)

47-72: Large base64 audio stored in SQLite — consider size implications.

episodeBase64 stores the full stitched MP3 as a base64 data URL. A typical 20-minute episode at 128kbps ≈ ~19MB raw → ~25MB base64, stored in a single SQLite text column. This works but may cause:

  • Slow reads when loading the draft (even for status polling)
  • DB bloat over time

Consider excluding episodeBase64 from the polling query in the loader (only fetch it when actually rendering the audio preview), or storing it on disk / object storage instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/call-kent-episode-draft.server.ts` around lines 47 - 72, The draft
currently writes the full stitched MP3 as a base64 data URL into the
episodeBase64 column (see createEpisodeAudio, bufferToMp3DataUrl and the
prisma.callKentEpisodeDraft.updateMany calls), which will bloat SQLite and slow
polling; change the flow to avoid persisting large base64 blobs: either (A)
persist the episode MP3 to object storage or disk (implement a helper like
persistEpisodeAudio that returns an episodeUrl/filePath) and store only that
reference in the callKentEpisodeDraft record instead of episodeBase64, or (B)
keep storing episodeBase64 but stop returning it in lightweight polling/loader
queries (exclude episodeBase64 from the select used for status polling and only
fetch it when rendering the audio preview). Ensure updateMany writes the
reference field (episodeUrl/filePath) or clears episodeBase64 accordingly and
update any callers that expect episodeBase64 to use the new reference-based
retrieval.

74-95: Transcript step uses stale isCloudflareTranscriptionConfigured() check after async work — correct but subtle.

The configuration check at line 83 happens after potentially waiting for audio generation. If configuration changes between starting processing and reaching this step, it would correctly fail. This is fine — just noting the implicit assumption that configuration is stable during processing.

One minor concern: if transcribeMp3WithWorkersAi returns an empty string, it would be stored as the transcript, and step 3 would proceed with an empty transcript for metadata generation. Consider guarding against empty transcripts.

Suggested diff
 			transcript = await transcribeMp3WithWorkersAi({ mp3: episodeMp3 })
+			if (!transcript?.trim()) {
+				throw new Error('Transcription returned empty result')
+			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/utils/call-kent-episode-draft.server.ts` around lines 74 - 95, The
transcript step currently allows an empty string from transcribeMp3WithWorkersAi
to be stored and advance processing; add a guard after calling
transcribeMp3WithWorkersAi to treat empty/whitespace transcripts as errors (or
retry) before updating the draft. Specifically, after
transcribeMp3WithWorkersAi(...) and before the
prisma.callKentEpisodeDraft.updateMany that sets transcript and
step='GENERATING_METADATA', validate that transcript is a non-empty string
(e.g., trim length > 0); if invalid, set an errorMessage and/or set step back to
a safe state and return/throw so you don't persist an empty transcript. Keep the
existing isCloudflareTranscriptionConfigured() check as-is but ensure the new
empty-transcript validation prevents advancing with blank data.
app/routes/calls_.admin/$callId.tsx (1)

335-470: DraftEditor form fields lack aria-label or aria-describedby for accessibility.

The form inputs use <label htmlFor> which is good, but the required fields (title, description, keywords, transcript) have no client-side validation feedback or error states. Consider whether the required HTML attribute alone is sufficient given noValidate is not set on this form (it will rely on native browser validation).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/calls_.admin/`$callId.tsx around lines 335 - 470, The DraftEditor
component's form fields (ids: draft-title, draft-description, draft-keywords,
draft-transcript) lack ARIA references and visible validation feedback; update
DraftEditor to (1) render per-field error message elements (e.g., <span
id="draft-title-error">) for each required input, (2) add aria-describedby on
each input pointing to its error id so screen readers know about errors, (3)
mark error elements with role="alert" and toggle their content/visibility based
on client-side validation state, and (4) wire simple validation logic in
DraftEditor to set/clear these error messages before submit (or set noValidate
on the <Form> and show custom validation) so native required-only behavior is
not the sole feedback mechanism.
app/routes/resources/calls/save.tsx (1)

808-823: updateEpisodeDraft silently ignores attempts to clear a field to an empty string.

Because if (nextTitle), if (nextDescription), etc., are falsy checks, an admin who intentionally blanks a field (e.g., to delete a bad AI-generated keyword list) will see their change silently discarded and the old value retained. Whether this is intentional or a UX gap is worth confirming; if clearing should be supported, the guard should check !== undefined rather than truthiness.

♻️ Proposed approach for explicit-clear support
-  if (nextTitle) updateData.title = nextTitle
-  if (nextDescription) updateData.description = nextDescription
-  if (nextKeywords) updateData.keywords = nextKeywords
-  if (nextTranscript) updateData.transcript = nextTranscript
+  if (nextTitle !== undefined) updateData.title = nextTitle || null
+  if (nextDescription !== undefined) updateData.description = nextDescription || null
+  if (nextKeywords !== undefined) updateData.keywords = nextKeywords || null
+  if (nextTranscript !== undefined) updateData.transcript = nextTranscript || null

(Requires the Prisma model fields to accept null.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/resources/calls/save.tsx` around lines 808 - 823, The update
builder currently uses truthy checks (nextTitle, nextDescription, nextKeywords,
nextTranscript) so attempts to clear a field to an empty string get ignored;
change those guards to explicit undefined checks (e.g., if (nextTitle !==
undefined) ...) so empty-string values are included in updateData and then saved
via prisma.callKentEpisodeDraft.update; apply the same change for title,
description, keywords, and transcript so deliberate clears are not silently
discarded.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/routes/calls_.admin/`$callId.tsx:
- Around line 45-57: The loader currently always selects
episodeDraft.episodeBase64 (episodeDraft.select) which causes large base64 audio
to be re-fetched on every 1.5s poll; update the loader to conditionally omit
episodeBase64 from episodeDraft.select when the episodeDraft.status is
PROCESSING (or when the poll path is used), or split the polling endpoint into a
lightweight status-only loader used by the poll. Apply the same change for the
second select usage (the other episodeDraft.select block referenced) so polling
requests only fetch metadata while full audio is fetched once when status moves
out of PROCESSING.

In `@mocks/cloudflare.ts`:
- Around line 964-978: The hasMessages check currently treats [] as valid;
change the guard so hasMessages is true only when messagesRaw is an array with
at least one element (e.g., Array.isArray(messagesRaw) && messagesRaw.length >
0) so empty arrays don't trigger the success branch; update any logic relying on
hasMessages (the conditional that returns jsonOk with response) to use the
tightened hasMessages and keep hasPrompt unchanged.

---

Duplicate comments:
In `@app/routes/calls_.admin/`$callId.tsx:
- Around line 307-313: The mapping for stepLabel (the object keyed by STARTED,
GENERATING_AUDIO, TRANSCRIBING, GENERATING_METADATA, DONE) can return undefined
for unknown step values, causing the <H6> to render empty; update the code that
computes stepLabel (using the step variable) to provide a safe default (e.g.,
'Processing…' or 'Unknown step') when the lookup yields undefined — you can do
this by adding a fallback in the lookup expression (or using nullish coalescing)
so H6 always displays a meaningful label even if the backend adds new enum
values.

In `@app/routes/me/_layout.tsx`:
- Around line 234-264: The fallback for unresolved episodes in
callKentCallerEpisodesDisplay sets seasonNumber and episodeNumber to 0 which
leads to the UI showing "Season 0 Episode 0"; change the fallback to use null
(or undefined) for seasonNumber and episodeNumber (keep episodeTitle 'Call Kent
episode (unavailable)' and episodePath '/calls'), and update the UI rendering
logic that consumes callKentCallerEpisodesDisplay to only render "Season X
Episode Y" when both seasonNumber and episodeNumber are present (i.e.,
non-null), otherwise render the episodeTitle or a generic "Unavailable" label.

In `@app/utils/transistor.server.ts`:
- Around line 228-236: The season-overflow block always sets episodeNumber to 1
even when number exceeds episodesPerSeason by more than one; update the logic in
the block that uses season, episodeNumber, number, and episodesPerSeason so
season is incremented by the number of full season-rollovers (e.g.,
floor((number-1)/episodesPerSeason)) and episodeNumber is set to the proper
wrapped index within the season (e.g., ((number-1) % episodesPerSeason) + 1)
rather than always 1; replace the current if/else that only increments season by
1 with this calculation so arbitrary large number values map to the correct
season and episode.
- Around line 249-253: shortEpisodePath is built with the raw variable number
instead of the adjusted episodeNumber, causing wrong season/episode combos when
number > episodesPerSeason; update the call to getEpisodePath to pass the
already-computed episodeNumber (and the correct season variable if different)
instead of number so shortEpisodePath uses the adjusted values (references:
shortEpisodePath, getEpisodePath, number, episodeNumber, season).

In `@prisma/migrations/20260222190000_call-kent-draft-episodes/migration.sql`:
- Around line 73-79: The migration creates both a UNIQUE index
CallKentEpisodeDraft_callId_key and a redundant non-unique index
CallKentEpisodeDraft_callId_idx on the same column in table
CallKentEpisodeDraft; remove the non-unique index by deleting the CREATE INDEX
"CallKentEpisodeDraft_callId_idx" statement (or replace it with a DROP INDEX if
this is a down-migration), leaving only the UNIQUE index
"CallKentEpisodeDraft_callId_key" to avoid duplicate indexes on "callId".

---

Nitpick comments:
In `@app/routes/calls_.admin/`$callId.tsx:
- Around line 335-470: The DraftEditor component's form fields (ids:
draft-title, draft-description, draft-keywords, draft-transcript) lack ARIA
references and visible validation feedback; update DraftEditor to (1) render
per-field error message elements (e.g., <span id="draft-title-error">) for each
required input, (2) add aria-describedby on each input pointing to its error id
so screen readers know about errors, (3) mark error elements with role="alert"
and toggle their content/visibility based on client-side validation state, and
(4) wire simple validation logic in DraftEditor to set/clear these error
messages before submit (or set noValidate on the <Form> and show custom
validation) so native required-only behavior is not the sole feedback mechanism.

In `@app/routes/resources/calls/save.tsx`:
- Around line 808-823: The update builder currently uses truthy checks
(nextTitle, nextDescription, nextKeywords, nextTranscript) so attempts to clear
a field to an empty string get ignored; change those guards to explicit
undefined checks (e.g., if (nextTitle !== undefined) ...) so empty-string values
are included in updateData and then saved via
prisma.callKentEpisodeDraft.update; apply the same change for title,
description, keywords, and transcript so deliberate clears are not silently
discarded.

In `@app/utils/call-kent-episode-draft.server.ts`:
- Around line 47-72: The draft currently writes the full stitched MP3 as a
base64 data URL into the episodeBase64 column (see createEpisodeAudio,
bufferToMp3DataUrl and the prisma.callKentEpisodeDraft.updateMany calls), which
will bloat SQLite and slow polling; change the flow to avoid persisting large
base64 blobs: either (A) persist the episode MP3 to object storage or disk
(implement a helper like persistEpisodeAudio that returns an
episodeUrl/filePath) and store only that reference in the callKentEpisodeDraft
record instead of episodeBase64, or (B) keep storing episodeBase64 but stop
returning it in lightweight polling/loader queries (exclude episodeBase64 from
the select used for status polling and only fetch it when rendering the audio
preview). Ensure updateMany writes the reference field (episodeUrl/filePath) or
clears episodeBase64 accordingly and update any callers that expect
episodeBase64 to use the new reference-based retrieval.
- Around line 74-95: The transcript step currently allows an empty string from
transcribeMp3WithWorkersAi to be stored and advance processing; add a guard
after calling transcribeMp3WithWorkersAi to treat empty/whitespace transcripts
as errors (or retry) before updating the draft. Specifically, after
transcribeMp3WithWorkersAi(...) and before the
prisma.callKentEpisodeDraft.updateMany that sets transcript and
step='GENERATING_METADATA', validate that transcript is a non-empty string
(e.g., trim length > 0); if invalid, set an errorMessage and/or set step back to
a safe state and return/throw so you don't persist an empty transcript. Keep the
existing isCloudflareTranscriptionConfigured() check as-is but ensure the new
empty-transcript validation prevents advancing with blank data.

In `@app/utils/ffmpeg.server.ts`:
- Around line 35-71: The full-stitch branch in the args ternary (when
hasStitchAssets is true) leaves the final acrossfade unnamed so FFmpeg
implicitly picks it; update the filter_complex used in that branch (the string
fed to filter_complex in args) to assign a named output label (e.g., append
[,out] to the last acrossfade) and then add the explicit '-map', '[out]' entries
before outputPath—mirror the fallback branch pattern used for [out] mapping to
make hasStitchAssets branch robust; modify the code building args (refer to
hasStitchAssets, filter_complex, args, and outputPath) accordingly.
- Line 79: The code currently calls await fs.promises.rmdir(cacheDir, {
recursive: true }) which uses the deprecated rmdir API; replace that call with
await fs.promises.rm(cacheDir, { recursive: true, force: true }) so removal is
non-deprecated and resilient; locate the invocation of fs.promises.rmdir that
references cacheDir and update the options to include both recursive: true and
force: true.

In `@prisma/schema.prisma`:
- Around line 126-128: Remove the redundant non-unique index on callId: since
the field has an `@unique` constraint (callId) that already creates a unique
index, delete the explicit @@index([callId]) declaration to avoid duplicate
indexing and wasted storage; update the model by removing the @@index([callId])
line and leave other indexes (e.g., @@index([status, updatedAt])) intact.
- Around line 104-128: The CallKentEpisodeDraft model is storing full MP3 audio
in the episodeBase64 String field which will bloat SQLite and block writes;
instead change the schema to store a lightweight reference (e.g., episodeUrl or
episodeStorageKey on CallKentEpisodeDraft) and move actual audio persistence to
object storage (R2/S3), then update the logic that writes/reads episodeBase64
(the code paths that create/update CallKentEpisodeDraft and the polling/loader
that SELECTs the draft) to upload/download audio from object storage and save
only the URL/key in the new DB field.

Comment on lines +964 to +978
const messagesRaw = body?.messages
const hasMessages = Array.isArray(messagesRaw)
const promptRaw = body?.prompt
const hasPrompt =
typeof promptRaw === 'string' && promptRaw.trim().length > 0
if (hasMessages || hasPrompt) {
return jsonOk({
response: JSON.stringify({
title: `Mock Call Kent episode title (${model})`,
description:
'Mock description generated by Workers AI. This is a placeholder used in local mocks.',
keywords: 'call kent, mock, podcast, workers ai, transcript',
}),
})
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n --type=ts -C5 'keywords' --glob '!mocks/**' --glob '!**/__tests__/**' --glob '!**/*.test.ts'

Repository: kentcdodds/kentcdodds.com

Length of output: 43919


🏁 Script executed:

sed -n '960,980p' mocks/cloudflare.ts

Repository: kentcdodds/kentcdodds.com

Length of output: 866


hasMessages accepts empty arrays; add a length check.

The Array.isArray(messagesRaw) check evaluates to true for [], so a malformed request with an empty messages array returns a successful metadata response instead of failing. Tests that accidentally send { messages: [] } will silently pass instead of catching the error early.

🛡️ Proposed guard
-const hasMessages = Array.isArray(messagesRaw)
+const hasMessages = Array.isArray(messagesRaw) && messagesRaw.length > 0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const messagesRaw = body?.messages
const hasMessages = Array.isArray(messagesRaw)
const promptRaw = body?.prompt
const hasPrompt =
typeof promptRaw === 'string' && promptRaw.trim().length > 0
if (hasMessages || hasPrompt) {
return jsonOk({
response: JSON.stringify({
title: `Mock Call Kent episode title (${model})`,
description:
'Mock description generated by Workers AI. This is a placeholder used in local mocks.',
keywords: 'call kent, mock, podcast, workers ai, transcript',
}),
})
}
const messagesRaw = body?.messages
const hasMessages = Array.isArray(messagesRaw) && messagesRaw.length > 0
const promptRaw = body?.prompt
const hasPrompt =
typeof promptRaw === 'string' && promptRaw.trim().length > 0
if (hasMessages || hasPrompt) {
return jsonOk({
response: JSON.stringify({
title: `Mock Call Kent episode title (${model})`,
description:
'Mock description generated by Workers AI. This is a placeholder used in local mocks.',
keywords: 'call kent, mock, podcast, workers ai, transcript',
}),
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mocks/cloudflare.ts` around lines 964 - 978, The hasMessages check currently
treats [] as valid; change the guard so hasMessages is true only when
messagesRaw is an array with at least one element (e.g.,
Array.isArray(messagesRaw) && messagesRaw.length > 0) so empty arrays don't
trigger the success branch; update any logic relying on hasMessages (the
conditional that returns jsonOk with response) to use the tightened hasMessages
and keep hasPrompt unchanged.

@cursor cursor bot force-pushed the cursor/episode-content-workflow-d1ca branch from 1759b15 to d00e1d3 Compare February 22, 2026 22:14
@cursor cursor bot force-pushed the cursor/episode-content-workflow-d1ca branch 2 times, most recently from d21bb1d to 6bdfe77 Compare February 23, 2026 20:57
@cursor

This comment has been minimized.

cursoragent and others added 4 commits February 23, 2026 21:11
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
cursoragent and others added 22 commits February 23, 2026 21:11
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
- Updated the `.env.example` file to reflect the new default for `CLOUDFLARE_AI_TEXT_TO_SPEECH_MODEL`.
- Improved formatting in various files for better readability, including consistent line breaks and indentation in TypeScript and Markdown files.
- Adjusted error handling messages in the Vite configuration and other components for clarity.
…nctionality

- Changed the default voice for caching and initialization in the text-to-speech module to "luna" to align with the new model specifications.
- Updated related test cases and mock responses to reflect the new default voice.
- Adjusted comments in the code to clarify the default voice behavior.
@cursor cursor bot force-pushed the cursor/episode-content-workflow-d1ca branch from dc4a832 to 931eae9 Compare February 23, 2026 21:15
@kentcdodds kentcdodds merged commit 606569b into main Feb 23, 2026
2 checks passed
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issue.

setIsSubmitting(false)
setError(e instanceof Error ? e.message : 'Unable to read recording.')
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing client-side validation before uploading audio blob

Medium Severity

ResponseAudioDraftForm's handleSubmit doesn't validate the callTitle field before reading the audio Blob as a data URL and sending it to the server. The form uses noValidate, which disables native required enforcement. Unlike RecordingForm in save.tsx, which short-circuits with getErrorForTitle() before the FileReader call, this form always proceeds to base64-encode and upload the full audio blob even when the title is empty — only to have the server reject it via a redirect.

Fix in Cursor Fix in Web

@cursor
Copy link

cursor bot commented Feb 23, 2026

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: Missing client-side validation before uploading audio blob
    • Added client-side call title validation to stop submission before reading and uploading the audio blob when the title is invalid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants