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.
| Endpoint | Minimum Role | Resolution |
|---|---|---|
GET / (list) | member | podcastId query param via requirePodcastRole() |
GET /:id (detail) | member | Resolves podcast_id from podcast_guests row |
PUT /:id (update) | admin | Resolves 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:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
podcastId | UUID | Yes | - | Podcast to search |
q | string | No | - | Search on display_name or email (case-insensitive) |
limit | number | No | 20 | Max results (clamped to 1-50) |
Response 200:
{
"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 fromepisode_guestsrows linked viapodcast_guest_idavatarUrl: Fetched fromuser_profilesif the guest has a linkeduser_id
Error Responses:
| Code | Condition |
|---|---|
400 | Missing podcastId query parameter |
401 | Missing or invalid auth token |
403 | User is not a member of the podcast |
500 | Database query error |
Get Guest Detail with Episode History
GET /api/podcast-guests/:idReturns a single canonical guest with full metadata and linked episode history.
Auth: requirePodcastRoleByResolver('member', resolvePodcastGuestPodcastId)
Response 200:
{
"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 fromepisode_guestsjoined withepisodestable, ordered byinvited_atdescendingavatarUrl: Fetched fromuser_profilesifuser_idis set
Error Responses:
| Code | Condition |
|---|---|
401 | Missing or invalid auth token |
404 | Guest ID not found (from resolver) |
Update Canonical Guest
PUT /api/podcast-guests/:idUpdates a canonical guest's display metadata. Only provided fields are updated.
Auth: requirePodcastRoleByResolver('admin', resolvePodcastGuestPodcastId)
Request Body (at least one field required):
{
"displayName": "Updated Name",
"notes": "Speaker on episode 5",
"bio": "Author of 'Podcast Mastery'"
}| Field | Type | Constraints | Description |
|---|---|---|---|
displayName | string | 1-200 chars | Guest display name |
notes | string | null | Max 2000 chars | Host-private notes |
bio | string | null | Max 2000 chars | Host-private bio/background |
Response 200:
{
"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:
| Code | Condition |
|---|---|
400 | No fields provided, or validation failure |
401 | Missing or invalid auth token |
403 | User is not an admin on the podcast |
404 | Guest ID not found (from resolver) |
500 | Database update error |
Database Schema
podcast_guests Table
Migration: supabase/migrations/20260218120000_podcast_guests_canonical_identity.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:
| Index | Type | Purpose |
|---|---|---|
idx_podcast_guests_podcast_email | Unique | One guest per email per podcast (podcast_id, lower(email)) |
idx_podcast_guests_podcast_user | Unique (partial) | One user per podcast (WHERE user_id IS NOT NULL) |
idx_podcast_guests_podcast | Standard | Lookup by podcast |
idx_podcast_guests_email | Standard | Lookup by email (lower(email)) |
idx_podcast_guests_user | Partial | Lookup by user (WHERE user_id IS NOT NULL) |
Foreign Key on episode_guests
The migration adds podcast_guest_id to episode_guests:
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:
- Skips if
podcast_guest_idis already set - Looks up
podcast_idfrom the episode - Upserts into
podcast_guestsusingON CONFLICT (podcast_id, lower(email)) - Sets
NEW.podcast_guest_idto 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):
- Finds
podcast_guestsrows wherelower(email)matches anduser_id IS NULL - Sets
user_idto the new user's ID - Logs the number of linked records
This enables automatic profile enrichment (avatar, etc.) when a previously email-only guest creates an account.
RLS Policies
| Policy | Operation | Condition |
|---|---|---|
| Team can view | SELECT | get_podcast_role(podcast_id) IS NOT NULL |
| Staff can insert | INSERT | has_podcast_role(podcast_id, 'admin') |
| Staff can update | UPDATE | has_podcast_role(podcast_id, 'admin') |
| Staff can delete | DELETE | has_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_nameandemail - 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:
- Search mode: Uses
GuestSearchComboboxto select an existing canonical guest - Manual mode: Enter name and email directly
When a canonical guest is selected, the invite inherits their displayName and email.
RPC Client Usage
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 }
);Related Documentation
- Hono API Overview - API architecture and patterns
- Multi-Tenancy Model - Multi-table permission model
- Episode People API - Episode roster and RSS credit management
- Guest Account Flow - Guest account system overview
- Guest Network API - Guest discovery and invitations