Skip to content

Show Notes Auto-Create & Per-Section Templates

When a new episode is created (either via the host's /e/new flow or from a confirmed booking) show.fm auto-creates the show_notes row and its sections, rendering each section from a per-section template. Templates are resolved per section — shared, host_private, guest — using a precedence chain that lets per-episode choices override booking-link overrides override podcast defaults.

This page covers the runtime resolution model, the service API, the guest-template snapshot pattern, and the unresolved-tag highlight that surfaces unrendered placeholders.

Resolution Precedence

For each of the three sections — shared, host_private, guest — the service walks four levels in order and picks the first one that supplies a concrete value:

LevelSourceNotes
1. Per-episode override/e/new form data → PerEpisodeShowNotesOverridesExplicit null is "blank" (overrides defaults), string is a template id, undefined is "inherit"
2. Booking link overridebooking_links.show_notes_<section>_mode + _template_idinherit falls through, template uses the id, blank returns null
3. Podcast defaultpodcasts.default_<section>_template_idSet via the template editor's "set as default" toggle
4. BlankSection is created with the empty TipTap doc and a default title

The result is a ResolvedShowNotesTemplates shape ({ shared, hostPrivate, guest } of string | null). Both null and "no-op" produce an empty section — the distinction only matters in the UI for selecting modes.

Service API

All exports live in src/lib/services/show-notes-service.ts.

resolveShowNotesTemplates(supabase, podcastId, bookingLinkId?, perEpisodeOverrides?)

Walks the precedence chain and returns the resolved per-section template ids. This is not a full template fetch — it only returns ids — so the result is cheap to recompute.

typescript
const resolved = await resolveShowNotesTemplates(
	supabase,
	podcast.id,
	booking.booking_link_id,
	parsePerSectionOverrides(formData.get('showNotesConfig'))
);
// → { shared: 'tpl-uuid' | null, hostPrivate: '…' | null, guest: '…' | null }

applyShowNotesTemplate(supabase, options)

Creates the show_notes row and inserts a section per shared / host_private / per-guest. Each template is fetched and re-validated (podcast match + section match) via fetchValidatedTemplate before render — defense-in-depth against per-episode overrides arriving from form data and being resolved with the admin client.

typescript
const result = await applyShowNotesTemplate(supabase, {
	episodeId: 'ep-uuid',
	resolved, // from resolveShowNotesTemplates
	createGuestSections: true
});
// → { success, showNotesId, error }

Failure rolls back the show_notes row so the host can retry cleanly. The function never throws — call sites should still complete their primary operation (episode insert, booking confirm) even if section creation fails.

getOrCreateShowNotes(supabase, episodeId, resolved, options?)

Idempotent wrapper. Returns the existing show_notes.id if one exists for the episode; otherwise calls applyShowNotesTemplate.

createGuestSection(supabase, args) / removeGuestSection(supabase, guestId)

Sync helpers used when a guest is added or removed after show notes already exist. createGuestSection resolves the template using the snapshot precedence (see below), renders with the guest's context, and appends the section after the highest existing display_order. removeGuestSection deletes any sections owned by the guest.

buildShowNotesContext(podcast, episode, host, guests, options?)

Builds the MagicTagContext used by replaceMagicTags. Pass forGuestId to focus the context on a specific guest (used when rendering each guest section).

htmlToTipTapDoc(html)

Pure-JS HTML → TipTap JSON converter using linkedom. Replaces @tiptap/html/server, which depends on happy-dom (Node-only) and breaks in Cloudflare Workers — the booking-confirm route and SvelteKit page actions both run there. The grammar matches what RichTextEditor emits: paragraph, headings (h1–h6), blockquote, horizontalRule, bullet/orderedList, listItem, codeBlock, hardBreak, plus the marks bold/italic/underline/strike/code/link.

Guest Template Snapshot

When applyShowNotesTemplate resolves the guest section's template id, it persists the id on show_notes.created_from_guest_template_id. createGuestSection later reads this column before falling back to podcasts.default_guest_template_id.

Why: guests added after the episode was created should render with the same template the original guests received — even if the podcast default has since changed, or the original came from a booking-link / per-episode override. Without the snapshot, late-added guests would silently drift onto the current default.

A trigger (enforce_show_notes_guest_template_match) validates the snapshot column on insert/update: it must reference a template with target_section='guest' belonging to the same podcast as the linked episode. See Database Schema.

Auto-Create Call Sites

Call siteFileNotes
New episode form actionsrc/routes/(app)/p/[slug]/e/new/+page.server.tsUses admin client because per-episode overrides arrive from raw form data
Booking confirmsrc/api/routes/bookings/index.tsNon-fatal: errors are logged but never fail the confirmation response
Manual (re-)applysrc/routes/(app)/p/[slug]/e/[episodeSlug]/show-notes/+page.server.tsUsed by the legacy "create show notes" dialog

Magic-Tag Rendering Modes

Templates are rendered through replaceMagicTags(template, context, options) from src/lib/automation/tag-parser.ts. The auto-create path uses two non-default options:

OptionValueWhy
unresolvedAs'literal'Leave missing tag values as {tag_name} literals so the editor can highlight them as TODO markers. (Default 'empty' would silently drop them.)
preserveUnknowntrueLeave unknown tags (typos, removed tags) visible too, instead of dropping them.

Show notes are rendered once at episode creation — they do not re-render when a guest is added or a host profile fills in later. The literal {tag_name} survives into the editor as a visible placeholder, and the unresolved-tag highlight extension surfaces it.

Unresolved Tag Highlight

src/lib/components/editor/unresolved-tag-extension.ts — a TipTap extension wrapping a ProseMirror plugin. It walks each editor state, scans text nodes for {tag_name} matches via the same grammar as tag-parser.ts, and emits inline Decorations with the class magic-tag-unresolved and an explanatory title tooltip:

"This tag was not filled in when the show notes were created. Edit it manually — it will not auto-update."

Decorations are derived from doc state, so they always stay in sync — including in read-only views (e.g. guests viewing shared sections).

The extension is registered in CollaborativeEditor.svelte alongside the standard StarterKit + Underline + Collaboration + CustomCursor extensions.

Per-Section Template Authoring

Templates are stored in notification_templates with template_type='show_notes' and a target_section column (shared / host_private / guest). A CHECK constraint enforces target_section IS NOT NULL iff template_type='show_notes'.

Two API conveniences keep section assignment honest:

  • Create & update validationPOST /api/automations/templates and PUT /api/automations/templates/:id require target_section for show-notes templates and reject it for email templates. The PUT also rejects changes to target_section after creation, because changing it would orphan dependent FKs (podcasts.default_*_template_id, booking_links.show_notes_*_template_id, show_notes.created_from_guest_template_id).
  • set_as_default toggle — passing set_as_default: true writes the template id to the matching podcasts.default_<section>_template_id column. On PUT, set_as_default: false clears the column iff this template is currently the default (so a concurrent edit doesn't get stomped).

DB triggers (enforce_show_notes_template_section_match, enforce_show_notes_guest_template_match) validate every cross-table reference points to a template with the matching target_section and the same owning podcast. See Database Schema → Triggers.

Each booking link has three independent mode + _template_id pairs:

sql
show_notes_shared_mode         show_notes_template_mode  -- 'inherit' | 'template' | 'blank'
show_notes_shared_template_id  uuid REFERENCES notification_templates(id)
-- (same shape for host_private and guest)

Per-pair CHECK constraints enforce that _template_id IS NOT NULL iff _mode = 'template'. The booking-links API mirrors this with a superRefine so a request returns 400 from validation rather than 500 from a constraint violation. See Booking Links API.

Client-Side Guest Staging

On /e/new the episode row does not exist yet, so guests picked in the invite panel (GuestInvitePicker.svelte) are staged client-side as StagedGuest entries (the interface still lives in GuestInviteCard.svelte, whose own onStage mode remains for inline callers). Staged guests are inserted into episode_guests after episodes.insert succeeds; auto-create then sees them in the standard guest fetch and renders one guest section per staged guest.

Internal documentation - Not for public distribution