diff --git a/src/app/api/rooms/[roomId]/invite/route.ts b/src/app/api/rooms/[roomId]/invite/route.ts
index 4867887a9..e8628a584 100644
--- a/src/app/api/rooms/[roomId]/invite/route.ts
+++ b/src/app/api/rooms/[roomId]/invite/route.ts
@@ -1,6 +1,13 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
-import { getRoomById, getRoomMembers, addRoomMember } from '@/lib/supabase-rooms';
+import {
+ getRoomById,
+ getRoomMembers,
+ getPendingInvite,
+ createRoomInvite,
+ getUserIdByGithubUsername,
+} from '@/lib/supabase-rooms';
+import { supabaseAdmin } from '@/lib/supabase-admin';
import { NextResponse } from 'next/server';
export async function POST(
@@ -33,9 +40,31 @@ export async function POST(
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 });
+
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 });
+
+ const existingInvite = await getPendingInvite(roomId, github_username);
+ if (existingInvite)
+ return NextResponse.json({ error: 'An invite is already pending for this user' }, { status: 409 });
+
+ await createRoomInvite(roomId, github_username, session.user.name);
+
+ // Best-effort notification — the invite still exists even if this fails
+ // (e.g. the invited user has never logged into DevTrack, so has no user row yet).
+ try {
+ const invitedUserId = await getUserIdByGithubUsername(github_username);
+ if (invitedUserId) {
+ await supabaseAdmin.from('notifications').insert({
+ user_id: invitedUserId,
+ type: 'room_invite',
+ message: `${session.user.name} invited you to join the room "${room.name}"`,
+ });
+ }
+ } catch (err) {
+ console.error('Failed to create room invite notification:', err);
+ }
+
+ return NextResponse.json({ success: true, status: 'pending' });
}
\ No newline at end of file
diff --git a/src/app/api/rooms/invites/[inviteId]/accept/route.ts b/src/app/api/rooms/invites/[inviteId]/accept/route.ts
new file mode 100644
index 000000000..b4fac2512
--- /dev/null
+++ b/src/app/api/rooms/invites/[inviteId]/accept/route.ts
@@ -0,0 +1,22 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { acceptRoomInvite } from '@/lib/supabase-rooms';
+import { NextResponse } from 'next/server';
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ inviteId: string }> }
+) {
+ const { inviteId } = await params;
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.name)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ await acceptRoomInvite(inviteId, session.user.name);
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error('Failed to accept room invite:', err);
+ return NextResponse.json({ error: 'Invite not found or already resolved' }, { status: 409 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/rooms/invites/[inviteId]/decline/route.ts b/src/app/api/rooms/invites/[inviteId]/decline/route.ts
new file mode 100644
index 000000000..b59c70152
--- /dev/null
+++ b/src/app/api/rooms/invites/[inviteId]/decline/route.ts
@@ -0,0 +1,22 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { declineRoomInvite } from '@/lib/supabase-rooms';
+import { NextResponse } from 'next/server';
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ inviteId: string }> }
+) {
+ const { inviteId } = await params;
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.name)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ await declineRoomInvite(inviteId, session.user.name);
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error('Failed to decline room invite:', err);
+ return NextResponse.json({ error: 'Invite not found or already resolved' }, { status: 409 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/rooms/invites/route.ts b/src/app/api/rooms/invites/route.ts
new file mode 100644
index 000000000..60f00dcac
--- /dev/null
+++ b/src/app/api/rooms/invites/route.ts
@@ -0,0 +1,13 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { getPendingInvitesForUser } 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 invites = await getPendingInvitesForUser(session.user.name);
+ return NextResponse.json({ invites });
+}
\ No newline at end of file
diff --git a/src/app/rooms/RoomsListClient.tsx b/src/app/rooms/RoomsListClient.tsx
index ddb39dda4..e9708a89e 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 PendingInvites from '@/components/rooms/PendingInvites';
interface Props {
initialRooms: CollaborationRoom[];
@@ -16,6 +17,7 @@ export default function RoomsListClient({ initialRooms, currentUser }: Props) {
return (
+
{/* Header */}
diff --git a/src/components/rooms/InviteModal.tsx b/src/components/rooms/InviteModal.tsx
index 53693be48..e76bedd77 100644
--- a/src/components/rooms/InviteModal.tsx
+++ b/src/components/rooms/InviteModal.tsx
@@ -12,6 +12,7 @@ export default function InviteModal({ roomId, onClose, onInvited }: Props) {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState
(null);
+ const [successMessage, setSuccessMessage] = useState(null);
async function handleInvite(e: React.FormEvent) {
e.preventDefault();
@@ -32,15 +33,17 @@ export default function InviteModal({ roomId, onClose, onInvited }: Props) {
return;
}
+ setSuccessMessage(`Invite sent to ${username.trim()}. They'll need to accept it before joining.`);
onInvited(username.trim());
- onClose();
}
return (
Invite by GitHub Username
-
+ {successMessage && (
+
{successMessage}
+ )}
diff --git a/src/components/rooms/MembersPanel.tsx b/src/components/rooms/MembersPanel.tsx
index 9e4d19db8..e578244d3 100644
--- a/src/components/rooms/MembersPanel.tsx
+++ b/src/components/rooms/MembersPanel.tsx
@@ -86,9 +86,10 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded,
setShowInvite(false)}
- onInvited={(username) => {
- onMemberAdded(username);
- setShowInvite(false);
+ onInvited={() => {
+ // Do NOT call onMemberAdded here — the invited user is not a
+ // member yet, only pending. They'll appear in the member list
+ // once they accept via /api/rooms/invites/[inviteId]/accept.
}}
/>
)}
diff --git a/src/components/rooms/PendingInvites.tsx b/src/components/rooms/PendingInvites.tsx
new file mode 100644
index 000000000..2e6d65efd
--- /dev/null
+++ b/src/components/rooms/PendingInvites.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import type { RoomInvite } from '@/types/rooms';
+
+export default function PendingInvites() {
+ const [invites, setInvites] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [respondingId, setRespondingId] = useState(null);
+
+ useEffect(() => {
+ fetch('/api/rooms/invites')
+ .then((res) => res.json())
+ .then((data) => setInvites(data.invites ?? []))
+ .finally(() => setLoading(false));
+ }, []);
+
+ async function respond(inviteId: string, action: 'accept' | 'decline') {
+ setRespondingId(inviteId);
+ try {
+ const res = await fetch(`/api/rooms/invites/${inviteId}/${action}`, { method: 'POST' });
+ if (res.ok) {
+ setInvites((prev) => prev.filter((inv) => inv.id !== inviteId));
+ } else {
+ const data = await res.json().catch(() => ({}));
+ alert((data as { error?: string }).error ?? `Failed to ${action} invite`);
+ }
+ } catch {
+ alert('Network error. Please try again.');
+ } finally {
+ setRespondingId(null);
+ }
+ }
+
+ if (loading || invites.length === 0) return null;
+
+ return (
+
+
Pending Room Invites
+ {invites.map((invite) => (
+
+
+ {invite.invited_by} invited you to join{' '}
+ {invite.room_name ?? 'a room'}
+
+
+
+
+
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/src/lib/supabase-rooms.ts b/src/lib/supabase-rooms.ts
index f4c4ff16f..ea707db12 100644
--- a/src/lib/supabase-rooms.ts
+++ b/src/lib/supabase-rooms.ts
@@ -5,6 +5,7 @@ import type {
RoomMember,
RoomMessage,
CreateRoomPayload,
+ RoomInvite,
} from "@/types/rooms";
export async function getRoomsForUser(username: string): Promise {
@@ -41,6 +42,101 @@ export async function addRoomMember(roomId: string, githubUsername: string) {
if (error) throw error;
}
+export async function getPendingInvite(roomId: string, invitedUsername: string): Promise {
+ const { data, error } = await supabaseAdmin
+ .from("room_invites")
+ .select("*")
+ .eq("room_id", roomId)
+ .eq("invited_username", invitedUsername)
+ .eq("status", "pending")
+ .maybeSingle();
+ if (error) throw error;
+ return data;
+}
+
+export async function createRoomInvite(roomId: string, invitedUsername: string, invitedBy: string): Promise {
+ // Upsert on (room_id, invited_username): if a prior invite was declined/cancelled,
+ // re-inviting should reopen it as pending rather than fail on the unique constraint.
+ const { data, error } = await supabaseAdmin
+ .from("room_invites")
+ .upsert(
+ {
+ room_id: roomId,
+ invited_username: invitedUsername,
+ invited_by: invitedBy,
+ status: "pending",
+ responded_at: null,
+ },
+ { onConflict: "room_id,invited_username" }
+ )
+ .select()
+ .single();
+ if (error) throw error;
+ return data;
+}
+
+export async function getPendingInvitesForUser(username: string): Promise {
+ const { data, error } = await supabaseAdmin
+ .from("room_invites")
+ .select(`id, room_id, invited_username, invited_by, status, created_at, responded_at, collaboration_rooms (name)`)
+ .eq("invited_username", username)
+ .eq("status", "pending")
+ .order("created_at", { ascending: false });
+ if (error) throw error;
+ return (data ?? []).map((row: any) => ({
+ ...row,
+ room_name: row.collaboration_rooms?.name ?? null,
+ }));
+}
+
+export async function getInviteById(inviteId: string): Promise {
+ const { data, error } = await supabaseAdmin
+ .from("room_invites")
+ .select("*")
+ .eq("id", inviteId)
+ .maybeSingle();
+ if (error) throw error;
+ return data;
+}
+
+export async function acceptRoomInvite(inviteId: string, username: string): Promise {
+ const invite = await getInviteById(inviteId);
+ if (!invite || invite.invited_username !== username || invite.status !== "pending") {
+ throw new Error("Invite not found or already resolved");
+ }
+ await addRoomMember(invite.room_id, username);
+ const { error } = await supabaseAdmin
+ .from("room_invites")
+ .update({ status: "accepted", responded_at: new Date().toISOString() })
+ .eq("id", inviteId);
+ if (error) throw error;
+}
+
+export async function declineRoomInvite(inviteId: string, username: string): Promise {
+ const invite = await getInviteById(inviteId);
+ if (!invite || invite.invited_username !== username || invite.status !== "pending") {
+ throw new Error("Invite not found or already resolved");
+ }
+ const { error } = await supabaseAdmin
+ .from("room_invites")
+ .update({ status: "declined", responded_at: new Date().toISOString() })
+ .eq("id", inviteId);
+ if (error) throw error;
+}
+
+export async function getUserIdByGithubUsername(username: string): Promise {
+ const { data, error } = await supabaseAdmin
+ .from("users")
+ .select("id")
+ .ilike("github_login", username)
+ .maybeSingle();
+ if (error) {
+ console.error("Error looking up user id for room invite notification:", error);
+ return null;
+ }
+ return data?.id ?? null;
+}
+
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);
diff --git a/src/types/rooms.ts b/src/types/rooms.ts
index 516987233..664b41ae7 100644
--- a/src/types/rooms.ts
+++ b/src/types/rooms.ts
@@ -46,4 +46,16 @@ export interface InvitePayload {
export interface SendMessagePayload {
room_id: string;
content: string;
-}
\ No newline at end of file
+}
+
+export interface RoomInvite {
+ id: string;
+ room_id: string;
+ invited_username: string;
+ invited_by: string;
+ status: 'pending' | 'accepted' | 'declined' | 'cancelled';
+ created_at: string;
+ responded_at: string | null;
+ // Joined fields (from API responses)
+ room_name?: string;
+}
diff --git a/supabase/migrations/20260712000000_add_room_invites.sql b/supabase/migrations/20260712000000_add_room_invites.sql
new file mode 100644
index 000000000..ff0f42eb5
--- /dev/null
+++ b/supabase/migrations/20260712000000_add_room_invites.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS room_invites (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ room_id UUID NOT NULL REFERENCES collaboration_rooms(id) ON DELETE CASCADE,
+ invited_username TEXT NOT NULL,
+ invited_by TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'cancelled')),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ responded_at TIMESTAMPTZ,
+ UNIQUE(room_id, invited_username)
+);
+
+CREATE INDEX IF NOT EXISTS room_invites_invited_username_idx
+ ON room_invites(invited_username, status);
+
+CREATE INDEX IF NOT EXISTS room_invites_room_id_idx
+ ON room_invites(room_id);
+
+ALTER TABLE room_invites ENABLE ROW LEVEL SECURITY;