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:
| Endpoint | Auth | Minimum Role |
|---|---|---|
GET /search | optionalAuth() | None (public) |
GET /profiles/:profileId | optionalAuth() | None (public) |
POST /profiles/:profileId/invite | requireAuth() | member (on chosen podcast, see #293) |
GET /invitations/received | requireAuth() | None (own invitations) |
POST /invitations/:invitationId/respond | requireAuth() | None (own invitations) |
GET /invitations/sent | requireAuth() | None (filters to admin/owner podcasts) |
Rate Limiting
Search and profile endpoints are rate-limited to prevent scraping:
| Endpoint | Scope | Max Requests | Window |
|---|---|---|---|
GET /search | api.guest-network.search | 60 | 60s |
GET /profiles/:profileId | api.guest-network.profile | 60 | 60s |
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
| Column | Type | Default | Description |
|---|---|---|---|
id | uuid | gen_random_uuid() | Primary key |
podcast_id | uuid | — | FK to podcasts (CASCADE delete) |
inviter_user_id | uuid | — | FK to auth.users (CASCADE delete) |
invitee_user_id | uuid | — | FK to auth.users (CASCADE delete) |
booking_link_id | uuid | NULL | FK to booking_links (SET NULL on delete). Nullable for legacy rows, required at API layer |
status | text | 'pending' | pending / accepted / declined / expired. CHECK constraint enforced |
message | text | NULL | Optional personal message from inviter |
response_message | text | NULL | Optional response from invitee |
responded_at | timestamptz | NULL | When invitee responded |
expires_at | timestamptz | NOW() + 14 days | Auto-set 14-day expiry on creation |
created_at | timestamptz | NOW() | Creation timestamp |
updated_at | timestamptz | NOW() | 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 guestIt used to key on
status, and that broke re-invitationUntil
20260815120000this wasUNIQUE 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, raising23505, 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.
statusCHECK: must be one ofpending,accepted,declined,expired
Indexes:
| Index | Columns | Purpose |
|---|---|---|
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 NULL | Partial 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:
| Column | Type | Default | Description |
|---|---|---|---|
is_discoverable | boolean | false | Profile appears in Guest Network search |
profile_privacy_settings | jsonb | See below | Granular visibility controls |
search_vector | tsvector | Generated | Full-text search vector (name weight A, bio weight B) |
Default privacy settings:
{
"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
| Policy | Operation | Condition |
|---|---|---|
| Podcast members can view | SELECT | has_podcast_role(podcast_id, 'member') |
| Podcast admins can create | INSERT | has_podcast_role(podcast_id, 'admin') |
| Podcast admins can update | UPDATE | has_podcast_role(podcast_id, 'admin') |
| Invitees can view their invitations | SELECT | invitee_user_id = auth.uid() |
| Invitees can respond to invitations | UPDATE | invitee_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)
| Parameter | Type | Default | Description |
|---|---|---|---|
search_query | TEXT | NULL | Full-text search query |
result_limit | INT | 20 | Max results (enforced 1–50 at API) |
result_offset | INT | 0 | Pagination offset |
filter_highly_rated | BOOLEAN | FALSE | Highly-rated guests only |
filter_has_endorsements | BOOLEAN | FALSE | Guests 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)
| Parameter | Type | Description |
|---|---|---|
p_user_id | UUID | Target 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)
| Parameter | Type | Description |
|---|---|---|
guest_user_id | UUID | Guest's user ID |
Returns: Published episodes where the guest appeared, joining episode_guests → user_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/searchSearch for discoverable guest profiles in the network.
Auth: optionalAuth() (works for both anonymous and authenticated users)
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | — | Search query (max 100 chars) |
limit | number | 20 | Results per page (1–50) |
offset | number | 0 | Pagination offset |
Response 200:
{
"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/:profileIdRetrieve a single guest profile with their podcast appearances.
Auth: optionalAuth()
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
profileId | uuid | The user's profile ID |
Response 200:
{
"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/inviteInvite 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:
| Parameter | Type | Description |
|---|---|---|
profileId | uuid | The invitee's user ID |
Request Body (validated with Zod):
{
"podcastId": "uuid",
"bookingLinkId": "uuid",
"message": "Loved your recent talk on AI!"
}| Field | Type | Required | Description |
|---|---|---|---|
podcastId | uuid | Yes | The podcast to invite the guest to |
bookingLinkId | uuid | Yes | The booking link the guest should use to schedule |
message | string | No | Personal 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_atis stampedexpiredfirst 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:
{
"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_invitationsrow with statuspending - 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:
| Code | Condition |
|---|---|
400 | Self-invite, inactive booking link, booking link doesn't match podcast, duplicate pending invite |
404 | Profile not found |
Get Received Invitations
GET /api/guest-network/invitations/receivedList all invitations the authenticated user has received.
Auth: requireAuth()
Response 200:
{
"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/respondAccept or decline a received network invitation.
Auth: requireAuth() (must be the invitee)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
invitationId | uuid | The invitation to respond to |
Request Body:
{
"accept": true,
"response_message": "Looking forward to it!"
}| Field | Type | Required | Description |
|---|---|---|---|
accept | boolean | Yes | Accept (true) or decline (false) |
response_message | string | No | Optional response (max 500 chars) |
Validation Rules:
- Only the invitee can respond
- Invitation must be in
pendingstatus - Expired invitations are auto-marked as
expiredand rejected
Response 200:
{
"success": true,
"status": "accepted"
}Side Effects:
- Updates invitation status to
acceptedordeclined - Sets
responded_attimestamp - 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/sentList all invitations sent from the authenticated user's podcasts (where user is admin/owner).
Auth: requireAuth()
Response 200:
{
"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.
Booking Link Integration
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:
interface ProfilePrivacySettings {
show_website: boolean;
show_twitter: boolean;
show_linkedin: boolean;
hide_email_until_accepted: boolean;
}Utilities (same module):
DEFAULT_PRIVACY_SETTINGS— constant with allshow_*fieldstrueandhide_email_until_accepted: truemergePrivacySettings(partial)— merges partial settings with defaults (useful for forms)
GuestNetworkProfile
Returned from search results:
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):
interface GuestProfileFull extends GuestNetworkProfile {
is_discoverable: boolean;
appearances: GuestAppearance[];
}GuestAppearance
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
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:
| Event | Template | Recipient | Details |
|---|---|---|---|
| Invitation sent | sendNetworkInvitationEmail() | Guest | Podcast info, booking link name, "Book a Time" CTA, expiration |
| Invitation responded | sendNetworkInvitationResponseEmail() | Host | Accept/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
| Variable | Required | Description |
|---|---|---|
PUBLIC_BOOK_URL | No | Base URL for booking pages (default: https://book.podcasterplus.com) |
RESEND_API_KEY | No* | Resend API key for invitation emails |
RESEND_FROM_EMAIL | No* | Sender email address |
PUBLIC_SUPABASE_URL | Yes | Supabase project URL |
SUPABASE_SECRET_KEY | Yes | Service 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:
| Area | Tests |
|---|---|
| POST /profiles/:id/invite | Email 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/respond | Response email to inviter on accept, response email on decline with message, success when email fails, no email without Resend config |
Mock Strategy:
@supabase/supabase-js→mockCreateClientwith chainable query builder (createTableChain)$lib/email→mockSendNetworkInvitationEmail,mockSendNetworkInvitationResponseEmail$lib/auth/permissions→hasPermissionandmeetsMinRolealways returntrue- Rate limit middleware → passthrough (no-op)
- Uses
vi.waitFor()to handle non-blocking email sends (dynamicimport()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.
Related Documentation
- Guest Network Invitation Flow - End-to-end lifecycle guide
- Resend Email Service - Email template details
- Booking Links API - Booking link management
- Bookings API - Booking lifecycle
- Team API - Team invitation pattern (similar flow)
- Team Invitation Flow - Similar invitation lifecycle