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.
Search
The search page calls the search_guest_network SECURITY DEFINER RPC which:
- Filters to profiles where
is_discoverable = trueanddisplay_name IS NOT NULL - Applies full-text search (name weighted A, bio weighted B) if a query is provided
- Truncates bios to 200 characters
- Applies privacy settings at the database layer (hiding social links based on per-profile settings)
- Counts appearances from published episodes
- 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:
- Profile data via
get_public_profile()RPC — enforces discoverability at the DB layer - Appearances via
get_guest_appearances()RPC — only published episodes where the guest appeared and the guest is discoverable. Each row carrieslisten_episode_slug, which is the RPC's verdict on whetherlisten.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) - Invitable podcasts: podcasts where the host is a
member,adminorowner(#293 moved invite down to Co-host), excluding those with existing pending invitations to this guest - Booking links — active booking links for each invitable podcast
- Pending invitations:
{ podcastId, sentAt }per pending row, so the page can name the date each one went out.pendingInvitePodcastIdsis 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:
| Layer | Mechanism | Where |
|---|---|---|
| Database | CASE expressions in RPCs | search_guest_network(), get_public_profile() |
| API | applyPrivacySettings() helper | Route handler |
| UI | Conditional rendering | Profile page component |
The ProfilePrivacySettings interface (src/lib/types/profile.ts) controls six fields:
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:
- Auth:
requireAuth()— user must be authenticated - Body validation: Zod schema validates
podcastId(UUID),bookingLinkId(UUID),message(max 1000 chars, optional) - Role check:
requirePodcastRole('member')— since #293 any team member may send one - Profile exists:
get_public_profile()RPC confirms target is discoverable - Self-invite check: Cannot invite yourself
- Booking link validation: Link must exist, belong to the specified podcast, and be active (
is_active = true) - Live-invitation check: no invitation from this podcast to this guest that is still LIVE. A
pendingrow past itsexpires_atdoes not count: it is stampedexpiredfirst, and the new invitation goes ahead. Without that step a lapsed invitation blocked re-invitation permanently, because the only thing that ever stamped a rowexpiredwas 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:
- Inserts into
network_invitationswithstatus: 'pending'andbooking_link_id - Default
expires_atis 14 days from creation (set by database default) - 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
| Field | Source |
|---|---|
inviterName | Host's user_profiles.display_name |
inviterEmail | Host's user_profiles.email |
recipientName | Guest's display_name from get_public_profile() |
recipientEmail | Guest's user_profiles.email (fetched via admin client) |
podcastTitle | From podcasts table |
bookingLinkName | From booking_links table |
bookingUrl | Constructed URL |
customMessage | Host's optional message |
expiresAt | Invitation 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 status | What the row offers |
|---|---|
pending | Accept / Decline, plus an amber pill inside three days |
accepted | "Book a time" to the booking URL, and the link's name |
declined | The reason the guest gave, under "You replied" |
expired | No 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
- Invitation must exist
- Caller must be the invitee (
invitee_user_id = user.id) - Invitation must be in
pendingstatus - Invitation must not be expired (if expired, auto-marked as
expired)
Response Processing
- Updates
statustoacceptedordeclined - Sets
responded_atto current timestamp - Stores optional
response_message - Sends response email to the inviter (non-blocking)
Response Email
Template: sendNetworkInvitationResponseEmail() from src/lib/email/templates/network-invitation-response.ts
| Field | Source |
|---|---|
recipientEmail | Inviter's email (via admin client) |
recipientName | Inviter's display name |
inviteeName | Guest's display name |
podcastTitle | Podcast title |
accepted | true or false |
responseMessage | Guest's optional response |
bookingUrl | Included 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
| Scenario | HTTP | User Experience |
|---|---|---|
| Profile not discoverable | 404 | "Profile not found" |
| Self-invite attempt | 400 | "Cannot invite yourself" |
| Booking link wrong podcast | 400 | "Booking link does not belong to the selected podcast" |
| Booking link inactive | 400 | "Booking link is not active" |
| Live invite already open | 400 | "An invitation is already pending for this user" |
| Previous invite lapsed | 200 | Stamped expired, kept in the record, new one sent |
| Invitation not found | 404 | "Invitation not found" |
| Not the invitee | 403 | "Not authorized to respond to this invitation" |
| Already responded | 400 | "Invitation is no longer pending" |
| Invitation expired | 400 | "Invitation has expired" (auto-marks as expired) |
| Email send fails | 200 | API succeeds; no email delivered |
| Resend not configured | 200 | API succeeds; email step skipped entirely |
Database Migrations
| Migration | Purpose |
|---|---|
20260127000005_epic6d_guest_network.sql | Core schema: privacy settings, search vector, network_invitations table, RPCs |
20260202100000_restrict_user_profiles_public_access.sql | Restricts public access, creates public_guest_profiles view |
20260202103000_enforce_discoverability_in_guest_network_rpcs.sql | Hardens RPCs with is_discoverable enforcement, adds search_path |
20260202104000_drop_deprecated_guest_profile_rpc.sql | Removes deprecated get_guest_profile_public() |
20260210120000_network_invitations_booking_link.sql | Adds booking_link_id to network_invitations |
Related Documentation
- Guest Network API Reference - Endpoint specifications and response shapes
- Booking Flow - What happens after the guest books a time
- Team Invitation Flow - Similar invitation pattern for team members
- Multi-Tenancy Model - Role hierarchy and permissions
- Resend Service - Email template system