Guest Account Flow
This guide documents the complete journey from guest magic-link access to full account creation, including automatic episode linking and the My Episodes dashboard.
Overview
show.fm guests start with token-based portal access (no account required). When they choose to create a full account, the system automatically links their existing guest episodes and provides a unified dashboard.
Entry Points
Guests can reach the account creation page from two places:
- Guest Portal CTA: A "Create Account" prompt within the guest episode portal
- Direct Link:
/guest/create-account(with optional?email=pre-fill)
Account Creation Flow
Source: src/routes/(guest)/guest/create-account/
Page Load
The load function (+page.server.ts) checks three sources for pre-filling:
- Form error values (highest priority, after validation failure)
- Guest token cookie (from portal session)
- URL
?email=parameter (from invitation links)
Form Submission
The form collects: Full Name (optional), Email (required), Password (required, 8+ chars).
Success State
After a successful signup the page shows the OTP step (OtpVerify) with one welcome card: once the code is verified, any episodes the address was invited to are linked automatically and appear under My Episodes. The action reads no linking state before confirmation, because there is none to read: since migration 20260906200000 the account and its guest links are provisioned by the database at confirmation, never at signup (an unconfirmed signup owns nothing, and reap_unconfirmed_users() removes it after seven days). The old "We found X episodes" preview was retired with that change.
Error Handling
| Error | Response |
|---|---|
| Missing email/password | 400 with validation message |
| Invalid email format | 400 with format error |
| Password < 8 chars | 400 with length error |
| Email already registered | 400 with sign-in suggestion |
| Database error | 500 with retry message |
Form values (email, name) are preserved on errors for re-population.
Account Linking Triggers
When a guest creates an account, their existing episode_guests records are linked automatically via database triggers.
Migration: supabase/migrations/20260127000002_epic6a_account_linking_triggers.sql
Bidirectional Linking
| Trigger | Fires On | Action |
|---|---|---|
link_episode_guest_to_user | BEFORE INSERT on episode_guests | Links new guest record to an existing confirmed user by email |
link_orphaned_guests_to_user | auth.users email confirmed (UPDATE OF email_confirmed_at NULL to timestamp; INSERT only if inserted confirmed) | Links all orphaned guest records to the confirmed user, and adopts the profile name |
link_podcast_guests_to_new_user | same two triggers | Same for the canonical podcast_guests rows |
propagate_profile_name_to_guest_snapshots | AFTER INSERT OR UPDATE on user_profiles | Pushes a later rename into all four name snapshots |
All of these use case-insensitive email matching and SECURITY DEFINER to safely access auth.users.
The name adoption is the #292 ownership rule: once a guest row is linked, the profile name wins over whatever the host typed. All four provisioning triggers fire on the same confirmation event in alphabetical order (profile, billing, then the two linkers), so the profile row exists before the linkers read its name; the user_profiles trigger still covers a later rename. See Guest Invite Pipeline.
See Multi-Tenancy Architecture for trigger implementation details.
My Episodes Dashboard
Source: src/routes/(app)/settings/my-episodes/
The My Episodes page provides authenticated users a complete view of all episodes where they are a guest or collaborator.
Data Loading
The server load function runs two parallel queries to ensure completeness:
// Query 1: Episodes linked by user_id (trigger has fired)
const { data: linkedEpisodes } = await supabase
.from('episode_guests')
.select(
`id, episode_id, status, access_token, ...,
episodes!inner(id, title, status, podcast_id,
podcasts!inner(id, title, slug, cover_image_url))`
)
.eq('user_id', user.id);
// Query 2: Episodes matching email but not yet linked
const { data: orphanedEpisodes } = await supabase
.from('episode_guests')
.select(/* same shape */)
.eq('email', user.email)
.is('user_id', null);Results are combined and deduplicated by episode_id. Role labels are enriched from episode_credits.
Both embeds are !inner, so RLS on either table can empty this page
podcasts!inner(...) means an episode disappears from My Episodes whenever its podcast row is unreadable, even though the episode itself is visible.
That is exactly what made the page look empty for guest-only users, and it presented as an account-linking bug when linkage was in fact working everywhere. episodes got a guest-visibility policy in 20260217120000; podcasts never did, so a guest-only user could read a podcast only through podcast_members or an active booking link. 20260805100100_podcasts_guest_visibility.sql adds the matching policy via the user_is_guest_on_podcast SECURITY DEFINER helper (DEFINER is load-bearing: an inline EXISTS here recurses through the episodes policies back into podcasts).
Invited episodes appear immediately as a result, matching the invited / active / completed status set the episodes policy already allowed. expired stays excluded on both.
Data Shape
Each episode card displays:
| Field | Source | Description |
|---|---|---|
episodeTitle | episodes.title | Episode name |
podcastName | podcasts.title | Parent podcast |
podcastCoverUrl | podcasts.cover_image_url | Podcast artwork |
status | episode_guests.status | invited / active / completed / expired |
roleLabel | episode_credits.role_label | Host / Co-host / Guest (defaults to "Guest") |
recordingDate | episodes.recording_scheduled_at | Scheduled recording time |
accessToken | episode_guests.access_token | For portal magic link |
UI Components
Filters
- Status filter: All Statuses / Invited / Active / Completed / Expired
- Podcast filter: Appears when episodes span 2+ podcasts
Episode Cards
Episodes are grouped by podcast. Each group shows:
- Podcast avatar (cover image with mic fallback)
- Podcast name + episode count badge
- Indented episode cards within the group
Each card displays:
- Episode title (truncated)
- Status badge (color-coded: secondary=invited, default=active, outline=completed, destructive=expired)
- Role label badge
- Recording date or last-accessed/invited timestamp
- Action button: "Open Episode Portal" (active/invited) or "View Episode" (completed)
Empty States
- No episodes at all: Informational card explaining that episodes appear when hosts invite the user
- No filter matches: Prompt to clear filters with a "Clear Filters" button
Portal Links
The "Open Episode Portal" button navigates to the guest collaboration portal:
function getPortalUrl(episodeId: string, accessToken: string | null): string {
if (accessToken) {
return `/guest/e/${episodeId}?token=${accessToken}`;
}
return `/guest/e/${episodeId}`;
}Guest-Aware Sidebar Navigation
The (app) layout detects whether the current user is a "guest-only" user (has guest episodes but no podcast memberships).
Source: src/routes/(app)/+layout.svelte
Guest-Only Users
When data.isGuestOnlyUser is true:
- Logo links to
/settings/my-episodes(not podcast dashboard) - Sidebar shows "Guest Dashboard" section with "My Episodes" as primary nav
- Episode count badge displayed
- CTA banner: "Start Your Own Podcast" linking to
/podcasts/new
Regular Users
For users with podcast memberships:
- "My Episodes" appears under Account Settings (headphones icon)
- Positioned alongside Account Settings and Notifications
- Not prominently featured since podcast management is their primary workflow
Sequence: Complete Guest Lifecycle
Related Documentation
- Multi-Tenancy Architecture - Multi-table permission model and account linking triggers
- SvelteKit Routing - Route groups and guest portal routes
- Booking Flow - How guests get invited in the first place
- Resend Email Service - Email templates for guest verification