From 4dcaae499600d3726f9cbf105998bbc1c4cb955f Mon Sep 17 00:00:00 2001 From: aaniya22 Date: Wed, 29 Jul 2026 04:40:47 +0530 Subject: [PATCH] fix: Invite to Room now creates a pending invitation instead of adding member directly (#3239) - Added room_invitations table (migration + schema.sql) with pending/ accepted/declined status - invite/route.ts now creates a pending invitation and notifies the invitee, instead of calling addRoomMember directly - Added GET /api/room-invitations to list a user's pending invitations - Added POST /api/room-invitations/[invitationId] to accept or decline - Added PendingInvitations component, surfaced on the rooms list page - InviteModal now shows a pending-confirmation state instead of optimistically closing - MembersPanel no longer adds the invited user to the members list on invite, since they aren't a member until they accept Signed-off-by: aaniya22 --- .../room-invitations/[invitationId]/route.ts | 44 ++++ src/app/api/room-invitations/route.ts | 13 ++ src/app/api/rooms/[roomId]/invite/route.ts | 65 ++++-- src/app/rooms/RoomsListClient.tsx | 3 + src/components/rooms/InviteModal.tsx | 100 +++++---- src/components/rooms/MembersPanel.tsx | 38 ++-- src/components/rooms/PendingInvitations.tsx | 87 ++++++++ src/lib/supabase-rooms.ts | 194 ++++++++++++++++-- src/types/rooms.ts | 12 ++ .../20260729041910_add_room_invitations.sql | 32 +++ supabase/schema.sql | 24 +++ 11 files changed, 520 insertions(+), 92 deletions(-) create mode 100644 src/app/api/room-invitations/[invitationId]/route.ts create mode 100644 src/app/api/room-invitations/route.ts create mode 100644 src/components/rooms/PendingInvitations.tsx create mode 100644 supabase/migrations/20260729041910_add_room_invitations.sql diff --git a/src/app/api/room-invitations/[invitationId]/route.ts b/src/app/api/room-invitations/[invitationId]/route.ts new file mode 100644 index 000000000..51b0e1053 --- /dev/null +++ b/src/app/api/room-invitations/[invitationId]/route.ts @@ -0,0 +1,44 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { + getRoomInvitation, + respondToRoomInvitation, +} from "@/lib/supabase-rooms"; +import { NextResponse } from "next/server"; + +export async function POST( + req: Request, + { params }: { params: Promise<{ invitationId: string }> } +) { + const { invitationId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.name) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const invitation = await getRoomInvitation(invitationId); + if (!invitation) + return NextResponse.json( + { error: "Invitation not found" }, + { status: 404 } + ); + if (invitation.github_username !== session.user.name) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + if (invitation.status !== "pending") + return NextResponse.json( + { error: "Invitation already responded to" }, + { status: 409 } + ); + + const { action } = await req.json(); + if (action !== "accept" && action !== "decline") + return NextResponse.json( + { error: 'action must be "accept" or "decline"' }, + { status: 400 } + ); + + const updated = await respondToRoomInvitation( + invitationId, + action === "accept" + ); + return NextResponse.json({ success: true, status: updated?.status }); +} diff --git a/src/app/api/room-invitations/route.ts b/src/app/api/room-invitations/route.ts new file mode 100644 index 000000000..a6990f0ae --- /dev/null +++ b/src/app/api/room-invitations/route.ts @@ -0,0 +1,13 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getPendingInvitationsForUser } from "@/lib/supabase-rooms"; +import { NextResponse } from "next/server"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.name) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const invitations = await getPendingInvitationsForUser(session.user.name); + return NextResponse.json({ invitations }); +} diff --git a/src/app/api/rooms/[roomId]/invite/route.ts b/src/app/api/rooms/[roomId]/invite/route.ts index 4867887a9..efa5298c8 100644 --- a/src/app/api/rooms/[roomId]/invite/route.ts +++ b/src/app/api/rooms/[roomId]/invite/route.ts @@ -1,7 +1,12 @@ -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; -import { getRoomById, getRoomMembers, addRoomMember } from '@/lib/supabase-rooms'; -import { NextResponse } from 'next/server'; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { + getRoomById, + getRoomMembers, + createRoomInvitation, + notifyRoomInvitation, +} from "@/lib/supabase-rooms"; +import { NextResponse } from "next/server"; export async function POST( req: Request, @@ -10,32 +15,60 @@ export async function POST( const { roomId } = await params; const session = await getServerSession(authOptions); if (!session?.user?.name) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const room = await getRoomById(roomId, session.user.name); - if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + if (!room) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!room.is_owner) - return NextResponse.json({ error: 'Only the room owner can invite' }, { status: 403 }); + return NextResponse.json( + { error: "Only the room owner can invite" }, + { status: 403 } + ); const { github_username } = await req.json(); if (!github_username?.trim()) - return NextResponse.json({ error: 'github_username required' }, { status: 400 }); + return NextResponse.json( + { error: "github_username required" }, + { status: 400 } + ); // GitHub usernames: 1-39 chars, alphanumeric + hyphens, no leading/trailing hyphen if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(github_username)) - return NextResponse.json({ error: 'Invalid GitHub username' }, { status: 400 }); + return NextResponse.json( + { error: "Invalid GitHub username" }, + { status: 400 } + ); const ghRes = await fetch(`https://api.github.com/users/${github_username}`, { headers: { - Accept: 'application/vnd.github+json', + Accept: "application/vnd.github+json", ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), }, }); if (ghRes.status === 404) - return NextResponse.json({ error: `GitHub user "${github_username}" does not exist` }, { status: 404 }); + return NextResponse.json( + { error: `GitHub user "${github_username}" does not exist` }, + { status: 404 } + ); if (!ghRes.ok) - return NextResponse.json({ error: 'Could not verify GitHub user' }, { status: 502 }); + return NextResponse.json( + { error: "Could not verify GitHub user" }, + { status: 502 } + ); const members = await getRoomMembers(roomId); if (members.some((m) => m.github_username === github_username)) - return NextResponse.json({ error: 'User is already a member' }, { status: 409 }); - await addRoomMember(roomId, github_username); - return NextResponse.json({ success: true }); -} \ No newline at end of file + return NextResponse.json( + { error: "User is already a member" }, + { status: 409 } + ); + try { + await createRoomInvitation(roomId, github_username, session.user.name); + } catch (error: any) { + if (error?.code === "23505") + return NextResponse.json( + { error: "An invitation is already pending for this user" }, + { status: 409 } + ); + throw error; + } + await notifyRoomInvitation(github_username, room.name, session.user.name); + return NextResponse.json({ success: true, status: "pending" }); +} diff --git a/src/app/rooms/RoomsListClient.tsx b/src/app/rooms/RoomsListClient.tsx index ddb39dda4..fbc62cade 100644 --- a/src/app/rooms/RoomsListClient.tsx +++ b/src/app/rooms/RoomsListClient.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import Link from 'next/link'; import type { CollaborationRoom } from '@/types/rooms'; import CreateRoomModal from '@/components/rooms/CreateRoomModal'; +import PendingInvitations from '@/components/rooms/PendingInvitations'; interface Props { initialRooms: CollaborationRoom[]; @@ -32,6 +33,8 @@ export default function RoomsListClient({ initialRooms, currentUser }: Props) { + + {/* Room cards */} {rooms.length === 0 ? (
diff --git a/src/components/rooms/InviteModal.tsx b/src/components/rooms/InviteModal.tsx index 53693be48..18f62c5c9 100644 --- a/src/components/rooms/InviteModal.tsx +++ b/src/components/rooms/InviteModal.tsx @@ -1,76 +1,88 @@ -'use client'; +"use client"; -import { useState } from 'react'; +import { useState } from "react"; interface Props { roomId: string; onClose: () => void; onInvited: (username: string) => void; } - export default function InviteModal({ roomId, onClose, onInvited }: Props) { - const [username, setUsername] = useState(''); + const [username, setUsername] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - + const [sent, setSent] = useState(false); async function handleInvite(e: React.FormEvent) { e.preventDefault(); setLoading(true); setError(null); - const res = await fetch(`/api/rooms/${roomId}/invite`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ github_username: username.trim() }), }); - const data = await res.json(); setLoading(false); - if (!res.ok) { - setError(data.error ?? 'Invite failed'); + setError(data.error ?? "Invite failed"); return; } - onInvited(username.trim()); - onClose(); + setSent(true); } return (
-

Invite by GitHub Username

- -
- setUsername(e.target.value)} - required - /> - - {error &&

{error}

} - -
- - +

+ Invite by GitHub Username +

+ {sent ? ( +
+

+ Invitation sent to {username.trim()}. + They'll join once they accept it. +

+
+ +
- + ) : ( +
+ setUsername(e.target.value)} + required + /> + {error &&

{error}

} +
+ + +
+
+ )}
); -} \ No newline at end of file +} diff --git a/src/components/rooms/MembersPanel.tsx b/src/components/rooms/MembersPanel.tsx index 9e4d19db8..fa33e3954 100644 --- a/src/components/rooms/MembersPanel.tsx +++ b/src/components/rooms/MembersPanel.tsx @@ -1,8 +1,8 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import type { RoomMember } from '@/types/rooms'; -import InviteModal from './InviteModal'; +import { useState } from "react"; +import type { RoomMember } from "@/types/rooms"; +import InviteModal from "./InviteModal"; interface Props { roomId: string; @@ -12,7 +12,13 @@ interface Props { onMemberRemoved: (username: string) => void; } -export default function MembersPanel({ roomId, members, isOwner, onMemberAdded, onMemberRemoved }: Props) { +export default function MembersPanel({ + roomId, + members, + isOwner, + onMemberAdded, + onMemberRemoved, +}: Props) { const [showInvite, setShowInvite] = useState(false); const [removingUsername, setRemovingUsername] = useState(null); @@ -22,16 +28,16 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded, try { const res = await fetch( `/api/rooms/${roomId}/members/${encodeURIComponent(username)}`, - { method: 'DELETE' } + { method: "DELETE" } ); if (res.ok) { onMemberRemoved(username); } else { const data = await res.json().catch(() => ({})); - alert((data as { error?: string }).error ?? 'Failed to remove member'); + alert((data as { error?: string }).error ?? "Failed to remove member"); } } catch { - alert('Network error. Please try again.'); + alert("Network error. Please try again."); } finally { setRemovingUsername(null); } @@ -64,18 +70,20 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded, />

{m.github_username}

- {m.role === 'owner' && ( - owner + {m.role === "owner" && ( + + owner + )}
- {isOwner && m.role !== 'owner' && ( + {isOwner && m.role !== "owner" && ( )}
@@ -86,9 +94,9 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded, setShowInvite(false)} - onInvited={(username) => { - onMemberAdded(username); - setShowInvite(false); + onInvited={() => { + // Invitation is pending, not an actual membership — don't add to + // the members list until the invitee accepts. }} /> )} diff --git a/src/components/rooms/PendingInvitations.tsx b/src/components/rooms/PendingInvitations.tsx new file mode 100644 index 000000000..0f8cc06ec --- /dev/null +++ b/src/components/rooms/PendingInvitations.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { RoomInvitation } from "@/types/rooms"; + +export default function PendingInvitations() { + const router = useRouter(); + const [invitations, setInvitations] = useState([]); + const [respondingId, setRespondingId] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + async function fetchInvitations() { + try { + const res = await fetch("/api/room-invitations"); + if (!res.ok) return; + const data = await res.json(); + if (!cancelled) setInvitations(data.invitations ?? []); + } catch { + // silent — pending invitations are non-critical to page load + } finally { + if (!cancelled) setLoading(false); + } + } + fetchInvitations(); + return () => { + cancelled = true; + }; + }, []); + + async function respond(invitationId: string, action: "accept" | "decline") { + setRespondingId(invitationId); + try { + const res = await fetch(`/api/room-invitations/${invitationId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + if (res.ok) { + setInvitations((prev) => prev.filter((inv) => inv.id !== invitationId)); + if (action === "accept") router.refresh(); + } + } finally { + setRespondingId(null); + } + } + + if (loading || invitations.length === 0) return null; + + return ( +
+

Pending Invitations

+ {invitations.map((inv) => ( +
+
+

{inv.collaboration_rooms.name}

+

+ {inv.collaboration_rooms.repo_owner}/{inv.collaboration_rooms.repo_name} · invited by{" "} + {inv.invited_by} +

+
+
+ + +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/src/lib/supabase-rooms.ts b/src/lib/supabase-rooms.ts index f4c4ff16f..2e3081320 100644 --- a/src/lib/supabase-rooms.ts +++ b/src/lib/supabase-rooms.ts @@ -1,4 +1,4 @@ -import 'server-only'; +import "server-only"; import { supabaseAdmin } from "@/lib/supabase-admin"; import type { CollaborationRoom, @@ -7,49 +7,192 @@ import type { CreateRoomPayload, } from "@/types/rooms"; -export async function getRoomsForUser(username: string): Promise { +export async function getRoomsForUser( + username: string +): Promise { const { data, error } = await supabaseAdmin .from("room_members") - .select(`role, collaboration_rooms (id, name, description, repo_owner, repo_name, created_by, created_at, updated_at)`) + .select( + `role, collaboration_rooms (id, name, description, repo_owner, repo_name, created_by, created_at, updated_at)` + ) .eq("github_username", username); if (error) throw error; - return (data ?? []).map((row: any) => ({ ...row.collaboration_rooms, is_owner: row.role === "owner" })); + return (data ?? []).map((row: any) => ({ + ...row.collaboration_rooms, + is_owner: row.role === "owner", + })); } -export async function createRoom(payload: CreateRoomPayload, creatorUsername: string): Promise { - const { data: room, error } = await supabaseAdmin.from("collaboration_rooms").insert({ ...payload, created_by: creatorUsername }).select().single(); +export async function createRoom( + payload: CreateRoomPayload, + creatorUsername: string +): Promise { + const { data: room, error } = await supabaseAdmin + .from("collaboration_rooms") + .insert({ ...payload, created_by: creatorUsername }) + .select() + .single(); if (error) throw error; - await supabaseAdmin.from("room_members").insert({ room_id: room.id, github_username: creatorUsername, role: "owner" }); + await supabaseAdmin + .from("room_members") + .insert({ + room_id: room.id, + github_username: creatorUsername, + role: "owner", + }); return room; } export async function getRoomById(roomId: string, username: string) { - const { data: membership } = await supabaseAdmin.from("room_members").select("role").eq("room_id", roomId).eq("github_username", username).single(); + const { data: membership } = await supabaseAdmin + .from("room_members") + .select("role") + .eq("room_id", roomId) + .eq("github_username", username) + .single(); if (!membership) return null; - const { data: room } = await supabaseAdmin.from("collaboration_rooms").select("*").eq("id", roomId).single(); + const { data: room } = await supabaseAdmin + .from("collaboration_rooms") + .select("*") + .eq("id", roomId) + .single(); return room ? { ...room, is_owner: membership.role === "owner" } : null; } export async function getRoomMembers(roomId: string): Promise { - const { data, error } = await supabaseAdmin.from("room_members").select("*").eq("room_id", roomId).order("joined_at", { ascending: true }); + const { data, error } = await supabaseAdmin + .from("room_members") + .select("*") + .eq("room_id", roomId) + .order("joined_at", { ascending: true }); if (error) throw error; return data ?? []; } export async function addRoomMember(roomId: string, githubUsername: string) { - const { error } = await supabaseAdmin.from("room_members").insert({ room_id: roomId, github_username: githubUsername, role: "member" }); + const { error } = await supabaseAdmin + .from("room_members") + .insert({ + room_id: roomId, + github_username: githubUsername, + role: "member", + }); if (error) throw error; } -export async function getRoomMessages(roomId: string, limit = 50, before?: string): Promise { - let query = supabaseAdmin.from("room_messages").select("*").eq("room_id", roomId).order("created_at", { ascending: false }).limit(limit); +export async function createRoomInvitation( + roomId: string, + githubUsername: string, + invitedBy: string +) { + const { data, error } = await supabaseAdmin + .from("room_invitations") + .insert({ + room_id: roomId, + github_username: githubUsername, + invited_by: invitedBy, + status: "pending", + }) + .select() + .single(); + if (error) throw error; + return data; +} + +export async function getPendingInvitationsForUser(githubUsername: string) { + const { data, error } = await supabaseAdmin + .from("room_invitations") + .select( + `id, room_id, invited_by, created_at, collaboration_rooms (id, name, repo_owner, repo_name)` + ) + .eq("github_username", githubUsername) + .eq("status", "pending") + .order("created_at", { ascending: false }); + if (error) throw error; + return data ?? []; +} + +export async function getRoomInvitation(invitationId: string) { + const { data, error } = await supabaseAdmin + .from("room_invitations") + .select("*") + .eq("id", invitationId) + .single(); + if (error) return null; + return data; +} + +export async function respondToRoomInvitation( + invitationId: string, + accept: boolean +) { + const invitation = await getRoomInvitation(invitationId); + if (!invitation || invitation.status !== "pending") return null; + + const { error: updateError } = await supabaseAdmin + .from("room_invitations") + .update({ + status: accept ? "accepted" : "declined", + responded_at: new Date().toISOString(), + }) + .eq("id", invitationId); + if (updateError) throw updateError; + + if (accept) { + await addRoomMember(invitation.room_id, invitation.github_username); + } + + return invitation; +} + +async function getUserIdByGithubLogin( + githubLogin: string +): Promise { + const { data, error } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_login", githubLogin) + .single(); + if (error) return null; + return data?.id ?? null; +} + +export async function notifyRoomInvitation( + githubUsername: string, + roomName: string, + invitedBy: string +) { + const userId = await getUserIdByGithubLogin(githubUsername); + if (!userId) return; + const { error } = await supabaseAdmin.from("notifications").insert({ + user_id: userId, + type: "room_invitation", + message: `${invitedBy} invited you to join the room "${roomName}"`, + }); + if (error) throw error; +} + +export async function getRoomMessages( + roomId: string, + limit = 50, + before?: string +): Promise { + let query = supabaseAdmin + .from("room_messages") + .select("*") + .eq("room_id", roomId) + .order("created_at", { ascending: false }) + .limit(limit); if (before) query = query.lt("created_at", before); const { data, error } = await query; if (error) throw error; return (data ?? []).reverse(); } -export async function getRoomMessagesSince(roomId: string, after: string): Promise { +export async function getRoomMessagesSince( + roomId: string, + after: string +): Promise { const { data, error } = await supabaseAdmin .from("room_messages") .select("*") @@ -60,13 +203,30 @@ export async function getRoomMessagesSince(roomId: string, after: string): Promi return data ?? []; } -export async function sendRoomMessage(roomId: string, senderUsername: string, senderAvatar: string | null, content: string): Promise { - const { data, error } = await supabaseAdmin.from("room_messages").insert({ room_id: roomId, sender_username: senderUsername, sender_avatar: senderAvatar, content }).select().single(); +export async function sendRoomMessage( + roomId: string, + senderUsername: string, + senderAvatar: string | null, + content: string +): Promise { + const { data, error } = await supabaseAdmin + .from("room_messages") + .insert({ + room_id: roomId, + sender_username: senderUsername, + sender_avatar: senderAvatar, + content, + }) + .select() + .single(); if (error) throw error; return data; } -export async function removeRoomMember(roomId: string, githubUsername: string): Promise { +export async function removeRoomMember( + roomId: string, + githubUsername: string +): Promise { const { error } = await supabaseAdmin .from("room_members") .delete() diff --git a/src/types/rooms.ts b/src/types/rooms.ts index 516987233..d415b47a6 100644 --- a/src/types/rooms.ts +++ b/src/types/rooms.ts @@ -42,6 +42,18 @@ export interface InvitePayload { room_id: string; github_username: string; } +export interface RoomInvitation { + id: string; + room_id: string; + invited_by: string; + created_at: string; + collaboration_rooms: { + id: string; + name: string; + repo_owner: string; + repo_name: string; + }; +} export interface SendMessagePayload { room_id: string; diff --git a/supabase/migrations/20260729041910_add_room_invitations.sql b/supabase/migrations/20260729041910_add_room_invitations.sql new file mode 100644 index 000000000..e6832f6cf --- /dev/null +++ b/supabase/migrations/20260729041910_add_room_invitations.sql @@ -0,0 +1,32 @@ +-- Adds a pending-invitation flow for collaboration rooms. +-- Previously, inviting a user via github_username added them to +-- room_members immediately. This table stores a pending invitation +-- that the invited user must accept before becoming a room member. + +CREATE TABLE IF NOT EXISTS room_invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID REFERENCES collaboration_rooms(id) ON DELETE CASCADE, + github_username TEXT NOT NULL, + invited_by TEXT NOT NULL, + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined')), + created_at TIMESTAMPTZ DEFAULT NOW(), + responded_at TIMESTAMPTZ, + UNIQUE(room_id, github_username, status) +); + +CREATE INDEX IF NOT EXISTS room_invitations_invitee_idx + ON room_invitations(github_username, status); + +ALTER TABLE room_invitations ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "invitation_select" ON room_invitations; +CREATE POLICY "invitation_select" ON room_invitations + FOR SELECT USING ( + github_username = current_setting('request.jwt.claims', true)::json->>'login' + OR EXISTS ( + SELECT 1 FROM room_members + WHERE room_id = room_invitations.room_id + AND github_username = current_setting('request.jwt.claims', true)::json->>'login' + AND role = 'owner' + ) + ); \ No newline at end of file diff --git a/supabase/schema.sql b/supabase/schema.sql index b857ceed5..a700f2cf5 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -260,9 +260,22 @@ CREATE TABLE IF NOT EXISTS room_messages ( content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS room_invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID REFERENCES collaboration_rooms(id) ON DELETE CASCADE, + github_username TEXT NOT NULL, + invited_by TEXT NOT NULL, + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined')), + created_at TIMESTAMPTZ DEFAULT NOW(), + responded_at TIMESTAMPTZ, + UNIQUE(room_id, github_username, status) +); +CREATE INDEX IF NOT EXISTS room_invitations_invitee_idx + ON room_invitations(github_username, status); ALTER TABLE collaboration_rooms ENABLE ROW LEVEL SECURITY; ALTER TABLE room_members ENABLE ROW LEVEL SECURITY; ALTER TABLE room_messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE room_invitations ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "room_select" ON collaboration_rooms; CREATE POLICY "room_select" ON collaboration_rooms FOR SELECT USING ( @@ -289,6 +302,17 @@ CREATE POLICY "message_insert" ON room_messages AND github_username = current_setting('request.jwt.claims', true)::json->>'login' ) ); +DROP POLICY IF EXISTS "invitation_select" ON room_invitations; +CREATE POLICY "invitation_select" ON room_invitations + FOR SELECT USING ( + github_username = current_setting('request.jwt.claims', true)::json->>'login' + OR EXISTS ( + SELECT 1 FROM room_members + WHERE room_id = room_invitations.room_id + AND github_username = current_setting('request.jwt.claims', true)::json->>'login' + AND role = 'owner' + ) + ); CREATE TABLE IF NOT EXISTS leaderboard_cache ( key text primary key, payload jsonb,