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;