IsotopeAI / docs

Guides

Getting started Configuration Supabase setup Sync & backup Backup console Community

Reference

Architecture API reference Database CLI Android APK Admin console

Help

Troubleshooting FAQ Contributing Changelog

Legal

Security Privacy Terms License

Community

Study groups, chat, presence and leaderboards. Every rule described here is enforced by row-level security inside your own database, not by the application — so it holds even for a direct API call.

Enrolment #

Community is opt-in. A row in community_enrollments carries your consent plus the settings that govern what others can see.

enrolled
Master switch. Defaults to true on account creation.
privacy
JSONB. Which fields other users may see.
quiet_hours
JSONB window during which alerts are suppressed.
day_offset_hours
Your day boundary. A user who studies past midnight can keep a streak intact.
onboarded
Whether the community walkthrough has been completed.

The row is created by a database trigger the moment your public.users row exists, so the community tab can never load against a missing enrolment record.

# am I enrolled?
curl -s -X POST "$SUPABASE_URL/rest/v1/rpc/community_is_enrolled" \
  -H "apikey: $SUPABASE_ANON_KEY" \
  -H "Authorization: Bearer $USER_JWT" \
  -H 'Content-Type: application/json' -d '{}'

Groups #

A group is a row in groups with 27 columns — identity, exam target, subject list, visibility, join policy and a denormalised member count.

Visibility and join policy #

Two independent axes. Visibility controls who can find the group; join policy controls who can enter it.

visibilityEffect
publicAppears in discovery search for everyone.
privateHidden from discovery. Reachable only by direct link or invite.
join_policyEffect
openAnyone who can see the group joins immediately.
requestCreates a row in community_join_requests for an owner to accept or decline. This is the default.
inviteEntry only by redeeming a token from group_invites.

Private groups never leak through discovery community_discover_groups returns public groups plus private groups you are already a member of. A private group cannot appear in results for a non-member, regardless of the search term.

Roles #

Two roles are in use: owner and member. Permission checks go through a single helper that takes an array, so a policy can accept several roles in one call:

create or replace function public._has_group_role(
  gid uuid, uid uuid, allowed_roles text[]
) returns boolean language sql stable security definer
set search_path = public as $$
  select exists (
    select 1 from public.group_members gm
    where gm.group_id = gid
      and gm.user_id  = uid
      and gm.role     = any(allowed_roles)
  );
$$;
Actionownermember
Read group and membersYesYes
Post chat messagesYesYes
Edit group detailsYesNo
Create invitesYesNo
Accept or decline join requestsYesNo
Remove a memberYesNo
Change a member's roleYesNo
Transfer ownershipYesNo
Delete the groupYesNo
LeaveOnly after transferring ownershipYes

Creation is atomic #

Creating a group and becoming its owner are logically one operation. If they were two writes and the second failed, you would have a group nobody could administer. Four triggers on groups close that gap:

TriggerEffect
trg_auto_add_ownerInserts the creator into group_members as owner.
trg_auto_add_super_adminAdds the platform super admin, for moderation.
trg_set_group_slugDerives a URL-safe slug from the name.
trg_sync_group_visibilityKeeps the legacy is_public flag consistent with visibility.

trg_sync_member_count on group_members maintains groups.member_count, so listing groups never needs a COUNT(*) per row.

Invites #

An invite is a token in group_invites with an optional use limit and expiry. Redemption is a three-step flow so a user can see what they are joining before committing:

  1. Create

    community_create_invite(p_type, p_target_id, p_days) — owner only.

  2. Preview

    community_preview_invite(p_token) returns the group name, member count and inviter without joining. Also exposed as get_invite_details.

  3. Redeem

    community_redeem_invite(p_token) validates expiry and use count, then adds membership and increments uses_count.

Validity is computed, not stored, so an expired invite cannot be resurrected by editing a flag:

(gi.expires_at is null or gi.expires_at > now())
and (gi.max_uses is null or gi.uses_count < gi.max_uses)

Group chat #

Messages live in group_chat_messages, published to Supabase Realtime. Reads and writes are membership-gated by RLS, so a non-member cannot fetch history even with a valid token and the correct group id.

FunctionPurpose
community_get_group_messages(p_group_id, p_limit)Recent messages, newest last. Default limit 50.
community_send_group_message(p_group_id, p_content)Posts a message as the calling user.

The chat panel is added by a serve-time patch The shipped frontend bundle has no group-chat component. The local server injects one into Community-*.js as it is served, wired to the two functions above. See Architecture.

Presence #

Presence answers "who is studying right now". The client sends a heartbeat while a session runs; user_presence holds one row per user.

-- community_heartbeat upserts on user_presence(user_id)
insert into public.user_presence (
  user_id, status, last_seen, subject_id, subject_name,
  task_id, task_title, session_started_at
) values (auth.uid(), p_state, now(), …)
on conflict (user_id) do update set
  status     = excluded.status,
  last_seen  = excluded.last_seen,
  -- a mid-session heartbeat must not reset when the session began
  session_started_at = coalesce(user_presence.session_started_at,
                                excluded.session_started_at),
  updated_at = now();

That COALESCE is the important line. Without it every heartbeat would move session_started_at forward and elapsed time would never grow.

Stale presence #

A closed tab sends no goodbye. expire_stale_presence() reconciles that:

update public.user_presence set status = 'offline'
where status != 'offline'
  and last_seen < now() - interval '2 minutes';

So a user who disappears shows as offline within two minutes, rather than appearing to study forever.

Buddies #

Buddy connections are rows in community_friends (user_id, friend_id, status, accepted_at). Requests are made by handle, not email — you never need to know someone's address to add them.

This was completely broken until 2026-08-30 The handle was written to one place and read from another: community_bootstrap_profile wrote profile_data->>'community_handle' (a JSONB key) while community_request_buddy read user_profiles.handle (a real column). The column was NULL for every user, so every request raised user_not_found, for every handle, always. Note which functions still worked: community_respond_buddy and community_remove_buddy both returned 200 — only the entry point failed, which is why the whole feature looked dead rather than partly broken. You can never create a connection for the others to act on.

Fixed in supabase/021_fix_buddy_handle_and_overview.sql: bootstrap now writes the column and the JSONB key, and request_buddy accepts either, plus users.username — so accounts that enrolled before the fix, or never enrolled at all, stay findable. Underneath both was a third defect: user_profiles had no rows at all, because the signup trigger never ran (see Triggers). A user with no user_profiles row cannot have a handle in either location.

What you see once a request is accepted

community_get_overview returns each buddy with the fields the UI renders:

FieldMeaning
minutesTodaySettled study minutes, on their day boundary from day_offset_hours — not UTC. A student studying at 01:00 local has not started a new day.
subjects[]{name, minutes, questions}, top eight by time.
tasks[]{id, title, subject, done} for today.
presence{state, subject, task} — live, and only within the last two minutes.
outgoingDirection. respond_buddy is only valid for a request sent to you, so an outgoing request must not render an Accept button.

Privacy is enforced server-side, from each buddy's own community_enrollments.privacy — not by the client choosing what to display. A client-side filter is not a privacy control: by the time the client filters, the data has already left the database. stealthMode collapses live status and subject; shareTasks, shareSubjectBreakdown, shareQuestionCounts, shareExactTime and shareCurrentSubject each gate their own field.

A withheld field returns null, not [] — the UI distinguishes them: null renders “not shared”, an empty array renders “no settled study time”. Returning [] for a withheld field would tell the reader their buddy did nothing today, which is a different and wrong statement.

FunctionPurpose
community_request_buddy(p_handle)Sends a request to the user with that handle.
community_respond_buddy(p_connection_id, p_accept)Accepts or declines.
community_remove_buddy(p_other_user, p_block)Removes, and optionally blocks.

buddy_invites is not dead — do not drop it An earlier version of this page said that table was an orphan referenced by nothing and scheduled for removal. That was wrong. Three functions depend on it, verified against the live database: community_create_invite, community_preview_invite and community_redeem_invite. It is the pending-invite store; community_friends is the accepted-buddy store. Dropping it breaks invite links.

What is true is that it has RLS enabled with zero policies, which is fail-closed. That is why buddy invite links previewed as “invalid” for perfectly valid tokens: create and redeem are SECURITY DEFINER and bypass RLS, but preview ran with invoker rights and could read nothing. Fixed in supabase/020_fix_community_preview_invite_rls.sql by making it SECURITY DEFINER too — not by adding a policy, since a preview is deliberately pre-authorisation and any policy permissive enough to allow it would mean “anyone may read any invite row”.

Start alerts and quiet hours #

You can ask to be notified when a specific buddy or group starts studying. community_start_alerts stores the subscription together with a quiet-hours window and a timezone offset, so a 3 a.m. notification is suppressed rather than delivered.

FunctionPurpose
community_set_start_alert(…)Creates or updates an alert with its quiet window.
community_get_start_alert(p_target_type, p_target_id)Reads the current setting.
community_sync_quiet_hours(…)Applies one quiet window across every alert.
community_register_device_token(p_token)Registers a push token in community_device_tokens.

Leaderboards #

Two scopes. Group leaderboards run in Postgres; the global leaderboard is assembled by the local server.

ScopePath
Groupget_group_leaderboard(p_group_id, p_limit) — ranks members by points.
GlobalPOST /__leaderboard on the local server, using the caller's own JWT.
Group analyticsget_group_analytics_from_snapshots(p_group_id, p_days) — daily totals and active members.

Empty leaderboard A leaderboard must read other users' stats, which requires a public SELECT policy on user_stats_summary and daily_user_stats. An older stats_own policy used FOR ALL, which blocked that read. Run leaderboard-rls-fix.sql if your leaderboard is empty.

Moderation #

community_submit_report(p_target_type, p_target_id, p_reason) writes to community_reports. Targets can be a group, a message or a user. Reports are readable only by the reporter and by service-role callers, so one user cannot enumerate another's reports.

trg_auto_add_super_admin also places the platform super admin in every new group, which is what makes moderation possible without a privileged back door into private groups.

How the rules are enforced #

Group content policies never query group_members directly, because a policy on that table querying itself recurses and Postgres aborts. Every check goes through a SECURITY DEFINER helper:

-- membership-gated read on group content
using ( public._is_group_member(group_id, (select auth.uid())) )

-- owner-only write
using ( public._has_group_role(group_id, (select auth.uid()), array['owner']) )

(select auth.uid()) rather than a bare call is deliberate: the subquery form is evaluated once per statement instead of once per row.