Skip to content

Guest Network API

Guest discovery platform and invitation endpoints for connecting podcast hosts with potential guests.

Source: src/api/routes/guest-network/index.ts

Overview

The Guest Network API enables hosts to search for discoverable guest profiles, view their podcast appearances, and send invitations that link directly to a booking page. Invitations are tied to a specific booking link so the guest can schedule a recording session when they accept.

Authentication

Endpoints use a mix of public/authenticated access:

EndpointAuthMinimum Role
GET /searchoptionalAuth()None (public)
GET /profiles/:profileIdoptionalAuth()None (public)
POST /profiles/:profileId/inviterequireAuth()member (on chosen podcast, see #293)
GET /invitations/receivedrequireAuth()None (own invitations)
POST /invitations/:invitationId/respondrequireAuth()None (own invitations)
GET /invitations/sentrequireAuth()None (filters to admin/owner podcasts)

Rate Limiting

Search and profile endpoints are rate-limited to prevent scraping:

EndpointScopeMax RequestsWindow
GET /searchapi.guest-network.search6060s
GET /profiles/:profileIdapi.guest-network.profile6060s

Rate limiting uses user_or_ip identifier mode (authenticated users get per-user limits, anonymous users get per-IP limits).

Database Schema

network_invitations table

ColumnTypeDefaultDescription
iduuidgen_random_uuid()Primary key
podcast_iduuidFK to podcasts (CASCADE delete)
inviter_user_iduuidFK to auth.users (CASCADE delete)
invitee_user_iduuidFK to auth.users (CASCADE delete)
booking_link_iduuidNULLFK to booking_links (SET NULL on delete). Nullable for legacy rows, required at API layer
statustext'pending'pending / accepted / declined / expired. CHECK constraint enforced
messagetextNULLOptional personal message from inviter
response_messagetextNULLOptional response from invitee
responded_attimestamptzNULLWhen invitee responded
expires_attimestamptzNOW() + 14 daysAuto-set 14-day expiry on creation
created_attimestamptzNOW()Creation timestamp
updated_attimestamptzNOW()Updated via trigger

Constraints:

  • unique_pending_network_invitation: a partial unique index on (podcast_id, invitee_user_id) WHERE status = 'pending' — one LIVE invitation per podcast per guest

    It used to key on status, and that broke re-invitation

    Until 20260815120000 this was UNIQUE NULLS NOT DISTINCT (podcast_id, invitee_user_id, status), which allows one row per STATUS rather than one live invitation. A podcast could therefore hold exactly one declined row per guest for all time: invite, decline, invite again, decline again, and the second response UPDATEs a row into a (podcast, invitee, 'declined') that already exists, raising 23505, which the respond endpoint surfaced as a 500. The same collision waited on a second accept and on the lazy expiry stamp.

    Declined, accepted and expired rows now accumulate freely, which is what makes "the host can invite you again for the same show" true.

  • status CHECK: must be one of pending, accepted, declined, expired

Indexes:

IndexColumnsPurpose
idx_network_invitations_invitee(invitee_user_id, status)Fast lookup of received invitations
idx_network_invitations_podcast(podcast_id, status)Fast lookup by podcast
idx_network_invitations_inviter(inviter_user_id, status)Fast lookup of sent invitations
idx_network_invitations_booking_link(booking_link_id) WHERE NOT NULLPartial index for booking link joins

The booking_link_id column was added in migration 20260210120000_network_invitations_booking_link.sql. It is nullable at the database level for backwards compatibility with existing invitations but required for all new invitations created via the API.

user_profiles columns (Guest Network)

These columns on the user_profiles table support Guest Network functionality:

ColumnTypeDefaultDescription
is_discoverablebooleanfalseProfile appears in Guest Network search
profile_privacy_settingsjsonbSee belowGranular visibility controls
search_vectortsvectorGeneratedFull-text search vector (name weight A, bio weight B)

Default privacy settings:

json
{
	"show_website": true,
	"show_twitter": true,
	"show_linkedin": true,
	"hide_email_until_accepted": true
}

A GIN index on search_vector is scoped to WHERE is_discoverable = true for efficient filtering.

RLS Policies

PolicyOperationCondition
Podcast members can viewSELECThas_podcast_role(podcast_id, 'member')
Podcast admins can createINSERThas_podcast_role(podcast_id, 'admin')
Podcast admins can updateUPDATEhas_podcast_role(podcast_id, 'admin')
Invitees can view their invitationsSELECTinvitee_user_id = auth.uid()
Invitees can respond to invitationsUPDATEinvitee_user_id = auth.uid()

Database Functions (RPCs)

Three SECURITY DEFINER functions power the Guest Network queries. All enforce is_discoverable = true at the database layer (defense in depth).

SECURITY DEFINER

These functions bypass RLS because they query across tables and must return consistent public data. Never remove SECURITY DEFINER from these functions. See supabase/CLAUDE.md for details on RLS recursion prevention.

search_guest_network(search_query, result_limit, result_offset, filter_highly_rated, filter_has_endorsements)

ParameterTypeDefaultDescription
search_queryTEXTNULLFull-text search query
result_limitINT20Max results (enforced 1–50 at API)
result_offsetINT0Pagination offset
filter_highly_ratedBOOLEANFALSEHighly-rated guests only
filter_has_endorsementsBOOLEANFALSEGuests with an approved endorsement

Returns: id, display_name, bio (truncated to 200 chars), avatar_url, social links (privacy-filtered), profile_privacy_settings, appearance_count, invited_back, highly_rated, has_endorsements, rating_mean, rating_bucket.

Ordering: Search rank DESC → appearance count DESC → display name ASC.

The rating aggregate must stay a call, not a copy

rating_mean and rating_bucket are the directory card's star line, and they come from reputation_rating_aggregate(user_id) and reputation_rating_bucket(distinct_podcasts) — the same two helpers get_guest_reputation calls for the profile. Both are computed live from network_ratings, both are gated on show_ratings AND at least 3 distinct rating podcasts, and neither is read from guest_reputation_state.mean_score even though this function already joins that table for the badge.

Copying the profile's CASE into this function instead of calling the helpers is the failure mode to watch: it works the day it is written and drifts the day either rule changes, leaving a card and a profile quoting different numbers for the same person. 20260816110000 carries a regression guard that fails if either helper name disappears from the function body, and supabase/tests/guest_network_appearances.test.sql asserts the two RPCs return the identical mean and bucket.

Source: 20260127000005_epic6d_guest_network.sql, hardened in 20260202103000, reputation fields added in 20260717100000, rating aggregate added in 20260816110000.

guest_network_counts(search_query, filter_highly_rated, filter_has_endorsements)

Directory totals, which the paged search cannot give: how many people have opted in, how many the current search and filters return, and what each filter WOULD return on its own.

Returns: total_count, match_count, highly_rated_count, has_endorsements_count.

The facet counts are scoped by the search but NOT by the other filter, so each chip answers "how many would I get if I applied this one". Scoped by both, the two chips would read 0 whenever the pair happened to be disjoint, which reads as "nobody" rather than "not both".

Counts are consent-masked, and must stay that way

Every count is computed from the same masked expressions as search_guest_network — copied verbatim, not approximated. A count derived from unmasked fields would let a number imply a person the filter itself would never surface, which the reputation charter (§7.4) does not allow. The migration carries a regression guard that fails if the show_ratings / show_endorsements / 'approved' predicates disappear from the function body.

This cannot be a client-side count: user_profiles has no "discoverable profiles are readable" SELECT policy, and guest_endorsements has no client-side policy at all.

Source: 20260815120000_guest_network_reinvite_and_counts.sql.

get_public_profile(p_user_id)

ParameterTypeDescription
p_user_idUUIDTarget user's ID

Returns: Full profile data with privacy-filtered social links, appearance count, and is_discoverable flag. Returns empty set if profile is not discoverable.

Source: Created as get_guest_profile_public in 20260127000005, renamed to get_public_profile with discoverability enforcement in 20260202103000.

get_guest_appearances(guest_user_id)

ParameterTypeDescription
guest_user_idUUIDGuest's user ID

Returns: Published episodes where the guest appeared, joining episode_guestsuser_profiles (discoverable check) → episodes (published check) → podcasts. Ordered by published_at DESC. Each row also carries listen_episode_slug.

listen_episode_slug is a verdict, not a column

It is episodes.slug when listen.podcasterplus.com/{podcastSlug}/e/{episodeSlug} would actually serve that episode, and NULL when it would 404. The profile page links on this value alone.

"Published" is not the same question. The listen route (src/routes/(listen)/[podcastSlug]/e/[episodeSlug]/+page.server.ts) serves an episode only when the show is hosting_type = 'podcasterplus', status IN ('active','paused'), is_active, and not overage-suspended, AND the episode is published, not blocked, and published_at <= now() — where a NULL published_at fails the route's .lte() filter and so must fail here too. The CASE mirrors all seven. An externally hosted back catalogue is the common NULL and always will be: a real appearance with no page of ours behind it, rendered without a link rather than dropped.

One nullable column rather than a slug plus a flag, so a caller cannot build the link without the check. supabase/tests/guest_network_appearances.test.sql exercises each gate separately, and each case also asserts the appearance row itself survives.

Source: 20260127000005_epic6d_guest_network.sql, hardened in 20260202103000, listen_episode_slug added in 20260816110000.

Execution grants: All three functions are granted to anon, authenticated, and service_role. Public access is revoked.

Endpoints

Search Guest Profiles

GET /api/guest-network/search

Search for discoverable guest profiles in the network.

Auth: optionalAuth() (works for both anonymous and authenticated users)

Query Parameters:

ParameterTypeDefaultDescription
qstringSearch query (max 100 chars)
limitnumber20Results per page (1–50)
offsetnumber0Pagination offset

Response 200:

json
{
	"profiles": [
		{
			"id": "uuid",
			"display_name": "John Guest",
			"bio": "Tech podcaster and speaker",
			"avatar_url": "https://...",
			"website_url": "https://...",
			"twitter_handle": "johng",
			"linkedin_url": "https://...",
			"profile_privacy_settings": null,
			"appearance_count": 5,
			"invited_back": true,
			"highly_rated": false,
			"has_endorsements": true,
			"rating_mean": 4.33,
			"rating_bucket": "3+"
		}
	],
	"pagination": {
		"limit": 20,
		"offset": 0,
		"has_more": false
	},
	"counts": {
		"total": 8,
		"match": 3,
		"highly_rated": 1,
		"has_endorsements": 3
	}
}

Privacy: Social links are filtered server-side based on each profile's profile_privacy_settings. Fields like website_url, twitter_handle, and linkedin_url return null when hidden.

Implementation: Uses the search_guest_network Supabase RPC function for efficient full-text search, plus guest_network_counts in the same round trip so the directory's result line and filter chips move with a client-side search.

counts is null when the counts query fails. That is deliberate: the results are the answer, a failed count must not take the response down with it, and a confident 0 would be worse than no number at all. The page keeps whatever it already had.


Get Guest Profile

GET /api/guest-network/profiles/:profileId

Retrieve a single guest profile with their podcast appearances.

Auth: optionalAuth()

Path Parameters:

ParameterTypeDescription
profileIduuidThe user's profile ID

Response 200:

json
{
	"id": "uuid",
	"display_name": "John Guest",
	"bio": "Tech podcaster and speaker",
	"avatar_url": "https://...",
	"website_url": "https://...",
	"twitter_handle": "johng",
	"linkedin_url": "https://...",
	"profile_privacy_settings": null,
	"appearance_count": 3,
	"is_discoverable": true,
	"appearances": [
		{
			"episode_id": "uuid",
			"episode_title": "AI in Podcasting",
			"episode_published_at": "2026-01-15T00:00:00Z",
			"podcast_id": "uuid",
			"podcast_title": "The Tech Show",
			"podcast_slug": "the-tech-show",
			"podcast_cover_url": "https://...",
			"listen_episode_slug": "ai-in-podcasting"
		}
	]
}

Error 404: Profile not found or not discoverable.

Implementation: Uses get_public_profile and get_guest_appearances Supabase RPC functions. listen_episode_slug is null whenever the listen page would not serve that episode (see the RPC above); pair it with podcast_slug through getListenEpisodeUrl() and render plain text when it is null.


Send Network Invitation

POST /api/guest-network/profiles/:profileId/invite

Invite a guest from the network to appear on your podcast. The invitation is linked to a specific booking link so the guest can schedule directly.

Auth: requireAuth() + requirePodcastRole('admin') (on the selected podcast)

Path Parameters:

ParameterTypeDescription
profileIduuidThe invitee's user ID

Request Body (validated with Zod):

json
{
	"podcastId": "uuid",
	"bookingLinkId": "uuid",
	"message": "Loved your recent talk on AI!"
}
FieldTypeRequiredDescription
podcastIduuidYesThe podcast to invite the guest to
bookingLinkIduuidYesThe booking link the guest should use to schedule
messagestringNoPersonal message (max 1000 chars)

Validation Rules:

  • Target profile must exist and be discoverable
  • Cannot invite yourself
  • Booking link must belong to the specified podcast and be active (is_active = true)
  • No duplicate LIVE invitations (one live invite per podcast per guest). A previous invitation that has passed expires_at is stamped expired first and does not block the new one — nothing sweeps this table, so without that step a lapsed invitation blocked re-invitation permanently. The stamp runs with elevated rights because the table's UPDATE policy is still admin-only while sending is open to every member (#293); it is a system-owned state transition, not a user edit.

Response 200:

json
{
	"success": true,
	"invitation": {
		"id": "uuid",
		"podcast_title": "The Tech Show",
		"invitee_name": "John Guest",
		"booking_link_name": "Guest Interview",
		"booking_url": "https://book.podcasterplus.com/the-tech-show/guest-interview",
		"created_at": "2026-02-10T12:00:00Z",
		"expires_at": "2026-03-12T12:00:00Z"
	}
}

Side Effects:

  • Creates a network_invitations row with status pending
  • Sends a Network Invitation Email to the invitee (non-blocking, see Resend docs)
  • The email includes the booking URL constructed from the podcast slug and booking link slug

Errors:

CodeCondition
400Self-invite, inactive booking link, booking link doesn't match podcast, duplicate pending invite
404Profile not found

Get Received Invitations

GET /api/guest-network/invitations/received

List all invitations the authenticated user has received.

Auth: requireAuth()

Response 200:

json
{
	"invitations": [
		{
			"id": "uuid",
			"status": "pending",
			"message": "Would love to have you on the show!",
			"expires_at": "2026-03-12T12:00:00Z",
			"created_at": "2026-02-10T12:00:00Z",
			"podcast": {
				"id": "uuid",
				"title": "The Tech Show",
				"slug": "the-tech-show",
				"cover_image_url": "https://..."
			},
			"booking_link": {
				"id": "uuid",
				"name": "Guest Interview",
				"slug": "guest-interview"
			},
			"booking_url": "https://book.podcasterplus.com/the-tech-show/guest-interview",
			"inviter": {
				"id": "uuid",
				"display_name": "Jane Host",
				"avatar_url": "https://..."
			}
		}
	]
}

Each invitation includes booking_link and booking_url fields so the invitee can navigate directly to the booking page.


Respond to Invitation

POST /api/guest-network/invitations/:invitationId/respond

Accept or decline a received network invitation.

Auth: requireAuth() (must be the invitee)

Path Parameters:

ParameterTypeDescription
invitationIduuidThe invitation to respond to

Request Body:

json
{
	"accept": true,
	"response_message": "Looking forward to it!"
}
FieldTypeRequiredDescription
acceptbooleanYesAccept (true) or decline (false)
response_messagestringNoOptional response (max 500 chars)

Validation Rules:

  • Only the invitee can respond
  • Invitation must be in pending status
  • Expired invitations are auto-marked as expired and rejected

Response 200:

json
{
	"success": true,
	"status": "accepted"
}

Side Effects:

  • Updates invitation status to accepted or declined
  • Sets responded_at timestamp
  • Sends a Network Invitation Response Email to the inviter (non-blocking)
  • If accepted and a booking link exists, the response email includes the booking URL

Get Sent Invitations

GET /api/guest-network/invitations/sent

List all invitations sent from the authenticated user's podcasts (where user is admin/owner).

Auth: requireAuth()

Response 200:

json
{
	"invitations": [
		{
			"id": "uuid",
			"status": "accepted",
			"message": "Would love to have you!",
			"response_message": "Sounds great!",
			"responded_at": "2026-02-11T08:30:00Z",
			"expires_at": "2026-03-12T12:00:00Z",
			"created_at": "2026-02-10T12:00:00Z",
			"podcast": {
				"id": "uuid",
				"title": "The Tech Show",
				"slug": "the-tech-show"
			},
			"booking_link": {
				"id": "uuid",
				"name": "Guest Interview",
				"slug": "guest-interview"
			},
			"booking_url": "https://book.podcasterplus.com/the-tech-show/guest-interview",
			"invitee": {
				"id": "uuid",
				"display_name": "John Guest",
				"avatar_url": "https://..."
			}
		}
	]
}

Only includes invitations from podcasts where the user has admin or owner role.

Network invitations are tied to a specific booking link to enable a seamless guest-to-booking flow:

Booking URL Construction

Booking URLs are built from the podcast slug and booking link slug:

https://book.podcasterplus.com/{podcastSlug}/{bookingLinkSlug}

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

UI Routes

The Guest Network has two SvelteKit page routes in (app):

Profile Page

Route: /guest-network/[profileId]Source: src/routes/(app)/guest-network/[profileId]/

Displays the full guest profile with:

  • Avatar, bio, social links (privacy-filtered)
  • Podcast appearances list
  • Invite form for hosts with admin/owner podcasts:
    • Podcast selector (filtered to podcasts without pending invites to this guest)
    • Booking link selector (active links for the chosen podcast)
    • Optional personal message
    • Pending invitation indicators

The page server loads invitablePodcasts, bookingLinks (grouped by podcast ID), and pendingInvitePodcastIds to drive the form state.

Invitations Page

Route: /guest-network/invitationsSource: src/routes/(app)/guest-network/invitations/

Tabbed view of received and sent invitations:

  • Received tab: Shows podcast info, inviter profile, message, booking link details, and accept/decline buttons for pending invitations. Accepted invitations show a "Book a Time" link.
  • Sent tab: Shows invitee profile, podcast, status, response message, and booking link name.

Both tabs resolve booking URLs from the joined booking_links and podcasts data.

TypeScript Types

All types are exported from src/lib/types/profile.ts.

ProfilePrivacySettings

Controls which fields are visible in Guest Network search and profile views:

typescript
interface ProfilePrivacySettings {
	show_website: boolean;
	show_twitter: boolean;
	show_linkedin: boolean;
	hide_email_until_accepted: boolean;
}

Utilities (same module):

  • DEFAULT_PRIVACY_SETTINGS — constant with all show_* fields true and hide_email_until_accepted: true
  • mergePrivacySettings(partial) — merges partial settings with defaults (useful for forms)

GuestNetworkProfile

Returned from search results:

typescript
interface GuestNetworkProfile {
	id: string;
	display_name: string | null;
	bio: string | null;
	avatar_url: string | null;
	website_url: string | null; // null when privacy-hidden
	twitter_handle: string | null; // null when privacy-hidden
	linkedin_url: string | null; // null when privacy-hidden
	profile_privacy_settings: ProfilePrivacySettings | null;
	appearance_count: number;
}

GuestProfileFull

Extended profile with appearances (single profile endpoint):

typescript
interface GuestProfileFull extends GuestNetworkProfile {
	is_discoverable: boolean;
	appearances: GuestAppearance[];
}

GuestAppearance

typescript
interface GuestAppearance {
	episode_id: string;
	episode_title: string;
	episode_published_at: string;
	podcast_id: string;
	podcast_title: string;
	podcast_slug: string;
	podcast_cover_url: string | null;
}

NetworkInvitation

typescript
interface NetworkInvitation {
	id: string;
	podcast_id: string;
	inviter_user_id: string;
	invitee_user_id: string;
	booking_link_id: string | null;
	status: 'pending' | 'accepted' | 'declined';
	message: string | null;
	responded_at: string | null;
	created_at: string;
	podcast?: { id: string; title: string; slug: string; cover_image_url: string | null };
	inviter?: { id: string; display_name: string | null; avatar_url: string | null };
	invitee?: { id: string; display_name: string | null; avatar_url: string | null };
	booking_link?: { id: string; name: string; slug: string };
	booking_url?: string;
}

Email Notifications

Two email templates power the invitation flow:

EventTemplateRecipientDetails
Invitation sentsendNetworkInvitationEmail()GuestPodcast info, booking link name, "Book a Time" CTA, expiration
Invitation respondedsendNetworkInvitationResponseEmail()HostAccept/decline status, response message, booking URL (if accepted)

Both emails are sent non-blocking (fire-and-forget). See Resend documentation for template details.

Environment Variables

VariableRequiredDescription
PUBLIC_BOOK_URLNoBase URL for booking pages (default: https://book.podcasterplus.com)
RESEND_API_KEYNo*Resend API key for invitation emails
RESEND_FROM_EMAILNo*Sender email address
PUBLIC_SUPABASE_URLYesSupabase project URL
SUPABASE_SECRET_KEYYesService key for admin queries (invitee email lookup)

*Email sending is skipped gracefully when Resend credentials are not configured.

Testing

Tests are in src/api/routes/guest-network/__tests__/index.test.ts.

Test Coverage:

AreaTests
POST /profiles/:id/inviteEmail sent with correct data (config, recipient, booking URL), success when email fails, no email without Resend config, response includes booking URL, booking link validation (wrong podcast, inactive link)
POST /invitations/:id/respondResponse email to inviter on accept, response email on decline with message, success when email fails, no email without Resend config

Mock Strategy:

  • @supabase/supabase-jsmockCreateClient with chainable query builder (createTableChain)
  • $lib/emailmockSendNetworkInvitationEmail, mockSendNetworkInvitationResponseEmail
  • $lib/auth/permissionshasPermission and meetsMinRole always return true
  • Rate limit middleware → passthrough (no-op)
  • Uses vi.waitFor() to handle non-blocking email sends (dynamic import() in route code)

Key pattern: The route uses dynamic import('$lib/email') for non-blocking email sends. Tests mock the module at the top level, which resolves the dynamic import. vi.waitFor() is used to assert the email was called after the async fire-and-forget completes.

Internal documentation - Not for public distribution