Skip to content

Podcast Guests API

Canonical guest identity management per podcast. Deduplicates the same person across multiple episode invitations into a single, manageable record.

Source: src/api/routes/podcast-guests/index.ts

Overview

The Podcast Guests API manages canonical guest identities at the podcast level. While episode_guests tracks per-episode invitations with magic link access, podcast_guests provides a single identity record per guest per podcast, enabling:

  • Deduplication of guests across episodes
  • Centralized guest search and management
  • Automatic account linking when a guest signs up
  • Episode history aggregation per guest

How Canonical Guests Are Created

Canonical podcast_guests records are not created directly via API. They are auto-created by a database trigger when an episode_guests row is inserted (via the Guests API at POST /api/guests). The trigger upserts a podcast_guests row using ON CONFLICT (podcast_id, lower(email)), ensuring deduplication.

The Podcast Guests API provides read and update access to these canonical records.

Authentication

All endpoints require Authorization: Bearer <supabase_access_token> via requireAuth() middleware.

EndpointMinimum RoleResolution
GET / (list)memberpodcastId query param via requirePodcastRole()
GET /:id (detail)memberResolves podcast_id from podcast_guests row
PUT /:id (update)adminResolves podcast_id from podcast_guests row

The GET /:id and PUT /:id endpoints use requirePodcastRoleByResolver() with a resolver that fetches the podcast_id from the podcast_guests table.

Host-private notes/bio (#293 §5.1): notes and bio moved off podcast_guests into the admin-scoped podcast_guest_private_notes side table. The list endpoint no longer selects them; GET /:id joins them for admin+ callers only (member callers receive nulls and privateNotesVisible: false); PUT /:id writes display_name to podcast_guests and upserts notes/bio into the side table.

Endpoints

All routes are mounted at /api/podcast-guests via app.route('/podcast-guests', podcastGuestsRoutes).

List / Search Canonical Guests

GET /api/podcast-guests?podcastId=<uuid>&q=<search>&limit=<n>

Returns canonical guests for a podcast with episode counts and avatar URLs.

Auth: requirePodcastRole('member')

Query Parameters:

ParamTypeRequiredDefaultDescription
podcastIdUUIDYes-Podcast to search
qstringNo-Search on display_name or email (case-insensitive)
limitnumberNo20Max results (clamped to 1-50)

Response 200:

json
{
	"success": true,
	"data": [
		{
			"id": "uuid",
			"displayName": "Jane Smith",
			"email": "[email protected]",
			"userId": "uuid-or-null",
			"avatarUrl": "https://...-or-null",
			"episodeCount": 3,
			"createdAt": "2026-02-17T00:00:00.000Z"
		}
	]
}

Data Enrichment:

  • episodeCount: Counted from episode_guests rows linked via podcast_guest_id
  • avatarUrl: Fetched from user_profiles if the guest has a linked user_id

Error Responses:

CodeCondition
400Missing podcastId query parameter
401Missing or invalid auth token
403User is not a member of the podcast
500Database query error

Get Guest Detail with Episode History

GET /api/podcast-guests/:id

Returns a single canonical guest with full metadata and linked episode history.

Auth: requirePodcastRoleByResolver('member', resolvePodcastGuestPodcastId)

Response 200:

json
{
	"success": true,
	"data": {
		"id": "uuid",
		"displayName": "Jane Smith",
		"email": "[email protected]",
		"userId": "uuid-or-null",
		"avatarUrl": "https://...-or-null",
		"notes": "Met at PodFest 2026",
		"bio": "Author and speaker on AI ethics",
		"createdAt": "2026-02-17T00:00:00.000Z",
		"updatedAt": "2026-02-17T00:00:00.000Z",
		"episodes": [
			{
				"episodeGuestId": "uuid",
				"episodeId": "uuid",
				"episodeTitle": "AI Ethics Deep Dive",
				"episodeSlug": "ai-ethics-deep-dive",
				"status": "active",
				"invitedAt": "2026-02-15T00:00:00.000Z",
				"lastAccessedAt": "2026-02-16T12:00:00.000Z"
			}
		]
	}
}

Data Resolution:

  • episodes: Fetched from episode_guests joined with episodes table, ordered by invited_at descending
  • avatarUrl: Fetched from user_profiles if user_id is set

Error Responses:

CodeCondition
401Missing or invalid auth token
404Guest ID not found (from resolver)

Update Canonical Guest

PUT /api/podcast-guests/:id

Updates a canonical guest's display metadata. Only provided fields are updated.

Auth: requirePodcastRoleByResolver('admin', resolvePodcastGuestPodcastId)

Request Body (at least one field required):

json
{
	"displayName": "Updated Name",
	"notes": "Speaker on episode 5",
	"bio": "Author of 'Podcast Mastery'"
}
FieldTypeConstraintsDescription
displayNamestring1-200 charsGuest display name
notesstring | nullMax 2000 charsHost-private notes
biostring | nullMax 2000 charsHost-private bio/background

Response 200:

json
{
	"success": true,
	"data": {
		"id": "uuid",
		"displayName": "Updated Name",
		"email": "[email protected]",
		"userId": "uuid-or-null",
		"notes": "Speaker on episode 5",
		"bio": "Author of 'Podcast Mastery'",
		"updatedAt": "2026-02-17T12:00:00.000Z"
	}
}

Error Responses:

CodeCondition
400No fields provided, or validation failure
401Missing or invalid auth token
403User is not an admin on the podcast
404Guest ID not found (from resolver)
500Database update error

Database Schema

podcast_guests Table

Migration: supabase/migrations/20260218120000_podcast_guests_canonical_identity.sql

sql
CREATE TABLE podcast_guests (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  podcast_id UUID NOT NULL REFERENCES podcasts(id) ON DELETE CASCADE,
  display_name TEXT NOT NULL,
  email TEXT NOT NULL,
  user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
  notes TEXT,
  bio TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

Indexes:

IndexTypePurpose
idx_podcast_guests_podcast_emailUniqueOne guest per email per podcast (podcast_id, lower(email))
idx_podcast_guests_podcast_userUnique (partial)One user per podcast (WHERE user_id IS NOT NULL)
idx_podcast_guests_podcastStandardLookup by podcast
idx_podcast_guests_emailStandardLookup by email (lower(email))
idx_podcast_guests_userPartialLookup by user (WHERE user_id IS NOT NULL)

Foreign Key on episode_guests

The migration adds podcast_guest_id to episode_guests:

sql
ALTER TABLE episode_guests
  ADD COLUMN podcast_guest_id UUID REFERENCES podcast_guests(id) ON DELETE SET NULL;

This links each per-episode invitation to its canonical guest identity.

Database Triggers

Two SECURITY DEFINER triggers automate canonical guest lifecycle:

1. Auto-Upsert on Episode Guest Creation

Trigger: trigger_upsert_podcast_guest (BEFORE INSERT on episode_guests) Function: ensure_podcast_guest_on_episode_guest_insert()

When an episode_guests row is inserted:

  1. Skips if podcast_guest_id is already set
  2. Looks up podcast_id from the episode
  3. Upserts into podcast_guests using ON CONFLICT (podcast_id, lower(email))
  4. Sets NEW.podcast_guest_id to the canonical record ID

This ensures every episode invitation is linked to a canonical guest record.

2. Account Linking on User Signup

Triggers: trigger_link_podcast_guests_on_confirm (AFTER UPDATE OF email_confirmed_at on auth.users, NULL to timestamp) and trigger_link_podcast_guests_to_user (AFTER INSERT, only when inserted already confirmed) Function: link_podcast_guests_to_new_user()

When a new user confirms their email (never before, so an unproven address can never claim a guest record):

  1. Finds podcast_guests rows where lower(email) matches and user_id IS NULL
  2. Sets user_id to the new user's ID
  3. Logs the number of linked records

This enables automatic profile enrichment (avatar, etc.) when a previously email-only guest creates an account.

RLS Policies

PolicyOperationCondition
Team can viewSELECTget_podcast_role(podcast_id) IS NOT NULL
Staff can insertINSERThas_podcast_role(podcast_id, 'admin')
Staff can updateUPDATEhas_podcast_role(podcast_id, 'admin')
Staff can deleteDELETEhas_podcast_role(podcast_id, 'admin')

Entity Relationship

UI Integration

GuestSearchCombobox

Source: src/lib/components/guest/GuestSearchCombobox.svelte

A searchable popover component that calls GET /api/podcast-guests to find existing guests. Used in the GuestInviteCard to allow hosts to select an existing canonical guest instead of manually entering details.

Features:

  • Debounced search (300ms) on display_name and email
  • Shows avatar, episode count badge, and email
  • "Add new guest manually" fallback option
  • Loads initial results when opened

GuestInvitePicker and GuestInvitePanel

Source: src/lib/components/guest/GuestInvitePicker.svelte, src/lib/components/guest/GuestInvitePanel.svelte

The invite panels' body. GuestInvitePicker calls GET /api/podcast-guests (debounced search, limit 30) and renders the results as an inline multi-select list with "someone new" name and email fields beneath. The episode surfaces use it directly; the Guests page and a guest's record use GuestInvitePanel, which pairs it with the single-select EpisodePicker and commits every pick with sequential POST /api/guests calls (podcastGuestId set for existing canonical guests).

GuestInviteCard

Source: src/lib/components/guest/GuestInviteCard.svelte

Single-select invite form, still used by the episode guests section. Dual-mode:

  1. Search mode: Uses GuestSearchCombobox to select an existing canonical guest
  2. Manual mode: Enter name and email directly

When a canonical guest is selected, the invite inherits their displayName and email.

RPC Client Usage

typescript
import { createApiClient } from '$api/client';

const client = createApiClient(fetch);
const token = session.access_token;
const headers = { Authorization: `Bearer ${token}` };

// List/search canonical guests
const res = await client.api['podcast-guests'].$get(
	{ query: { podcastId, q: 'jane', limit: '10' } },
	{ headers }
);

// Get guest detail with episode history
const res = await client.api['podcast-guests'][':id'].$get({ param: { id: guestId } }, { headers });

// Update guest metadata
const res = await client.api['podcast-guests'][':id'].$put(
	{
		param: { id: guestId },
		json: { displayName: 'Updated Name', notes: 'Met at PodFest' }
	},
	{ headers }
);

Internal documentation - Not for public distribution