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.
true on account creation.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.
| visibility | Effect |
|---|---|
public | Appears in discovery search for everyone. |
private | Hidden from discovery. Reachable only by direct link or invite. |
| join_policy | Effect |
|---|---|
open | Anyone who can see the group joins immediately. |
request | Creates a row in community_join_requests for an owner to accept or decline. This is the default. |
invite | Entry 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)
);
$$;
| Action | owner | member |
|---|---|---|
| Read group and members | Yes | Yes |
| Post chat messages | Yes | Yes |
| Edit group details | Yes | No |
| Create invites | Yes | No |
| Accept or decline join requests | Yes | No |
| Remove a member | Yes | No |
| Change a member's role | Yes | No |
| Transfer ownership | Yes | No |
| Delete the group | Yes | No |
| Leave | Only after transferring ownership | Yes |
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:
| Trigger | Effect |
|---|---|
trg_auto_add_owner | Inserts the creator into group_members as owner. |
trg_auto_add_super_admin | Adds the platform super admin, for moderation. |
trg_set_group_slug | Derives a URL-safe slug from the name. |
trg_sync_group_visibility | Keeps 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:
Create
community_create_invite(p_type, p_target_id, p_days)— owner only.Preview
community_preview_invite(p_token)returns the group name, member count and inviter without joining. Also exposed asget_invite_details.Redeem
community_redeem_invite(p_token)validates expiry and use count, then adds membership and incrementsuses_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.
| Function | Purpose |
|---|---|
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:
| Field | Meaning |
|---|---|
minutesToday | Settled 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. |
outgoing | Direction. 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.
| Function | Purpose |
|---|---|
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.
| Function | Purpose |
|---|---|
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.
| Scope | Path |
|---|---|
| Group | get_group_leaderboard(p_group_id, p_limit) — ranks members by points. |
| Global | POST /__leaderboard on the local server, using the caller's own JWT. |
| Group analytics | get_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.