Skip to content

Commit f71fc4b

Browse files
authored
fix: Invite to Room now creates a pending invitation instead of adding member directly (#3239) (#3269)
- 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 <aaniyaatomar@gmail.com>
1 parent 14b8869 commit f71fc4b

11 files changed

Lines changed: 520 additions & 92 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { getServerSession } from "next-auth";
2+
import { authOptions } from "@/lib/auth";
3+
import {
4+
getRoomInvitation,
5+
respondToRoomInvitation,
6+
} from "@/lib/supabase-rooms";
7+
import { NextResponse } from "next/server";
8+
9+
export async function POST(
10+
req: Request,
11+
{ params }: { params: Promise<{ invitationId: string }> }
12+
) {
13+
const { invitationId } = await params;
14+
const session = await getServerSession(authOptions);
15+
if (!session?.user?.name)
16+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
17+
18+
const invitation = await getRoomInvitation(invitationId);
19+
if (!invitation)
20+
return NextResponse.json(
21+
{ error: "Invitation not found" },
22+
{ status: 404 }
23+
);
24+
if (invitation.github_username !== session.user.name)
25+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
26+
if (invitation.status !== "pending")
27+
return NextResponse.json(
28+
{ error: "Invitation already responded to" },
29+
{ status: 409 }
30+
);
31+
32+
const { action } = await req.json();
33+
if (action !== "accept" && action !== "decline")
34+
return NextResponse.json(
35+
{ error: 'action must be "accept" or "decline"' },
36+
{ status: 400 }
37+
);
38+
39+
const updated = await respondToRoomInvitation(
40+
invitationId,
41+
action === "accept"
42+
);
43+
return NextResponse.json({ success: true, status: updated?.status });
44+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { getServerSession } from "next-auth";
2+
import { authOptions } from "@/lib/auth";
3+
import { getPendingInvitationsForUser } from "@/lib/supabase-rooms";
4+
import { NextResponse } from "next/server";
5+
6+
export async function GET() {
7+
const session = await getServerSession(authOptions);
8+
if (!session?.user?.name)
9+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10+
11+
const invitations = await getPendingInvitationsForUser(session.user.name);
12+
return NextResponse.json({ invitations });
13+
}
Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import { getServerSession } from 'next-auth';
2-
import { authOptions } from '@/lib/auth';
3-
import { getRoomById, getRoomMembers, addRoomMember } from '@/lib/supabase-rooms';
4-
import { NextResponse } from 'next/server';
1+
import { getServerSession } from "next-auth";
2+
import { authOptions } from "@/lib/auth";
3+
import {
4+
getRoomById,
5+
getRoomMembers,
6+
createRoomInvitation,
7+
notifyRoomInvitation,
8+
} from "@/lib/supabase-rooms";
9+
import { NextResponse } from "next/server";
510

611
export async function POST(
712
req: Request,
@@ -10,32 +15,60 @@ export async function POST(
1015
const { roomId } = await params;
1116
const session = await getServerSession(authOptions);
1217
if (!session?.user?.name)
13-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
18+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
1419
const room = await getRoomById(roomId, session.user.name);
15-
if (!room) return NextResponse.json({ error: 'Not found' }, { status: 404 });
20+
if (!room) return NextResponse.json({ error: "Not found" }, { status: 404 });
1621
if (!room.is_owner)
17-
return NextResponse.json({ error: 'Only the room owner can invite' }, { status: 403 });
22+
return NextResponse.json(
23+
{ error: "Only the room owner can invite" },
24+
{ status: 403 }
25+
);
1826
const { github_username } = await req.json();
1927
if (!github_username?.trim())
20-
return NextResponse.json({ error: 'github_username required' }, { status: 400 });
28+
return NextResponse.json(
29+
{ error: "github_username required" },
30+
{ status: 400 }
31+
);
2132
// GitHub usernames: 1-39 chars, alphanumeric + hyphens, no leading/trailing hyphen
2233
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(github_username))
23-
return NextResponse.json({ error: 'Invalid GitHub username' }, { status: 400 });
34+
return NextResponse.json(
35+
{ error: "Invalid GitHub username" },
36+
{ status: 400 }
37+
);
2438
const ghRes = await fetch(`https://api.github.com/users/${github_username}`, {
2539
headers: {
26-
Accept: 'application/vnd.github+json',
40+
Accept: "application/vnd.github+json",
2741
...(process.env.GITHUB_TOKEN
2842
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
2943
: {}),
3044
},
3145
});
3246
if (ghRes.status === 404)
33-
return NextResponse.json({ error: `GitHub user "${github_username}" does not exist` }, { status: 404 });
47+
return NextResponse.json(
48+
{ error: `GitHub user "${github_username}" does not exist` },
49+
{ status: 404 }
50+
);
3451
if (!ghRes.ok)
35-
return NextResponse.json({ error: 'Could not verify GitHub user' }, { status: 502 });
52+
return NextResponse.json(
53+
{ error: "Could not verify GitHub user" },
54+
{ status: 502 }
55+
);
3656
const members = await getRoomMembers(roomId);
3757
if (members.some((m) => m.github_username === github_username))
38-
return NextResponse.json({ error: 'User is already a member' }, { status: 409 });
39-
await addRoomMember(roomId, github_username);
40-
return NextResponse.json({ success: true });
41-
}
58+
return NextResponse.json(
59+
{ error: "User is already a member" },
60+
{ status: 409 }
61+
);
62+
try {
63+
await createRoomInvitation(roomId, github_username, session.user.name);
64+
} catch (error: any) {
65+
if (error?.code === "23505")
66+
return NextResponse.json(
67+
{ error: "An invitation is already pending for this user" },
68+
{ status: 409 }
69+
);
70+
throw error;
71+
}
72+
await notifyRoomInvitation(github_username, room.name, session.user.name);
73+
return NextResponse.json({ success: true, status: "pending" });
74+
}

src/app/rooms/RoomsListClient.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState } from 'react';
44
import Link from 'next/link';
55
import type { CollaborationRoom } from '@/types/rooms';
66
import CreateRoomModal from '@/components/rooms/CreateRoomModal';
7+
import PendingInvitations from '@/components/rooms/PendingInvitations';
78

89
interface Props {
910
initialRooms: CollaborationRoom[];
@@ -32,6 +33,8 @@ export default function RoomsListClient({ initialRooms, currentUser }: Props) {
3233
</button>
3334
</div>
3435

36+
<PendingInvitations />
37+
3538
{/* Room cards */}
3639
{rooms.length === 0 ? (
3740
<div className="text-center py-20 text-gray-400">
Lines changed: 56 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,88 @@
1-
'use client';
1+
"use client";
22

3-
import { useState } from 'react';
3+
import { useState } from "react";
44

55
interface Props {
66
roomId: string;
77
onClose: () => void;
88
onInvited: (username: string) => void;
99
}
10-
1110
export default function InviteModal({ roomId, onClose, onInvited }: Props) {
12-
const [username, setUsername] = useState('');
11+
const [username, setUsername] = useState("");
1312
const [loading, setLoading] = useState(false);
1413
const [error, setError] = useState<string | null>(null);
15-
14+
const [sent, setSent] = useState(false);
1615
async function handleInvite(e: React.FormEvent) {
1716
e.preventDefault();
1817
setLoading(true);
1918
setError(null);
20-
2119
const res = await fetch(`/api/rooms/${roomId}/invite`, {
22-
method: 'POST',
23-
headers: { 'Content-Type': 'application/json' },
20+
method: "POST",
21+
headers: { "Content-Type": "application/json" },
2422
body: JSON.stringify({ github_username: username.trim() }),
2523
});
26-
2724
const data = await res.json();
2825
setLoading(false);
29-
3026
if (!res.ok) {
31-
setError(data.error ?? 'Invite failed');
27+
setError(data.error ?? "Invite failed");
3228
return;
3329
}
34-
3530
onInvited(username.trim());
36-
onClose();
31+
setSent(true);
3732
}
3833

3934
return (
4035
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
4136
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-sm p-6">
42-
<h2 className="text-lg font-semibold mb-4">Invite by GitHub Username</h2>
43-
44-
<form onSubmit={handleInvite} className="space-y-4">
45-
<input
46-
autoFocus
47-
className="w-full border rounded-lg px-3 py-2 text-sm dark:bg-gray-800 dark:border-gray-700"
48-
placeholder="github-username"
49-
value={username}
50-
onChange={(e) => setUsername(e.target.value)}
51-
required
52-
/>
53-
54-
{error && <p className="text-red-500 text-sm">{error}</p>}
55-
56-
<div className="flex gap-3 justify-end">
57-
<button
58-
type="button"
59-
onClick={onClose}
60-
className="px-4 py-2 rounded-lg text-sm border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
61-
>
62-
Cancel
63-
</button>
64-
<button
65-
type="submit"
66-
disabled={loading || !username.trim()}
67-
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
68-
>
69-
{loading ? 'Inviting…' : 'Send Invite'}
70-
</button>
37+
<h2 className="text-lg font-semibold mb-4">
38+
Invite by GitHub Username
39+
</h2>
40+
{sent ? (
41+
<div className="space-y-4">
42+
<p className="text-sm text-green-600 dark:text-green-400">
43+
Invitation sent to <strong>{username.trim()}</strong>.
44+
They&apos;ll join once they accept it.
45+
</p>
46+
<div className="flex justify-end">
47+
<button
48+
type="button"
49+
onClick={onClose}
50+
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700"
51+
>
52+
Done
53+
</button>
54+
</div>
7155
</div>
72-
</form>
56+
) : (
57+
<form onSubmit={handleInvite} className="space-y-4">
58+
<input
59+
autoFocus
60+
className="w-full border rounded-lg px-3 py-2 text-sm dark:bg-gray-800 dark:border-gray-700"
61+
placeholder="github-username"
62+
value={username}
63+
onChange={(e) => setUsername(e.target.value)}
64+
required
65+
/>
66+
{error && <p className="text-red-500 text-sm">{error}</p>}
67+
<div className="flex gap-3 justify-end">
68+
<button
69+
type="button"
70+
onClick={onClose}
71+
className="px-4 py-2 rounded-lg text-sm border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
72+
>
73+
Cancel
74+
</button>
75+
<button
76+
type="submit"
77+
disabled={loading || !username.trim()}
78+
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
79+
>
80+
{loading ? "Inviting…" : "Send Invite"}
81+
</button>
82+
</div>
83+
</form>
84+
)}
7385
</div>
7486
</div>
7587
);
76-
}
88+
}

src/components/rooms/MembersPanel.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
'use client';
1+
"use client";
22

3-
import { useState } from 'react';
4-
import type { RoomMember } from '@/types/rooms';
5-
import InviteModal from './InviteModal';
3+
import { useState } from "react";
4+
import type { RoomMember } from "@/types/rooms";
5+
import InviteModal from "./InviteModal";
66

77
interface Props {
88
roomId: string;
@@ -12,7 +12,13 @@ interface Props {
1212
onMemberRemoved: (username: string) => void;
1313
}
1414

15-
export default function MembersPanel({ roomId, members, isOwner, onMemberAdded, onMemberRemoved }: Props) {
15+
export default function MembersPanel({
16+
roomId,
17+
members,
18+
isOwner,
19+
onMemberAdded,
20+
onMemberRemoved,
21+
}: Props) {
1622
const [showInvite, setShowInvite] = useState(false);
1723
const [removingUsername, setRemovingUsername] = useState<string | null>(null);
1824

@@ -22,16 +28,16 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded,
2228
try {
2329
const res = await fetch(
2430
`/api/rooms/${roomId}/members/${encodeURIComponent(username)}`,
25-
{ method: 'DELETE' }
31+
{ method: "DELETE" }
2632
);
2733
if (res.ok) {
2834
onMemberRemoved(username);
2935
} else {
3036
const data = await res.json().catch(() => ({}));
31-
alert((data as { error?: string }).error ?? 'Failed to remove member');
37+
alert((data as { error?: string }).error ?? "Failed to remove member");
3238
}
3339
} catch {
34-
alert('Network error. Please try again.');
40+
alert("Network error. Please try again.");
3541
} finally {
3642
setRemovingUsername(null);
3743
}
@@ -64,18 +70,20 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded,
6470
/>
6571
<div className="min-w-0 flex-1">
6672
<p className="text-sm truncate">{m.github_username}</p>
67-
{m.role === 'owner' && (
68-
<span className="text-[10px] text-yellow-600 dark:text-yellow-400">owner</span>
73+
{m.role === "owner" && (
74+
<span className="text-[10px] text-yellow-600 dark:text-yellow-400">
75+
owner
76+
</span>
6977
)}
7078
</div>
71-
{isOwner && m.role !== 'owner' && (
79+
{isOwner && m.role !== "owner" && (
7280
<button
7381
onClick={() => handleRemove(m.github_username)}
7482
disabled={removingUsername === m.github_username}
7583
aria-label={`Remove ${m.github_username}`}
7684
className="shrink-0 text-[10px] text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-40"
7785
>
78-
{removingUsername === m.github_username ? '…' : '✕'}
86+
{removingUsername === m.github_username ? "…" : "✕"}
7987
</button>
8088
)}
8189
</div>
@@ -86,9 +94,9 @@ export default function MembersPanel({ roomId, members, isOwner, onMemberAdded,
8694
<InviteModal
8795
roomId={roomId}
8896
onClose={() => setShowInvite(false)}
89-
onInvited={(username) => {
90-
onMemberAdded(username);
91-
setShowInvite(false);
97+
onInvited={() => {
98+
// Invitation is pending, not an actual membership — don't add to
99+
// the members list until the invitee accepts.
92100
}}
93101
/>
94102
)}

0 commit comments

Comments
 (0)