Skip to content

Commit c140249

Browse files
authored
Merge branch 'main' into fix-global-mint-flag-grace
2 parents e5fa79d + 976171e commit c140249

28 files changed

Lines changed: 1930 additions & 432 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Add `defaultRegion` to the project GET and list API responses; null when unset.

apps/webapp/CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ The `triggerTask.server.ts` service is the **highest-throughput code path** in t
115115

116116
- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues.
117117

118+
## Transactions
119+
120+
- **Always use the `$transaction` helper from `~/db.server`, never `prisma.$transaction` (or `$replica.$transaction`) directly.** The helper wraps the raw call with tracing (an OTEL span + an `isolation_level` attribute) and boundary logging for infrastructure errors (e.g. `PrismaClientInitializationError`) that the raw client swallows. Signature: `$transaction(prisma, name?, async (tx) => { ... }, options?)`.
121+
- Pass the isolation level via options as a string: `{ isolationLevel: "Serializable" }`. Reach for `Serializable` when a read-then-write must be atomic against concurrent transactions (e.g. a count-then-delete invariant); the loser of a race fails and can retry, which is the right trade for rare, correctness-critical paths.
122+
- The helper returns `R | undefined` — guard the result (`if (!result) throw ...`) when callers need a definite value.
123+
124+
## PAT-authenticated API routes
125+
126+
- **A PAT route must resolve its target org/project scoped to the caller's membership** (`members: { some: { userId } }`, or a helper like `findProjectByRef` / `resolveOrganizationForApiUser`). A PAT is user-scoped and can name any org/project by id/slug, and the OSS RBAC fallback ability is permissive — so `ability.can(...)` alone does NOT reject a non-member on self-hosted. The RBAC `authorization` gate enforces the *role*; the membership-scoped query is the *tenant* floor. Skipping it opens cross-org access on OSS.
127+
118128
## React Patterns
119129

120130
- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations.

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ const EnvironmentSchema = z
113113
// agent dark; flip to "1" to enable it for everyone at GA. Per-org overrides
114114
// (org featureFlags) win regardless.
115115
DASHBOARD_AGENT_ENABLED: z.string().default("0"),
116+
// Gates the create-org management API endpoint (default off).
117+
ORG_CREATION_API_ENABLED: z.string().default("0"),
116118
// "1" gives admins/impersonators an everywhere-preview (default off),
117119
// separate from the per-org rollout flag above.
118120
DASHBOARD_AGENT_ADMIN_PREVIEW: z.string().default("0"),

apps/webapp/app/models/member.server.ts

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -100,35 +100,44 @@ export async function inviteMembers({
100100
throw new Error("User does not have access to this organization");
101101
}
102102

103-
const invites = [...new Set(emails)].map(
104-
(email) =>
105-
({
106-
email,
107-
token: tokenGenerator(),
108-
organizationId: org.id,
109-
inviterId: userId,
110-
role: "MEMBER",
111-
rbacRoleId: rbacRoleId ?? null,
112-
}) satisfies Prisma.OrgMemberInviteCreateManyInput
113-
);
114-
115-
await prisma.orgMemberInvite.createMany({
116-
data: invites,
117-
});
103+
// Create one invite per unique email and return ONLY the invites actually
104+
// created by this call. A P2002 means the email is already invited to this org
105+
// (unique org+email) — skip it so one duplicate can't fail the batch, and
106+
// don't return it: callers email exactly what they created, and re-sending an
107+
// already-pending invite is the dedicated resend flow's job (its own cooldown).
108+
const created: Prisma.OrgMemberInviteGetPayload<{
109+
include: { organization: true; inviter: true };
110+
}>[] = [];
111+
112+
for (const email of new Set(emails)) {
113+
try {
114+
const invite = await prisma.orgMemberInvite.create({
115+
data: {
116+
email,
117+
token: tokenGenerator(),
118+
organizationId: org.id,
119+
inviterId: userId,
120+
role: "MEMBER",
121+
rbacRoleId: rbacRoleId ?? null,
122+
},
123+
include: {
124+
organization: true,
125+
inviter: true,
126+
},
127+
});
128+
created.push(invite);
129+
} catch (error) {
130+
if (
131+
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
132+
error.code === "P2002"
133+
) {
134+
continue;
135+
}
136+
throw error;
137+
}
138+
}
118139

119-
return await prisma.orgMemberInvite.findMany({
120-
where: {
121-
organizationId: org.id,
122-
inviterId: userId,
123-
email: {
124-
in: emails,
125-
},
126-
},
127-
include: {
128-
organization: true,
129-
inviter: true,
130-
},
131-
});
140+
return created;
132141
}
133142

134143
export async function getInviteFromToken({ token }: { token: string }) {

apps/webapp/app/models/project.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { customAlphabet, nanoid } from "nanoid";
33
import slug from "slug";
44
import { $replica, prisma } from "~/db.server";
55
import { projectCreated } from "~/services/projectCreated.server";
6+
import { ServiceValidationError } from "~/v3/services/common.server";
67
import { type Organization, createEnvironment } from "./organization.server";
78
export type { Project } from "@trigger.dev/database";
89

@@ -50,7 +51,10 @@ export async function createProject(
5051

5152
if (version === "v3") {
5253
if (!organization.isActivated) {
53-
throw new Error(`Organization can't create v3 projects.`);
54+
throw new ServiceValidationError(
55+
"You must select a plan for this organization before creating projects.",
56+
402
57+
);
5458
}
5559
}
5660

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { PrismaClient } from "@trigger.dev/database";
2+
import { ServiceValidationError } from "~/v3/services/common.server";
23

34
// Leaf module with a type-only Prisma import (caller passes the client) so it
45
// can be unit-tested without importing `~/db.server`, which eagerly connects
5-
// the global prisma singleton.
6+
// the global prisma singleton. ServiceValidationError is a plain error class
7+
// with no imports, so it stays leaf-safe and lets callers map it to a status.
68
export async function removeTeamMember(
79
{
810
userId,
@@ -20,22 +22,35 @@ export async function removeTeamMember(
2022
});
2123

2224
if (!org) {
23-
throw new Error("User does not have access to this organization");
25+
throw new ServiceValidationError("User does not have access to this organization", 403);
2426
}
2527

26-
// Scope both the lookup and the delete to org.id, in a transaction, so the
27-
// member id is only ever resolved within the actor's organization.
28-
return prismaClient.$transaction(async (tx) => {
29-
const target = await tx.orgMember.findFirst({
30-
where: { id: memberId, organizationId: org.id },
31-
include: { organization: true, user: true },
32-
});
28+
// Serializable so the "keep at least one member" check and the delete are
29+
// atomic: at ReadCommitted two concurrent removals could each see >1 member
30+
// and both delete, orphaning the org. The guard lives here, not per-caller,
31+
// so every surface (dashboard + management API) is TOCTOU-safe. Raw
32+
// $transaction (not the ~/db.server helper) keeps this module leaf/testable.
33+
return prismaClient.$transaction(
34+
async (tx) => {
35+
// Scope both the lookup and the delete to org.id, so the member id is
36+
// only ever resolved within the actor's organization.
37+
const target = await tx.orgMember.findFirst({
38+
where: { id: memberId, organizationId: org.id },
39+
include: { organization: true, user: true },
40+
});
3341

34-
if (!target) {
35-
throw new Error("Member not found in this organization");
36-
}
42+
if (!target) {
43+
throw new ServiceValidationError("Member not found in this organization", 404);
44+
}
3745

38-
await tx.orgMember.delete({ where: { id: target.id } });
39-
return target;
40-
});
46+
const memberCount = await tx.orgMember.count({ where: { organizationId: org.id } });
47+
if (memberCount <= 1) {
48+
throw new ServiceValidationError("Cannot remove the last member of an organization", 400);
49+
}
50+
51+
await tx.orgMember.delete({ where: { id: target.id } });
52+
return target;
53+
},
54+
{ isolationLevel: "Serializable" }
55+
);
4156
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
MapPinIcon,
88
} from "@heroicons/react/20/solid";
99
import { Form } from "@remix-run/react";
10-
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
10+
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
1111
import { tryCatch } from "@trigger.dev/core";
1212
import { useState } from "react";
1313
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -51,9 +51,11 @@ import { useFeatures } from "~/hooks/useFeatures";
5151
import { useOrganization } from "~/hooks/useOrganizations";
5252
import { useHasAdminAccess } from "~/hooks/useUser";
5353
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
54+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
5455
import { findProjectBySlug } from "~/models/project.server";
5556
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
5657
import { requireUser } from "~/services/session.server";
58+
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
5759
import {
5860
docsPath,
5961
EnvironmentParamSchema,
@@ -90,44 +92,60 @@ const FormSchema = z.object({
9092
regionId: z.string(),
9193
});
9294

93-
export const action = async ({ request, params }: ActionFunctionArgs) => {
94-
const user = await requireUser(request);
95-
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
95+
export const action = dashboardAction(
96+
{
97+
params: EnvironmentParamSchema,
98+
context: async (params) => {
99+
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
100+
return orgId ? { organizationId: orgId } : {};
101+
},
102+
},
103+
async ({ user, ability, request, params }) => {
104+
const { organizationSlug, projectParam, envParam } = params;
96105

97-
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
106+
const redirectPath = regionsPath(
107+
{ slug: organizationSlug },
108+
{ slug: projectParam },
109+
{ slug: envParam }
110+
);
98111

99-
const redirectPath = regionsPath(
100-
{ slug: organizationSlug },
101-
{ slug: projectParam },
102-
{ slug: envParam }
103-
);
112+
if (!ability.can("manage", { type: "project" })) {
113+
throw await redirectWithErrorMessage(
114+
redirectPath,
115+
request,
116+
"You don't have permission to change the default region"
117+
);
118+
}
104119

105-
if (!project) {
106-
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
107-
}
120+
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
108121

109-
const formData = await request.formData();
110-
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
122+
if (!project) {
123+
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
124+
}
111125

112-
if (!parsedFormData.success) {
113-
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
114-
}
126+
const formData = await request.formData();
127+
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
115128

116-
const service = new SetDefaultRegionService();
117-
const [error, result] = await tryCatch(
118-
service.call({
119-
projectId: project.id,
120-
regionId: parsedFormData.data.regionId,
121-
isAdmin: user.admin || user.isImpersonating,
122-
})
123-
);
129+
if (!parsedFormData.success) {
130+
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
131+
}
124132

125-
if (error) {
126-
return redirectWithErrorMessage(redirectPath, request, error.message);
127-
}
133+
const service = new SetDefaultRegionService();
134+
const [error, result] = await tryCatch(
135+
service.call({
136+
projectId: project.id,
137+
regionId: parsedFormData.data.regionId,
138+
isAdmin: user.admin || user.isImpersonating,
139+
})
140+
);
128141

129-
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
130-
};
142+
if (error) {
143+
return redirectWithErrorMessage(redirectPath, request, error.message);
144+
}
145+
146+
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
147+
}
148+
);
131149

132150
export default function Page() {
133151
const { regions, isPaying: _isPaying } = useTypedLoaderData<typeof loader>();

0 commit comments

Comments
 (0)