Files
triggerdotdev--trigger.dev/apps/webapp/app/models/project.server.ts
Oskar Otwinowski 4efe0a07c4 fix(webapp): create dev environments for SSO and Directory Sync members (#4426)
Members added by SSO just-in-time provisioning or Directory Sync never
got
their per-member DEVELOPMENT environments - only invite acceptance and
project creation created them. `trigger dev` returned "Environment not
found" for those members and the dashboard had no dev view.

ensureOrgMember now queues provisioning for every membership it settles,
so
both paths are covered and members missing environments are repaired on
their next sync. Provisioning runs as a common-worker job to keep
sign-in
and directory webhooks off the per-project write loop. A failed enqueue
surfaces for Directory Sync, whose worker retries the idempotent effect,
and is swallowed for sign-in, where the next login enqueues again.
Environment creation now tolerates a concurrent creator so the
project-creation loop and the job cannot collide on the unique index.

Also fixes environment resolution ignoring dev-environment ownership: a
member without their own dev environment could be handed a colleague's
and
have it persisted as their dashboard preference.
2026-07-30 21:50:15 +02:00

172 lines
4.4 KiB
TypeScript

import type { Prisma, Project } from "@trigger.dev/database";
import { customAlphabet, nanoid } from "nanoid";
import slug from "slug";
import { $replica, prisma } from "~/db.server";
import { projectCreated } from "~/services/projectCreated.server";
import { ServiceValidationError } from "~/v3/services/common.server";
import {
type Organization,
createDevelopmentEnvironmentForMember,
createEnvironment,
} from "./organization.server";
export type { Project } from "@trigger.dev/database";
const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20);
type Options = {
organizationSlug: string;
name: string;
userId: string;
version: "v2" | "v3";
onboardingData?: Prisma.InputJsonValue;
};
export class ExceededProjectLimitError extends Error {
constructor(message: string) {
super(message);
this.name = "ExceededProjectLimitError";
}
}
export async function createProject(
{ organizationSlug, name, userId, version, onboardingData }: Options,
attemptCount = 0
): Promise<Project & { organization: Organization }> {
//check the user has permissions to do this
const organization = await prisma.organization.findFirst({
select: {
id: true,
slug: true,
isActivated: true,
maximumConcurrencyLimit: true,
maximumProjectCount: true,
},
where: {
slug: organizationSlug,
members: { some: { userId } },
},
});
if (!organization) {
throw new Error(
`User ${userId} does not have permission to create a project in organization ${organizationSlug}`
);
}
if (version === "v3") {
if (!organization.isActivated) {
throw new ServiceValidationError(
"You must select a plan for this organization before creating projects.",
402
);
}
}
const projectCount = await prisma.project.count({
where: {
organizationId: organization.id,
deletedAt: null,
},
});
if (projectCount >= organization.maximumProjectCount) {
throw new ExceededProjectLimitError(
`This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
);
}
//ensure the slug is globally unique
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
const projectWithSameSlug = await prisma.project.findFirst({
where: { slug: uniqueProjectSlug },
});
if (attemptCount > 100) {
throw new Error(`Unable to create project with slug ${uniqueProjectSlug} after 100 attempts`);
}
if (projectWithSameSlug) {
return createProject(
{
organizationSlug,
name,
userId,
version,
onboardingData,
},
attemptCount + 1
);
}
const project = await prisma.project.create({
data: {
name,
slug: uniqueProjectSlug,
organization: {
connect: {
slug: organizationSlug,
},
},
externalRef: `proj_${externalRefGenerator()}`,
version: version === "v3" ? "V3" : "V2",
// New projects run on the v2 engine. The Prisma column still defaults to V1
// for historical rows; the V1->V2 upgrade guards on worker-register / deploy
// stay in place to migrate existing legacy projects.
engine: "V2",
onboardingData,
},
include: {
organization: {
include: {
members: true,
},
},
},
});
// Create the dev and prod environments
await createEnvironment({
organization,
project,
type: "PRODUCTION",
isBranchableEnvironment: false,
});
for (const member of project.organization.members) {
await createDevelopmentEnvironmentForMember({
organization,
project,
member,
});
}
await projectCreated(organization, project);
return project;
}
export async function findProjectBySlug(orgSlug: string, projectSlug: string, userId: string) {
// Find the project scoped to the organization, making sure the user belongs to that org
return await $replica.project.findFirst({
where: {
slug: projectSlug,
organization: {
slug: orgSlug,
members: { some: { userId } },
},
},
});
}
export async function findProjectByRef(externalRef: string, userId: string) {
// Find the project scoped to the organization, making sure the user belongs to that org
return await $replica.project.findFirst({
where: {
externalRef,
organization: {
members: { some: { userId } },
},
},
});
}