Skip to content

Guest Network Invitation Flow

This guide documents the complete lifecycle of a Guest Network invitation in show.fm, from profile discovery through invitation to booking.

Key Source Files:

  • API: src/api/routes/guest-network/index.ts
  • Types: src/lib/types/profile.ts
  • UI (Browse): src/routes/(app)/guest-network/+page.svelte
  • UI (Profile): src/routes/(app)/guest-network/[profileId]/+page.svelte
  • UI (Invitations): src/routes/(app)/guest-network/invitations/+page.svelte
  • Email (Invite): src/lib/email/templates/network-invitation.ts
  • Email (Response): src/lib/email/templates/network-invitation-response.ts
  • Migrations: supabase/migrations/20260127000005_epic6d_guest_network.sql, 20260210120000_network_invitations_booking_link.sql

Flow Overview

Step 1: Host Discovers Guests

The host navigates to /guest-network to browse discoverable guest profiles.

The search page calls the search_guest_network SECURITY DEFINER RPC which:

  1. Filters to profiles where is_discoverable = true and display_name IS NOT NULL
  2. Applies full-text search (name weighted A, bio weighted B) if a query is provided
  3. Truncates bios to 200 characters
  4. Applies privacy settings at the database layer (hiding social links based on per-profile settings)
  5. Counts appearances from published episodes
  6. Returns results ordered by: search rank DESC, appearance count DESC, name ASC

Rate limit: 60 requests per minute per user (or per IP for anonymous).

Profile Cards

Results are displayed as GuestCard components showing avatar, display name, truncated bio, and appearance count. The UI supports infinite scroll with client-side pagination.

The card also carries the rating aggregate as a third identity line (★★★★☆ 4.33 · 3+ shows) when search_guest_network returns one. That line is conditional and has no empty state, which is the one place the card's "bands never collapse" rule is deliberately not applied: the database returns a mean only behind the guest's own show_ratings opt-in and only once three different podcasts have rated them, so an absent mean covers "has not opted in", "not rated yet" and "not rated widely enough" at the same time. Printing any of those would turn a private choice or a thin history into a statement about the person.

Card heights survive it. A three-line identity stack still fits beside the 48px avatar, so a rated card is the same height as an unrated one, and the links band sits after a grow spacer so the divider stays pinned to the bottom of whatever height the grid's stretch hands each card.

Step 2: Host Views Guest Profile

Clicking a profile navigates to /guest-network/[profileId].

The page server loads:

  1. Profile data via get_public_profile() RPC — enforces discoverability at the DB layer
  2. Appearances via get_guest_appearances() RPC — only published episodes where the guest appeared and the guest is discoverable. Each row carries listen_episode_slug, which is the RPC's verdict on whether listen. would actually serve that episode; the page links the title only when it is non-null, and never infers "published, therefore public" (see the RPC reference for the seven gates it mirrors)
  3. Invitable podcasts: podcasts where the host is a member, admin or owner (#293 moved invite down to Co-host), excluding those with existing pending invitations to this guest
  4. Booking links — active booking links for each invitable podcast
  5. Pending invitations: { podcastId, sentAt } per pending row, so the page can name the date each one went out. pendingInvitePodcastIds is derived from the same list and still drives the podcast filter

An empty profile result is a page state, not a 404

get_public_profile() returns zero rows both for a profile that never existed and for one whose owner has switched discoverability off. The load returns profile: null (with the rest of the payload zeroed) rather than throwing error(404), so the page can render the designed Profile not found state telling the host their own guest record and the episodes are untouched. Nothing extra is disclosed: both causes produce the same answer either way.

consentSummary is own-profile only

The load returns the guest's raw visibility switches as consentSummary only when profileId === user.id, and null for everyone else. The masking below is what a third party is entitled to; handing them the switch positions as well would turn a sparse profile into a read-out of that person's privacy choices. Guarded by src/routes/(app)/guest-network/[profileId]/__tests__/page.server.test.ts, which asserts null for another host even when every switch is on.

Privacy Enforcement (Defense in Depth)

Privacy settings are enforced at three layers:

LayerMechanismWhere
DatabaseCASE expressions in RPCssearch_guest_network(), get_public_profile()
APIapplyPrivacySettings() helperRoute handler
UIConditional renderingProfile page component

The ProfilePrivacySettings interface (src/lib/types/profile.ts) controls six fields:

typescript
interface ProfilePrivacySettings {
	show_website: boolean; // Website URL visibility
	show_twitter: boolean; // Twitter/X handle visibility
	show_linkedin: boolean; // LinkedIn URL visibility
	hide_email_until_accepted: boolean; // Email hidden until invitation accepted
	show_endorsements: boolean; // Default TRUE: per-item approval is the consent, this is the kill-switch
	show_ratings: boolean; // Default FALSE: being publicly scored is its own opt-in
}

The last two are enforced in SQL inside the reputation read RPCs, not in the page load. See Guest reputation.

Step 3: Host Sends Invitation

From the profile page, the host selects a podcast and booking link, optionally adds a personal message, and submits.

POST /api/guest-network/profiles/:profileId/invite
{
  "podcastId": "uuid",
  "bookingLinkId": "uuid",
  "message": "Loved your recent talk on AI!"
}

Validation Chain

The API performs these checks in order:

  1. Auth: requireAuth() — user must be authenticated
  2. Body validation: Zod schema validates podcastId (UUID), bookingLinkId (UUID), message (max 1000 chars, optional)
  3. Role check: requirePodcastRole('member') — since #293 any team member may send one
  4. Profile exists: get_public_profile() RPC confirms target is discoverable
  5. Self-invite check: Cannot invite yourself
  6. Booking link validation: Link must exist, belong to the specified podcast, and be active (is_active = true)
  7. Live-invitation check: no invitation from this podcast to this guest that is still LIVE. A pending row past its expires_at does not count: it is stamped expired first, and the new invitation goes ahead. Without that step a lapsed invitation blocked re-invitation permanently, because the only thing that ever stamped a row expired was the invitee answering it — which, by definition, was not going to happen. The Expired section tells the host to send a fresh invitation, and that was impossible.

Invitation Creation

On success:

  1. Inserts into network_invitations with status: 'pending' and booking_link_id
  2. Default expires_at is 14 days from creation (set by database default)
  3. Fetches podcast details (title, slug, cover image) for the response and email

Booking URL Construction

The booking URL is built from podcast and booking link slugs:

{PUBLIC_BOOK_URL}/{podcastSlug}/{bookingLinkSlug}

Example: https://book.podcasterplus.com/the-tech-show/guest-interview

The base URL comes from the PUBLIC_BOOK_URL environment variable (defaults to https://book.podcasterplus.com).

Invitation Email

If Resend credentials are configured, an email is sent to the guest (non-blocking):

Template: sendNetworkInvitationEmail() from src/lib/email/templates/network-invitation.ts

FieldSource
inviterNameHost's user_profiles.display_name
inviterEmailHost's user_profiles.email
recipientNameGuest's display_name from get_public_profile()
recipientEmailGuest's user_profiles.email (fetched via admin client)
podcastTitleFrom podcasts table
bookingLinkNameFrom booking_links table
bookingUrlConstructed URL
customMessageHost's optional message
expiresAtInvitation expiry date

The email includes a "Book a Time" CTA button linking to the booking URL, and sets Reply-To to the inviter's email.

Non-blocking email

The email is sent via a dynamic import('$lib/email') promise chain with .catch(). If the email fails, the API response still succeeds. The invitation record is always created regardless of email delivery.

Step 4: Guest Views Invitations

Received and Sent are two of the Guest Network's three tabs: /guest-network/invitations and /guest-network/invitations/sent. Both are SvelteKit server loads over locals.supabase, not API calls — the endpoints below still exist and serve the Bookings hub.

/guest-network/invitations must keep resolving to Received: src/lib/notifications/integrations.ts puts that URL in every network.response notification, so it is live in people's inboxes.

Status is DERIVED, never read straight from the row

The stored status is not the truth

Nothing sweeps network_invitations. The only transition out of pending on expiry is lazy — inside POST /invitations/:id/respond, and now also inside the invite endpoint's pre-insert check — so a lapsed invitation sits in the table as pending indefinitely.

Read it raw and you get issue #422: a "Pending" badge and working-looking Accept and Decline buttons that the API answers with 400 Invitation has expired. At the time of the fix, three of the four invitation rows in production were in exactly that state.

Everything on these screens goes through $lib/components/guest-network/invitation-status, whose hasLapsed is deliberately the same expression the endpoint uses (new Date(expires_at) < new Date()), including its behaviour on an unparseable date. The button and the endpoint can then never disagree about one invitation.

There is no sweeper on purpose. A cron that only rewrote pending to expired would change no user-visible behaviour while adding a worker, a schedule and a failure mode. If expiry ever needs to be an EVENT — a "your invitation lapsed" notification — that is when one earns its keep.

Received Tab

Loaded by guest-network/(network)/invitations/+page.server.ts, which selects expires_at and response_message (neither was read before) and embeds podcasts and booking_links without !inner.

!inner deleted invitations from the screen

An invitee can read a podcast only through the "Public can view podcasts for booking" policy, which requires the show to still have an ACTIVE booking link. A host pausing their last booking link therefore made every invitation they had sent VANISH from the invitee's list, because an inner join drops the row rather than the embed. With a plain embed the invitation lists with the designed "Unknown podcast" copy and can still be answered, which the respond endpoint has always allowed.

Display statusWhat the row offers
pendingAccept / Decline, plus an amber pill inside three days
accepted"Book a time" to the booking URL, and the link's name
declinedThe reason the guest gave, under "You replied"
expiredNo actions, and a line saying who to ask for a new one

The decline reason and any failure live on the ROW, not in a page-level banner: a banner above the tabs cannot say which of four invitations failed.

Sent Tab

Loaded by guest-network/(network)/invitations/sent/+page.server.ts, scoped to the podcast the sidebar is currently on (resolved by $lib/server/current-podcast, which reads the same pp_last_podcast cookie the (app) layout writes).

Podcast-scoped, not inviter-scoped. The old page filtered inviter_user_id = user.id, so you saw only what you had personally sent across every show. Since #293 any team member can send an invitation, so "has anyone already asked this person?" is a question about the show. Each row names its sender ("by you", "by Sam Kim"). This needs no new permission: the "Podcast members can view network invitations" policy has always allowed the read.

GET /api/guest-network/invitations/sent and /invitations/podcast are unchanged and still serve the Bookings hub.

Step 5: Guest Responds to Invitation

POST /api/guest-network/invitations/:invitationId/respond
{
  "accept": true,
  "response_message": "Looking forward to it!"
}

Validation

  1. Invitation must exist
  2. Caller must be the invitee (invitee_user_id = user.id)
  3. Invitation must be in pending status
  4. Invitation must not be expired (if expired, auto-marked as expired)

Response Processing

  1. Updates status to accepted or declined
  2. Sets responded_at to current timestamp
  3. Stores optional response_message
  4. Sends response email to the inviter (non-blocking)

Response Email

Template: sendNetworkInvitationResponseEmail() from src/lib/email/templates/network-invitation-response.ts

FieldSource
recipientEmailInviter's email (via admin client)
recipientNameInviter's display name
inviteeNameGuest's display name
podcastTitlePodcast title
acceptedtrue or false
responseMessageGuest's optional response
bookingUrlIncluded only if accepted and booking link exists

The email uses a green header for acceptance and red for decline.

Step 6: Guest Books a Time

After accepting, the guest navigates to the booking URL (shown in the invitations page and in the email). This enters the standard booking flow documented in Booking Flow.

Error Scenarios

ScenarioHTTPUser Experience
Profile not discoverable404"Profile not found"
Self-invite attempt400"Cannot invite yourself"
Booking link wrong podcast400"Booking link does not belong to the selected podcast"
Booking link inactive400"Booking link is not active"
Live invite already open400"An invitation is already pending for this user"
Previous invite lapsed200Stamped expired, kept in the record, new one sent
Invitation not found404"Invitation not found"
Not the invitee403"Not authorized to respond to this invitation"
Already responded400"Invitation is no longer pending"
Invitation expired400"Invitation has expired" (auto-marks as expired)
Email send fails200API succeeds; no email delivered
Resend not configured200API succeeds; email step skipped entirely

Database Migrations

MigrationPurpose
20260127000005_epic6d_guest_network.sqlCore schema: privacy settings, search vector, network_invitations table, RPCs
20260202100000_restrict_user_profiles_public_access.sqlRestricts public access, creates public_guest_profiles view
20260202103000_enforce_discoverability_in_guest_network_rpcs.sqlHardens RPCs with is_discoverable enforcement, adds search_path
20260202104000_drop_deprecated_guest_profile_rpc.sqlRemoves deprecated get_guest_profile_public()
20260210120000_network_invitations_booking_link.sqlAdds booking_link_id to network_invitations

Internal documentation - Not for public distribution