Skip to content

Commit 0a2d289

Browse files
committed
feat(invite-workspace): added invite UI and capabilites; improved tooltips; added keyboard shortcuts; adjusted clay desc
1 parent 2c7806f commit 0a2d289

File tree

18 files changed

+1156
-62
lines changed

18 files changed

+1156
-62
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { and, eq } from 'drizzle-orm'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
import { randomUUID } from 'crypto'
4+
import { getSession } from '@/lib/auth'
5+
import { db } from '@/db'
6+
import { workspace, workspaceMember, workspaceInvitation, user } from '@/db/schema'
7+
8+
// GET /api/workspaces/invitations/accept - Accept an invitation via token
9+
export async function GET(req: NextRequest) {
10+
const token = req.nextUrl.searchParams.get('token')
11+
12+
if (!token) {
13+
// Redirect to a page explaining the error
14+
return NextResponse.redirect(new URL('/invitation-error?reason=missing-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
15+
}
16+
17+
const session = await getSession()
18+
19+
if (!session?.user?.id) {
20+
// Store the token in a query param and redirect to login page
21+
return NextResponse.redirect(new URL(`/auth/signin?callbackUrl=${encodeURIComponent(`/api/workspaces/invitations/accept?token=${token}`)}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
22+
}
23+
24+
try {
25+
// Find the invitation by token
26+
const invitation = await db
27+
.select()
28+
.from(workspaceInvitation)
29+
.where(eq(workspaceInvitation.token, token))
30+
.then(rows => rows[0])
31+
32+
if (!invitation) {
33+
return NextResponse.redirect(new URL('/invitation-error?reason=invalid-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
34+
}
35+
36+
// Check if invitation has expired
37+
if (new Date() > new Date(invitation.expiresAt)) {
38+
return NextResponse.redirect(new URL('/invitation-error?reason=expired', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
39+
}
40+
41+
// Check if invitation is already accepted
42+
if (invitation.status !== 'pending') {
43+
return NextResponse.redirect(new URL('/invitation-error?reason=already-processed', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
44+
}
45+
46+
// Check if invitation email matches the logged-in user
47+
if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) {
48+
return NextResponse.redirect(new URL('/invitation-error?reason=email-mismatch', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
49+
}
50+
51+
// Get the workspace details
52+
const workspaceDetails = await db
53+
.select()
54+
.from(workspace)
55+
.where(eq(workspace.id, invitation.workspaceId))
56+
.then(rows => rows[0])
57+
58+
if (!workspaceDetails) {
59+
return NextResponse.redirect(new URL('/invitation-error?reason=workspace-not-found', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
60+
}
61+
62+
// Check if user is already a member
63+
const existingMembership = await db
64+
.select()
65+
.from(workspaceMember)
66+
.where(
67+
and(
68+
eq(workspaceMember.workspaceId, invitation.workspaceId),
69+
eq(workspaceMember.userId, session.user.id)
70+
)
71+
)
72+
.then(rows => rows[0])
73+
74+
if (existingMembership) {
75+
// User is already a member, just mark the invitation as accepted and redirect
76+
await db
77+
.update(workspaceInvitation)
78+
.set({
79+
status: 'accepted',
80+
updatedAt: new Date(),
81+
})
82+
.where(eq(workspaceInvitation.id, invitation.id))
83+
84+
return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
85+
}
86+
87+
// Add user to workspace
88+
await db
89+
.insert(workspaceMember)
90+
.values({
91+
id: randomUUID(),
92+
workspaceId: invitation.workspaceId,
93+
userId: session.user.id,
94+
role: invitation.role,
95+
joinedAt: new Date(),
96+
updatedAt: new Date(),
97+
})
98+
99+
// Mark invitation as accepted
100+
await db
101+
.update(workspaceInvitation)
102+
.set({
103+
status: 'accepted',
104+
updatedAt: new Date(),
105+
})
106+
.where(eq(workspaceInvitation.id, invitation.id))
107+
108+
// Redirect to the workspace
109+
return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
110+
} catch (error) {
111+
console.error('Error accepting invitation:', error)
112+
return NextResponse.redirect(new URL('/invitation-error?reason=server-error', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'))
113+
}
114+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { and, eq, sql, inArray } from 'drizzle-orm'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { db } from '@/db'
5+
import { workspace, workspaceMember, workspaceInvitation, user } from '@/db/schema'
6+
import { randomUUID } from 'crypto'
7+
import { Resend } from 'resend'
8+
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
9+
import { render } from '@react-email/render'
10+
11+
// Initialize Resend for email sending
12+
const resend = new Resend(process.env.RESEND_API_KEY)
13+
14+
// GET /api/workspaces/invitations - Get all invitations for the user's workspaces
15+
export async function GET(req: NextRequest) {
16+
const session = await getSession()
17+
18+
if (!session?.user?.id) {
19+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
20+
}
21+
22+
try {
23+
// First get all workspaces where the user is a member with owner role
24+
const userWorkspaces = await db
25+
.select({ id: workspace.id })
26+
.from(workspace)
27+
.innerJoin(
28+
workspaceMember,
29+
and(
30+
eq(workspaceMember.workspaceId, workspace.id),
31+
eq(workspaceMember.userId, session.user.id),
32+
eq(workspaceMember.role, 'owner')
33+
)
34+
)
35+
36+
if (userWorkspaces.length === 0) {
37+
return NextResponse.json({ invitations: [] })
38+
}
39+
40+
// Get all workspaceIds where the user is an owner
41+
const workspaceIds = userWorkspaces.map(w => w.id)
42+
43+
// Find all invitations for those workspaces
44+
const invitations = await db
45+
.select()
46+
.from(workspaceInvitation)
47+
.where(
48+
inArray(workspaceInvitation.workspaceId, workspaceIds)
49+
)
50+
51+
return NextResponse.json({ invitations })
52+
} catch (error) {
53+
console.error('Error fetching workspace invitations:', error)
54+
return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 })
55+
}
56+
}
57+
58+
// POST /api/workspaces/invitations - Create a new invitation
59+
export async function POST(req: NextRequest) {
60+
const session = await getSession()
61+
62+
if (!session?.user?.id) {
63+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
64+
}
65+
66+
try {
67+
const { workspaceId, email, role = 'member' } = await req.json()
68+
69+
if (!workspaceId || !email) {
70+
return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 })
71+
}
72+
73+
// Check if user is authorized to invite to this workspace (must be owner)
74+
const membership = await db
75+
.select()
76+
.from(workspaceMember)
77+
.where(
78+
and(
79+
eq(workspaceMember.workspaceId, workspaceId),
80+
eq(workspaceMember.userId, session.user.id)
81+
)
82+
)
83+
.then(rows => rows[0])
84+
85+
if (!membership || membership.role !== 'owner') {
86+
return NextResponse.json({ error: 'You are not authorized to invite to this workspace' }, { status: 403 })
87+
}
88+
89+
// Get the workspace details for the email
90+
const workspaceDetails = await db
91+
.select()
92+
.from(workspace)
93+
.where(eq(workspace.id, workspaceId))
94+
.then(rows => rows[0])
95+
96+
if (!workspaceDetails) {
97+
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
98+
}
99+
100+
// Check if the user is already a member
101+
// First find if a user with this email exists
102+
const existingUser = await db
103+
.select()
104+
.from(user)
105+
.where(eq(user.email, email))
106+
.then(rows => rows[0])
107+
108+
if (existingUser) {
109+
// Check if the user is already a member of this workspace
110+
const existingMembership = await db
111+
.select()
112+
.from(workspaceMember)
113+
.where(
114+
and(
115+
eq(workspaceMember.workspaceId, workspaceId),
116+
eq(workspaceMember.userId, existingUser.id)
117+
)
118+
)
119+
.then(rows => rows[0])
120+
121+
if (existingMembership) {
122+
return NextResponse.json({
123+
error: `${email} is already a member of this workspace`,
124+
email
125+
}, { status: 400 })
126+
}
127+
}
128+
129+
// Check if there's already a pending invitation
130+
const existingInvitation = await db
131+
.select()
132+
.from(workspaceInvitation)
133+
.where(
134+
and(
135+
eq(workspaceInvitation.workspaceId, workspaceId),
136+
eq(workspaceInvitation.email, email),
137+
eq(workspaceInvitation.status, 'pending')
138+
)
139+
)
140+
.then(rows => rows[0])
141+
142+
if (existingInvitation) {
143+
return NextResponse.json({
144+
error: `${email} has already been invited to this workspace`,
145+
email
146+
}, { status: 400 })
147+
}
148+
149+
// Generate a unique token and set expiry date (1 week from now)
150+
const token = randomUUID()
151+
const expiresAt = new Date()
152+
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
153+
154+
// Create the invitation
155+
const invitation = await db
156+
.insert(workspaceInvitation)
157+
.values({
158+
id: randomUUID(),
159+
workspaceId,
160+
email,
161+
inviterId: session.user.id,
162+
role,
163+
status: 'pending',
164+
token,
165+
expiresAt,
166+
createdAt: new Date(),
167+
updatedAt: new Date(),
168+
})
169+
.returning()
170+
.then(rows => rows[0])
171+
172+
// Send the invitation email
173+
await sendInvitationEmail({
174+
to: email,
175+
inviterName: session.user.name || session.user.email || 'A user',
176+
workspaceName: workspaceDetails.name,
177+
token: token,
178+
})
179+
180+
return NextResponse.json({ success: true, invitation })
181+
} catch (error) {
182+
console.error('Error creating workspace invitation:', error)
183+
return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 })
184+
}
185+
}
186+
187+
// Helper function to send invitation email using the Resend API
188+
async function sendInvitationEmail({
189+
to,
190+
inviterName,
191+
workspaceName,
192+
token
193+
}: {
194+
to: string;
195+
inviterName: string;
196+
workspaceName: string;
197+
token: string;
198+
}) {
199+
try {
200+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
201+
const invitationLink = `${baseUrl}/api/workspaces/invitations/accept?token=${token}`
202+
203+
const emailHtml = await render(
204+
WorkspaceInvitationEmail({
205+
workspaceName,
206+
inviterName,
207+
invitationLink,
208+
})
209+
)
210+
211+
await resend.emails.send({
212+
from: process.env.RESEND_FROM_EMAIL || 'noreply@simstudio.ai',
213+
to,
214+
subject: `You've been invited to join "${workspaceName}" on SimStudio`,
215+
html: emailHtml,
216+
})
217+
218+
console.log(`Invitation email sent to ${to}`)
219+
} catch (error) {
220+
console.error('Error sending invitation email:', error)
221+
// Continue even if email fails - the invitation is still created
222+
}
223+
}

0 commit comments

Comments
 (0)