Skip to content

Commit 41efcd8

Browse files
committed
fix: team invite fixes
1 parent cfab158 commit 41efcd8

File tree

8 files changed

+510
-464
lines changed

8 files changed

+510
-464
lines changed
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { getServerSession } from 'next-auth'
3+
import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options'
4+
import db from 'common/db'
5+
import * as schema from 'common/db/schema'
6+
import { eq, and, isNull, inArray } from 'drizzle-orm' // Added inArray
7+
import { checkOrganizationPermission } from '@/lib/organization-permissions'
8+
import { sendOrganizationInvitationEmail } from '@codebuff/integrations'
9+
import { logger } from '@/util/logger'
10+
import crypto from 'crypto'
11+
import { env } from '@/env.mjs'
12+
13+
interface RouteParams {
14+
params: {
15+
orgId: string
16+
email?: string[] // e.g., ['user@example.com'] or ['user@example.com', 'resend']
17+
}
18+
}
19+
20+
interface InviteRequest {
21+
email: string
22+
role: 'admin' | 'member'
23+
}
24+
25+
// POST handles:
26+
// 1. Create new invitation (if params.email is undefined)
27+
// 2. Resend invitation (if params.email is [email, 'resend'])
28+
export async function POST(request: NextRequest, { params }: RouteParams) {
29+
const session = await getServerSession(authOptions)
30+
if (!session?.user?.id) {
31+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
32+
}
33+
34+
const { orgId, email: emailParams } = params
35+
const action = emailParams?.[1]
36+
const specificEmail = emailParams?.[0]
37+
38+
// Check permissions - only owners and admins can invite/resend
39+
const permissionResult = await checkOrganizationPermission(orgId, [
40+
'owner',
41+
'admin',
42+
])
43+
if (!permissionResult.success) {
44+
return NextResponse.json(
45+
{ error: permissionResult.error },
46+
{ status: permissionResult.status || 500 }
47+
)
48+
}
49+
const { organization } = permissionResult
50+
51+
// Inviter information (common for create and resend)
52+
const inviter = await db
53+
.select({ name: schema.user.name })
54+
.from(schema.user)
55+
.where(eq(schema.user.id, session.user.id))
56+
.limit(1)
57+
const inviterName = inviter[0]?.name || 'Someone'
58+
59+
// === RESEND INVITATION LOGIC ===
60+
if (action === 'resend' && specificEmail) {
61+
try {
62+
const existingInvitation = await db
63+
.select()
64+
.from(schema.orgInvite)
65+
.where(
66+
and(
67+
eq(schema.orgInvite.org_id, orgId),
68+
eq(schema.orgInvite.email, specificEmail),
69+
isNull(schema.orgInvite.accepted_at)
70+
)
71+
)
72+
.limit(1)
73+
74+
if (existingInvitation.length === 0) {
75+
return NextResponse.json(
76+
{ error: 'No pending invitation found for this email to resend' },
77+
{ status: 404 }
78+
)
79+
}
80+
const inviteRecord = existingInvitation[0]
81+
const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
82+
83+
await db
84+
.update(schema.orgInvite)
85+
.set({ expires_at: newExpiresAt })
86+
.where(eq(schema.orgInvite.id, inviteRecord.id))
87+
88+
const invitationUrl = `${env.NEXT_PUBLIC_APP_URL}/invites/${inviteRecord.token}` // Replaced request.nextUrl.origin
89+
const emailResult = await sendOrganizationInvitationEmail({
90+
email: inviteRecord.email,
91+
organizationName: organization!.name,
92+
inviterName, // Using fetched inviterName
93+
invitationUrl,
94+
role: inviteRecord.role,
95+
})
96+
97+
if (!emailResult.success) {
98+
return NextResponse.json(
99+
{ error: 'Failed to resend invitation email' },
100+
{ status: 500 }
101+
)
102+
}
103+
logger.info(
104+
{
105+
organizationId: orgId,
106+
invitedEmail: specificEmail,
107+
resentBy: session.user.id,
108+
newExpiresAt,
109+
},
110+
'Organization invitation resent successfully'
111+
)
112+
return NextResponse.json({
113+
success: true,
114+
message: 'Invitation resent successfully',
115+
expires_at: newExpiresAt.toISOString(),
116+
})
117+
} catch (error) {
118+
logger.error(
119+
{ organizationId: orgId, email: specificEmail, error },
120+
'Error resending organization invitation'
121+
)
122+
return NextResponse.json(
123+
{ error: 'Internal server error during resend' },
124+
{ status: 500 }
125+
)
126+
}
127+
}
128+
// === CREATE NEW INVITATION LOGIC ===
129+
else if (!emailParams || emailParams.length === 0) {
130+
try {
131+
const body: InviteRequest = await request.json()
132+
if (!body.email || !body.role) {
133+
return NextResponse.json(
134+
{ error: 'Email and role are required for new invitation' },
135+
{ status: 400 }
136+
)
137+
}
138+
if (!['admin', 'member'].includes(body.role)) {
139+
return NextResponse.json(
140+
{ error: 'Role must be admin or member' },
141+
{ status: 400 }
142+
)
143+
}
144+
145+
const existingMember = await db
146+
.select()
147+
.from(schema.orgMember)
148+
.innerJoin(schema.user, eq(schema.orgMember.user_id, schema.user.id))
149+
.where(
150+
and(
151+
eq(schema.orgMember.org_id, orgId),
152+
eq(schema.user.email, body.email)
153+
)
154+
)
155+
.limit(1)
156+
if (existingMember.length > 0) {
157+
return NextResponse.json(
158+
{ error: 'User is already a member of this organization' },
159+
{ status: 409 }
160+
)
161+
}
162+
163+
const existingInvitation = await db
164+
.select()
165+
.from(schema.orgInvite)
166+
.where(
167+
and(
168+
eq(schema.orgInvite.org_id, orgId),
169+
eq(schema.orgInvite.email, body.email),
170+
isNull(schema.orgInvite.accepted_at)
171+
)
172+
)
173+
.limit(1)
174+
if (existingInvitation.length > 0) {
175+
return NextResponse.json(
176+
{ error: 'Invitation already sent to this email' },
177+
{ status: 409 }
178+
)
179+
}
180+
181+
const token = crypto.randomBytes(32).toString('hex')
182+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
183+
184+
const [invitation] = await db
185+
.insert(schema.orgInvite)
186+
.values({
187+
org_id: orgId,
188+
email: body.email,
189+
role: body.role,
190+
token,
191+
invited_by: session.user.id,
192+
expires_at: expiresAt,
193+
})
194+
.returning()
195+
196+
const invitationUrl = `${env.NEXT_PUBLIC_APP_URL}/invites/${token}` // Replaced request.nextUrl.origin
197+
const emailResult = await sendOrganizationInvitationEmail({
198+
email: body.email,
199+
organizationName: organization!.name,
200+
inviterName, // Using fetched inviterName
201+
invitationUrl,
202+
role: body.role,
203+
})
204+
205+
if (!emailResult.success) {
206+
await db
207+
.delete(schema.orgInvite)
208+
.where(eq(schema.orgInvite.id, invitation.id))
209+
return NextResponse.json(
210+
{ error: 'Failed to send invitation email' },
211+
{ status: 500 }
212+
)
213+
}
214+
logger.info(
215+
{
216+
organizationId: orgId,
217+
invitedEmail: body.email,
218+
invitedBy: session.user.id,
219+
role: body.role,
220+
},
221+
'Organization invitation sent successfully'
222+
)
223+
return NextResponse.json({
224+
success: true,
225+
invitation: {
226+
id: invitation.id,
227+
email: invitation.email,
228+
role: invitation.role,
229+
expires_at: invitation.expires_at.toISOString(),
230+
},
231+
})
232+
} catch (error) {
233+
logger.error(
234+
{ organizationId: orgId, error },
235+
'Error sending organization invitation'
236+
)
237+
return NextResponse.json(
238+
{ error: 'Internal server error during create' },
239+
{ status: 500 }
240+
)
241+
}
242+
}
243+
// Fallback for invalid POST requests to /invitations/*
244+
return NextResponse.json(
245+
{ error: 'Invalid request path for POST' },
246+
{ status: 404 }
247+
)
248+
}
249+
250+
// GET handles:
251+
// 1. List invitations (if params.email is undefined)
252+
export async function GET(request: NextRequest, { params }: RouteParams) {
253+
const session = await getServerSession(authOptions)
254+
if (!session?.user?.id) {
255+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
256+
}
257+
258+
const { orgId, email: emailParams } = params
259+
260+
// Check permissions - only members can view invitations
261+
const permissionResult = await checkOrganizationPermission(orgId, 'member')
262+
if (!permissionResult.success) {
263+
return NextResponse.json(
264+
{ error: permissionResult.error },
265+
{ status: permissionResult.status || 500 }
266+
)
267+
}
268+
269+
// === LIST INVITATIONS LOGIC ===
270+
if (!emailParams || emailParams.length === 0) {
271+
try {
272+
const invitations = await db
273+
.select({
274+
id: schema.orgInvite.id,
275+
email: schema.orgInvite.email,
276+
role: schema.orgInvite.role,
277+
invited_by_name: schema.user.name,
278+
created_at: schema.orgInvite.created_at,
279+
expires_at: schema.orgInvite.expires_at,
280+
})
281+
.from(schema.orgInvite)
282+
.innerJoin(schema.user, eq(schema.orgInvite.invited_by, schema.user.id))
283+
.where(
284+
and(
285+
eq(schema.orgInvite.org_id, orgId),
286+
isNull(schema.orgInvite.accepted_at)
287+
)
288+
)
289+
.orderBy(schema.orgInvite.created_at)
290+
return NextResponse.json({ invitations })
291+
} catch (error) {
292+
logger.error(
293+
{ organizationId: orgId, error },
294+
'Error fetching organization invitations'
295+
)
296+
return NextResponse.json(
297+
{ error: 'Internal server error during list' },
298+
{ status: 500 }
299+
)
300+
}
301+
}
302+
// Fallback for invalid GET requests to /invitations/*
303+
return NextResponse.json(
304+
{ error: 'Invalid request path for GET' },
305+
{ status: 404 }
306+
)
307+
}
308+
309+
// DELETE handles:
310+
// 1. Cancel/delete invitation (if params.email is [email])
311+
export async function DELETE(request: NextRequest, { params }: RouteParams) {
312+
const session = await getServerSession(authOptions)
313+
if (!session?.user?.id) {
314+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
315+
}
316+
317+
const { orgId, email: emailParams } = params
318+
const specificEmail = emailParams?.[0]
319+
320+
// Check permissions - only owners and admins can cancel invitations
321+
const permissionResult = await checkOrganizationPermission(orgId, [
322+
'owner',
323+
'admin',
324+
])
325+
if (!permissionResult.success) {
326+
return NextResponse.json(
327+
{ error: permissionResult.error },
328+
{ status: permissionResult.status || 500 }
329+
)
330+
}
331+
332+
// === CANCEL INVITATION LOGIC ===
333+
if (specificEmail && emailParams?.length === 1) {
334+
try {
335+
const deletedInvitations = await db
336+
.delete(schema.orgInvite)
337+
.where(
338+
and(
339+
eq(schema.orgInvite.org_id, orgId),
340+
eq(schema.orgInvite.email, specificEmail),
341+
isNull(schema.orgInvite.accepted_at)
342+
)
343+
)
344+
.returning()
345+
346+
if (deletedInvitations.length === 0) {
347+
return NextResponse.json(
348+
{ error: 'No pending invitation found for this email to delete' },
349+
{ status: 404 }
350+
)
351+
}
352+
logger.info(
353+
{
354+
organizationId: orgId,
355+
cancelledEmail: specificEmail,
356+
cancelledBy: session.user.id,
357+
},
358+
'Organization invitation cancelled'
359+
)
360+
return NextResponse.json({ success: true })
361+
} catch (error) {
362+
logger.error(
363+
{ organizationId: orgId, email: specificEmail, error },
364+
'Error cancelling organization invitation'
365+
)
366+
return NextResponse.json(
367+
{ error: 'Internal server error during delete' },
368+
{ status: 500 }
369+
)
370+
}
371+
}
372+
// Fallback for invalid DELETE requests to /invitations/*
373+
return NextResponse.json(
374+
{ error: 'Invalid request path for DELETE' },
375+
{ status: 404 }
376+
)
377+
}

0 commit comments

Comments
 (0)