Skip to content

Collaboration Database Schema

This document details the PostgreSQL schema supporting collaborative show notes in show.fm.

Schema Overview

Enum Types

show_note_section_type

Determines visibility and edit permissions for sections.

sql
CREATE TYPE show_note_section_type AS ENUM (
  'shared',        -- Everyone can view and edit
  'host_private',  -- Host/Producer/Co-host only (hidden from guests)
  'guest'          -- Owner guest can edit, host can edit, others view only
);

show_notes_template_section

Identifies which section a notification_templates row of type show_notes is intended for. Mirrors show_note_section_type but is a distinct type so future template-only values can diverge.

sql
CREATE TYPE show_notes_template_section AS ENUM (
  'shared',
  'host_private',
  'guest'
);

show_notes_template_mode

Per-section override mode used by booking-link columns (and in-memory by the per-episode override path).

sql
CREATE TYPE show_notes_template_mode AS ENUM (
  'inherit',   -- Fall through to the podcast default
  'template',  -- Use the paired _template_id
  'blank'      -- Force a blank section, ignoring the podcast default
);

episode_message_type

Classifies messages in the episode chat.

sql
CREATE TYPE episode_message_type AS ENUM (
  'chat',          -- Regular chat message
  'activity',      -- System activity (joined, left, edited section)
  'note'           -- Sticky note/reminder
);

Tables

show_notes

Parent document for collaborative show notes. One per episode.

ColumnTypeDescription
idUUIDPrimary key
episode_idUUIDForeign key to episodes (unique)
created_from_template_idUUIDOptional template reference (the shared-section template at creation, kept as the most representative source)
created_from_guest_template_idUUIDSnapshot of the resolved guest-section template at creation. Used by createGuestSection so guests added later render with the same template the original guests received. Validated by trigger to point at a target_section='guest' template owned by the same podcast
versionINTIncremented on any section change
last_edited_byUUIDUser who last edited
last_edited_atTIMESTAMPTZWhen last edited

Indexes:

  • idx_show_notes_episode on episode_id
  • idx_show_notes_template on created_from_template_id (partial)

See Show Notes Auto-Create & Per-Section Templates for how these columns are populated and consumed.

show_note_sections

Individual sections within a show notes document.

ColumnTypeDescription
idUUIDPrimary key
show_notes_idUUIDForeign key to show_notes
section_typeENUMshared, host_private, or guest
titleTEXTSection title
contentJSONBTipTap JSON document
owner_guest_idUUIDFor guest sections only
display_orderINTOrdering within document
is_collapsedBOOLEANUI state
last_edited_byUUIDUser who last edited
last_edited_by_nameTEXTSnapshot of editor name

Content Format (TipTap JSON):

json
{
	"type": "doc",
	"content": [
		{
			"type": "paragraph",
			"content": [{ "type": "text", "text": "Sample content" }]
		}
	]
}

Constraint:

sql
CONSTRAINT valid_guest_section CHECK (
  (section_type = 'guest' AND owner_guest_id IS NOT NULL) OR
  (section_type != 'guest' AND owner_guest_id IS NULL)
)

episode_messages

Real-time chat and activity feed for episode collaboration. For comprehensive chat documentation including reactions, mentions, and guest RPCs, see the Chat Database Schema.

ColumnTypeDescription
idUUIDPrimary key
episode_idUUIDForeign key to episodes
user_idUUIDSender (for team members)
guest_idUUIDSender (for guests)
message_typeENUMchat, activity, or note
sender_nameTEXTSnapshot of sender name
sender_avatar_urlTEXTOptional avatar
contentTEXTMessage content
activity_dataJSONBFor activity messages
mentionsJSONBArray of MentionData objects (see Chat Schema)
is_pinnedBOOLEANPinned messages stay visible
is_systemBOOLEANSystem-generated messages

Activity Data Example:

json
{
	"action": "edited_section",
	"section_id": "uuid",
	"section_title": "Shared Notes"
}

show_note_presence

Tracks who is currently viewing/editing show notes.

ColumnTypeDescription
idUUIDPrimary key
show_notes_idUUIDForeign key to show_notes
user_idUUIDTeam member (or guest_id)
guest_idUUIDGuest user (or user_id)
nameTEXTDisplay name
colorTEXTCursor color (hex)
current_section_idUUIDActive section
cursor_positionJSONB{ "from": 0, "to": 0 }
last_seen_atTIMESTAMPTZHeartbeat timestamp

Unique Constraints:

  • One presence record per user per document
  • One presence record per guest per document

RLS Policies

Show Notes Access

sql
-- Team members can view show notes for their podcasts
CREATE POLICY "Team can view show notes"
ON show_notes FOR SELECT
USING (is_episode_team_member(episode_id));

-- Active guests can view show notes for their episodes
CREATE POLICY "Guests can view assigned show notes"
ON show_notes FOR SELECT
USING (is_active_episode_guest(episode_id));

Section Visibility (Critical)

Guests have restricted visibility - they cannot see host_private sections or other guests' sections.

sql
-- Guests can view shared sections and their own guest section
CREATE POLICY "Guests can view appropriate sections"
ON show_note_sections FOR SELECT
USING (
  EXISTS (
    SELECT 1 FROM show_notes sn
    WHERE sn.id = show_notes_id
    AND is_active_episode_guest(sn.episode_id)
  )
  AND (
    section_type = 'shared' OR
    (section_type = 'guest' AND owner_guest_id = get_my_guest_id(
      (SELECT episode_id FROM show_notes WHERE id = show_notes_id)
    ))
  )
);

Edit Permissions

Section TypeTeam MembersGuest (Owner)Guest (Other)
sharedEditEditView
host_privateEditHiddenHidden
guestEditEdit (own only)Hidden

Helper Functions

is_episode_team_member(episode_id UUID)

Checks if the current user is a team member for an episode's podcast.

sql
CREATE OR REPLACE FUNCTION is_episode_team_member(episode_id_param UUID)
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM episodes e
    JOIN podcast_members pm ON pm.podcast_id = e.podcast_id
    WHERE e.id = episode_id_param
    AND pm.user_id = auth.uid()
  )
$$ LANGUAGE sql SECURITY DEFINER STABLE;

is_active_episode_guest(episode_id UUID)

Checks if the current user is an active guest for an episode.

sql
CREATE OR REPLACE FUNCTION is_active_episode_guest(episode_id_param UUID)
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM episode_guests
    WHERE episode_id = episode_id_param
    AND (user_id = auth.uid() OR email = auth.email())
    AND status = 'active'
  )
$$ LANGUAGE sql SECURITY DEFINER STABLE;

get_my_guest_id(episode_id UUID)

Returns the guest ID for the current user on an episode.

sql
CREATE OR REPLACE FUNCTION get_my_guest_id(episode_id_param UUID)
RETURNS UUID AS $$
  SELECT id FROM episode_guests
  WHERE episode_id = episode_id_param
  AND (user_id = auth.uid() OR email = auth.email())
  AND status = 'active'
  LIMIT 1
$$ LANGUAGE sql SECURITY DEFINER STABLE;

Show Notes Template Plumbing

Per-section show-notes templates ride on existing tables (notification_templates, podcasts, booking_links) rather than a dedicated table. Three pieces of schema connect them:

notification_templates.target_section

Templates of template_type='show_notes' carry a target_section so each template is scoped to exactly one of the three section kinds:

sql
ALTER TABLE notification_templates
  ADD COLUMN target_section show_notes_template_section NULL;

ALTER TABLE notification_templates
  ADD CONSTRAINT notification_templates_target_section_consistency CHECK (
    (template_type = 'show_notes') = (target_section IS NOT NULL)
  );

CREATE INDEX idx_notification_templates_section
  ON notification_templates (podcast_id, target_section)
  WHERE template_type = 'show_notes';

The CHECK constraint enforces "required iff template_type='show_notes'". The API additionally rejects mutating target_section after creation, since dependent FKs (below) rely on it staying stable.

podcasts.default_<section>_template_id

Three nullable columns on podcasts hold the host's per-section defaults:

ColumnSection
default_shared_template_idshared
default_host_private_template_idhost_private
default_guest_template_idguest

Each REFERENCES notification_templates(id) ON DELETE SET NULL and is validated by trigger to point at a template with the matching target_section and the same podcast_id.

Six columns on booking_links provide per-section overrides:

ColumnTypeDefault
show_notes_shared_modeshow_notes_template_mode'inherit'
show_notes_shared_template_idUUID NULLNULL
show_notes_host_private_modeshow_notes_template_mode'inherit'
show_notes_host_private_template_idUUID NULLNULL
show_notes_guest_modeshow_notes_template_mode'inherit'
show_notes_guest_template_idUUID NULLNULL

Per-pair CHECK constraints enforce that _template_id IS NOT NULL iff _mode = 'template':

sql
CONSTRAINT booking_links_show_notes_shared_consistency CHECK (
  (show_notes_shared_mode = 'template') = (show_notes_shared_template_id IS NOT NULL)
)
-- (parallel constraints for host_private and guest)

The booking-links API mirrors these constraints with a Zod superRefine so client errors return 400 from validation rather than 500 from a constraint violation.

Triggers

Show Notes Template Section Match

enforce_show_notes_template_section_match() validates every default_*_template_id on podcasts and every show_notes_*_template_id on booking_links points to a notification_templates row whose target_section matches the column and whose podcast_id matches the row's owning podcast. A plain FK can guarantee existence but cannot validate either invariant.

sql
CREATE OR REPLACE FUNCTION enforce_show_notes_template_section_match()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER  -- required: trigger reads RLS-protected notification_templates
SET search_path = public
AS $$
  -- … RAISE EXCEPTION 'must reference a template with target_section=…'
  -- … RAISE EXCEPTION 'must reference a template owned by this podcast'
$$;

CREATE TRIGGER enforce_podcast_show_notes_template_sections
  BEFORE INSERT OR UPDATE OF
    default_shared_template_id,
    default_host_private_template_id,
    default_guest_template_id
  ON podcasts
  FOR EACH ROW EXECUTE FUNCTION enforce_show_notes_template_section_match();

CREATE TRIGGER enforce_booking_link_show_notes_template_sections
  BEFORE INSERT OR UPDATE OF
    show_notes_shared_template_id,
    show_notes_host_private_template_id,
    show_notes_guest_template_id
  ON booking_links
  FOR EACH ROW EXECUTE FUNCTION enforce_show_notes_template_section_match();

SECURITY DEFINER is required because the trigger reads notification_templates, which has RLS. We bypass RLS so validation works regardless of the calling user's read access. See the Supabase rules for the broader recursion-prevention pattern.

Show Notes Guest Template Snapshot Match

enforce_show_notes_guest_template_match() validates show_notes.created_from_guest_template_id on insert/update: it must reference a template with target_section='guest' belonging to the same podcast as the linked episode (resolved via episodes.podcast_id).

sql
CREATE TRIGGER enforce_show_notes_guest_template
  BEFORE INSERT OR UPDATE OF created_from_guest_template_id, episode_id
  ON show_notes
  FOR EACH ROW EXECUTE FUNCTION enforce_show_notes_guest_template_match();

Version Tracking

Automatically increments show_notes.version when any section changes.

sql
CREATE TRIGGER trigger_update_show_notes_on_section_change
  AFTER INSERT OR UPDATE OR DELETE ON show_note_sections
  FOR EACH ROW
  EXECUTE FUNCTION update_show_notes_on_section_change();

Edit Timestamp

Automatically updates last_edited_at when section content changes.

sql
CREATE TRIGGER trigger_section_edited_at
  BEFORE UPDATE OF content ON show_note_sections
  FOR EACH ROW
  EXECUTE FUNCTION update_section_edited_at();

Presence Cleanup

Function to clean up stale presence records (called periodically or on demand).

sql
CREATE OR REPLACE FUNCTION cleanup_stale_presence()
RETURNS void AS $$
BEGIN
  DELETE FROM show_note_presence
  WHERE last_seen_at < NOW() - INTERVAL '5 minutes';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Realtime Subscriptions

These tables are enabled for Supabase Realtime:

sql
ALTER PUBLICATION supabase_realtime ADD TABLE show_note_sections;
ALTER PUBLICATION supabase_realtime ADD TABLE episode_messages;
ALTER PUBLICATION supabase_realtime ADD TABLE show_note_presence;

Migration Files

The schema was applied across multiple incremental migrations:

MigrationPurpose
20260119185222Core tables (show_notes, show_note_sections)
20260119185239Episode messages table
20260119185248Presence tracking table
20260119185302Helper functions
20260119185314-40RLS policies
20260119185355Triggers
20260119185406Realtime subscriptions
20260119190352Show notes template type
20260427101943Show notes auto-create: per-section enums, notification_templates.target_section, podcast defaults, booking-link overrides, and the enforce_show_notes_template_section_match trigger
20260427160743show_notes.created_from_guest_template_id snapshot column and enforce_show_notes_guest_template_match trigger

Internal documentation - Not for public distribution