Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/app/api/rooms/[roomId]/invite/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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' });
}
22 changes: 22 additions & 0 deletions src/app/api/rooms/invites/[inviteId]/accept/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
22 changes: 22 additions & 0 deletions src/app/api/rooms/invites/[inviteId]/decline/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
13 changes: 13 additions & 0 deletions src/app/api/rooms/invites/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
2 changes: 2 additions & 0 deletions src/app/rooms/RoomsListClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -16,6 +17,7 @@ export default function RoomsListClient({ initialRooms, currentUser }: Props) {

return (
<div className="max-w-3xl mx-auto">
<PendingInvites />
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
Expand Down
11 changes: 7 additions & 4 deletions src/components/rooms/InviteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);

async function handleInvite(e: React.FormEvent) {
e.preventDefault();
Expand All @@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-sm p-6">
<h2 className="text-lg font-semibold mb-4">Invite by GitHub Username</h2>

{successMessage && (
<p className="text-green-600 text-sm mb-3">{successMessage}</p>
)}
<form onSubmit={handleInvite} className="space-y-4">
<input
autoFocus
Expand All @@ -63,10 +66,10 @@ export default function InviteModal({ roomId, onClose, onInvited }: Props) {
</button>
<button
type="submit"
disabled={loading || !username.trim()}
disabled={loading || !username.trim() || !!successMessage}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Inviting…' : 'Send Invite'}
{loading ? 'Inviting…' : successMessage ? 'Sent βœ“' : 'Send Invite'}
</button>
</div>
</form>
Expand Down
7 changes: 4 additions & 3 deletions src/components/rooms/MembersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded,
<InviteModal
roomId={roomId}
onClose={() => 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.
}}
/>
)}
Expand Down
69 changes: 69 additions & 0 deletions src/components/rooms/PendingInvites.tsx
Original file line number Diff line number Diff line change
@@ -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<RoomInvite[]>([]);
const [loading, setLoading] = useState(true);
const [respondingId, setRespondingId] = useState<string | null>(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 (
<div className="mb-6 space-y-2">
<h2 className="text-sm font-semibold text-gray-500">Pending Room Invites</h2>
{invites.map((invite) => (
<div
key={invite.id}
className="flex items-center justify-between p-3 border dark:border-gray-800 rounded-xl bg-blue-50 dark:bg-blue-900/20"
>
<p className="text-sm">
<span className="font-medium">{invite.invited_by}</span> invited you to join{' '}
<span className="font-medium">{invite.room_name ?? 'a room'}</span>
</p>
<div className="flex gap-2 shrink-0 ml-4">
<button
onClick={() => respond(invite.id, 'decline')}
disabled={respondingId === invite.id}
className="text-xs px-3 py-1.5 rounded-lg border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50"
>
Decline
</button>
<button
onClick={() => respond(invite.id, 'accept')}
disabled={respondingId === invite.id}
className="text-xs px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{respondingId === invite.id ? '…' : 'Accept'}
</button>
</div>
</div>
))}
</div>
);
}
96 changes: 96 additions & 0 deletions src/lib/supabase-rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
RoomMember,
RoomMessage,
CreateRoomPayload,
RoomInvite,
} from "@/types/rooms";

export async function getRoomsForUser(username: string): Promise<CollaborationRoom[]> {
Expand Down Expand Up @@ -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<RoomInvite | null> {
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<RoomInvite> {
// 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<RoomInvite[]> {
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<RoomInvite | null> {
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<void> {
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<void> {
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<string | null> {
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<RoomMessage[]> {
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);
Expand Down
14 changes: 13 additions & 1 deletion src/types/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,16 @@ export interface InvitePayload {
export interface SendMessagePayload {
room_id: string;
content: string;
}
}

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;
}
Loading