|
1 | | -import { type Prisma, prisma } from "~/db.server"; |
| 1 | +import type { Organization, OrgMember, Project } from "@trigger.dev/database"; |
| 2 | +import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server"; |
2 | 3 | import { createEnvironment } from "./organization.server"; |
3 | 4 | import { customAlphabet } from "nanoid"; |
4 | 5 | import { logger } from "~/services/logger.server"; |
| 6 | +import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; |
5 | 7 | import { rbac } from "~/services/rbac.server"; |
6 | 8 |
|
| 9 | +export const INVITE_NOT_FOUND = "Invite not found"; |
| 10 | +export const ENV_SETUP_INCOMPLETE = |
| 11 | + "You joined the organization, but we couldn't finish setting up your development environments. Please try again or contact support if this persists."; |
| 12 | + |
| 13 | +export function isAcceptInviteFormError(error: unknown): error is Error { |
| 14 | + return ( |
| 15 | + error instanceof Error && |
| 16 | + (error.message === INVITE_NOT_FOUND || error.message === ENV_SETUP_INCOMPLETE) |
| 17 | + ); |
| 18 | +} |
| 19 | + |
7 | 20 | const tokenValueLength = 40; |
8 | 21 | const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); |
9 | 22 |
|
@@ -177,65 +190,138 @@ export async function getUsersInvites({ email }: { email: string }) { |
177 | 190 | }); |
178 | 191 | } |
179 | 192 |
|
180 | | -export async function acceptInvite({ |
181 | | - user, |
| 193 | +export async function provisionMemberDevelopmentEnvironments({ |
182 | 194 | inviteId, |
| 195 | + user, |
| 196 | + member, |
| 197 | + organization, |
| 198 | + projects, |
| 199 | + maximumConcurrencyLimit, |
183 | 200 | }: { |
184 | | - user: { id: string; email: string }; |
185 | 201 | inviteId: string; |
| 202 | + user: { id: string; email: string }; |
| 203 | + member: OrgMember; |
| 204 | + organization: Pick<Organization, "id" | "maximumConcurrencyLimit">; |
| 205 | + projects: Pick<Project, "id">[]; |
| 206 | + maximumConcurrencyLimit: number; |
186 | 207 | }) { |
187 | | - const result = await prisma.$transaction(async (tx) => { |
188 | | - // 1. Delete the invite and get the invite details |
189 | | - const invite = await tx.orgMemberInvite.delete({ |
190 | | - where: { |
191 | | - id: inviteId, |
192 | | - email: user.email, |
193 | | - }, |
194 | | - include: { |
195 | | - organization: { |
196 | | - include: { |
197 | | - projects: true, |
198 | | - }, |
199 | | - }, |
200 | | - }, |
201 | | - }); |
| 208 | + const projectIds = projects.map((p) => p.id); |
| 209 | + const createdProjectIds: string[] = []; |
| 210 | + let failedProjectId: string | undefined; |
| 211 | + let failedProjectIndex: number | undefined; |
202 | 212 |
|
203 | | - // 2. Join the organization |
204 | | - const member = await tx.orgMember.create({ |
205 | | - data: { |
206 | | - organizationId: invite.organizationId, |
207 | | - userId: user.id, |
208 | | - role: invite.role, |
209 | | - }, |
210 | | - }); |
| 213 | + try { |
| 214 | + for (const [index, project] of projects.entries()) { |
| 215 | + failedProjectId = project.id; |
| 216 | + failedProjectIndex = index; |
211 | 217 |
|
212 | | - // 3. Create an environment for each project |
213 | | - for (const project of invite.organization.projects) { |
214 | 218 | await createEnvironment({ |
215 | | - organization: invite.organization, |
| 219 | + organization, |
216 | 220 | project, |
217 | 221 | type: "DEVELOPMENT", |
218 | 222 | // We set this true but no backfill (yet!?) so never used |
219 | 223 | // for dev environments |
220 | 224 | isBranchableEnvironment: true, |
221 | 225 | member, |
222 | | - prismaClient: tx, |
| 226 | + maximumConcurrencyLimit, |
223 | 227 | }); |
| 228 | + |
| 229 | + createdProjectIds.push(project.id); |
| 230 | + failedProjectId = undefined; |
| 231 | + failedProjectIndex = undefined; |
224 | 232 | } |
| 233 | + } catch (error) { |
| 234 | + logger.error("acceptInvite: development environment creation failed after membership created", { |
| 235 | + inviteId, |
| 236 | + userId: user.id, |
| 237 | + organizationId: organization.id, |
| 238 | + orgMemberId: member.id, |
| 239 | + projectIds, |
| 240 | + failedProjectId, |
| 241 | + failedProjectIndex, |
| 242 | + totalProjects: projects.length, |
| 243 | + createdProjectIds, |
| 244 | + error: |
| 245 | + error instanceof Error |
| 246 | + ? { name: error.name, message: error.message, stack: error.stack } |
| 247 | + : String(error), |
| 248 | + }); |
225 | 249 |
|
226 | | - // 4. Check for other invites |
227 | | - const remainingInvites = await tx.orgMemberInvite.findMany({ |
228 | | - where: { |
229 | | - email: user.email, |
230 | | - }, |
| 250 | + throw new Error(ENV_SETUP_INCOMPLETE); |
| 251 | + } |
| 252 | +} |
| 253 | + |
| 254 | +export async function acceptInvite({ |
| 255 | + user, |
| 256 | + inviteId, |
| 257 | +}: { |
| 258 | + user: { id: string; email: string }; |
| 259 | + inviteId: string; |
| 260 | +}) { |
| 261 | + const pendingInvite = await prisma.orgMemberInvite.findFirst({ |
| 262 | + where: { id: inviteId, email: user.email }, |
| 263 | + select: { id: true, organizationId: true }, |
| 264 | + }); |
| 265 | + if (!pendingInvite) { |
| 266 | + throw new Error(INVITE_NOT_FOUND); |
| 267 | + } |
| 268 | + |
| 269 | + const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit( |
| 270 | + pendingInvite.organizationId, |
| 271 | + "DEVELOPMENT" |
| 272 | + ); |
| 273 | + |
| 274 | + let result; |
| 275 | + try { |
| 276 | + result = await prisma.$transaction(async (tx) => { |
| 277 | + const invite = await tx.orgMemberInvite.delete({ |
| 278 | + where: { |
| 279 | + id: inviteId, |
| 280 | + email: user.email, |
| 281 | + }, |
| 282 | + include: { |
| 283 | + organization: { |
| 284 | + include: { |
| 285 | + projects: { where: { deletedAt: null } }, |
| 286 | + }, |
| 287 | + }, |
| 288 | + }, |
| 289 | + }); |
| 290 | + |
| 291 | + const member = await tx.orgMember.create({ |
| 292 | + data: { |
| 293 | + organizationId: invite.organizationId, |
| 294 | + userId: user.id, |
| 295 | + role: invite.role, |
| 296 | + }, |
| 297 | + }); |
| 298 | + |
| 299 | + return { |
| 300 | + member, |
| 301 | + organization: invite.organization, |
| 302 | + rbacRoleId: invite.rbacRoleId, |
| 303 | + }; |
231 | 304 | }); |
| 305 | + } catch (error) { |
| 306 | + if (error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025") { |
| 307 | + throw new Error(INVITE_NOT_FOUND); |
| 308 | + } |
| 309 | + throw error; |
| 310 | + } |
232 | 311 |
|
233 | | - return { |
234 | | - remainingInvites, |
235 | | - organization: invite.organization, |
236 | | - inviteRole: invite.role, |
237 | | - rbacRoleId: invite.rbacRoleId, |
238 | | - }; |
| 312 | + await provisionMemberDevelopmentEnvironments({ |
| 313 | + inviteId, |
| 314 | + user, |
| 315 | + member: result.member, |
| 316 | + organization: result.organization, |
| 317 | + projects: result.organization.projects, |
| 318 | + maximumConcurrencyLimit, |
| 319 | + }); |
| 320 | + |
| 321 | + const remainingInvites = await prisma.orgMemberInvite.findMany({ |
| 322 | + where: { |
| 323 | + email: user.email, |
| 324 | + }, |
239 | 325 | }); |
240 | 326 |
|
241 | 327 | // If the invite carried an explicit RBAC role, assign it. Best-effort: the |
@@ -271,7 +357,7 @@ export async function acceptInvite({ |
271 | 357 | } |
272 | 358 | } |
273 | 359 |
|
274 | | - return { remainingInvites: result.remainingInvites, organization: result.organization }; |
| 360 | + return { remainingInvites, organization: result.organization }; |
275 | 361 | } |
276 | 362 |
|
277 | 363 | export async function declineInvite({ |
|
0 commit comments