Files
triggerdotdev--trigger.dev/apps/webapp/app/services/memberDevEnvironments.server.ts
T
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

106 lines
2.8 KiB
TypeScript

import { z } from "zod";
import { prisma } from "~/db.server";
import {
getProjectsMissingMemberDevelopmentEnvironments,
MembershipSourceSchema,
provisionMemberDevelopmentEnvironments,
type MembershipSource,
} from "~/models/member.server";
import { logger } from "~/services/logger.server";
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
export const MembershipDevEnvironmentsSchema = z.object({
userId: z.string(),
organizationId: z.string(),
source: MembershipSourceSchema,
});
export type MembershipDevEnvironments = z.infer<typeof MembershipDevEnvironmentsSchema>;
/**
* Create the member's missing development environments, one per active project.
* Idempotent, so it is safe to re-run and to retry after a partial failure.
*/
export async function provisionDevEnvironmentsForMembership({
userId,
organizationId,
source,
}: MembershipDevEnvironments): Promise<void> {
const member = await prisma.orgMember.findFirst({
where: {
userId,
organizationId,
organization: { deletedAt: null },
},
include: {
organization: {
include: {
projects: { where: { deletedAt: null }, select: { id: true } },
},
},
},
});
if (!member) {
logger.info("provisionDevEnvironmentsForMembership: no membership found", {
userId,
organizationId,
source,
});
return;
}
const projectsNeedingEnvs = await getProjectsMissingMemberDevelopmentEnvironments({
memberId: member.id,
organizationId,
projects: member.organization.projects,
});
if (projectsNeedingEnvs.length === 0) {
return;
}
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
organizationId,
"DEVELOPMENT"
);
await provisionMemberDevelopmentEnvironments({
source,
member,
organization: member.organization,
projects: projectsNeedingEnvs,
maximumConcurrencyLimit,
});
}
/**
* Queue provisioning, deduped per membership. Never throws: callers decide what
* `enqueued: false` means for them.
*/
export async function enqueueMemberDevelopmentEnvironments(payload: {
userId: string;
organizationId: string;
source: MembershipSource;
}): Promise<{ enqueued: boolean }> {
try {
// Lazy: a static import would close a module cycle.
const { commonWorker } = await import("~/v3/commonWorker.server");
await commonWorker.enqueueOnce({
id: `membership:devEnvs:${payload.organizationId}:${payload.userId}`,
job: "membership.provisionDevEnvironments",
payload,
});
return { enqueued: true };
} catch (error) {
logger.error("Failed to enqueue member development environment provisioning", {
...payload,
error: error instanceof Error ? error.message : String(error),
});
return { enqueued: false };
}
}