Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/project-default-region-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Add `defaultRegion` (the project's default worker-group name, or null when unset) to the project GET and list API responses.
69 changes: 69 additions & 0 deletions apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { revokeInvite } from "~/models/member.server";
import { logger } from "~/services/logger.server";
import {
authorizePatOrganizationAccess,
resolveOrganizationForApiUser,
} from "~/services/organizationApiAccess.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";

const ParamsSchema = z.object({
orgParam: z.string(),
inviteId: z.string(),
});

export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "DELETE") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}

const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}

const { orgParam, inviteId } = parsedParams.data;

try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}

const organization = await resolveOrganizationForApiUser({
orgParam,
userId: authenticationResult.userId,
});

if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}

const denied = await authorizePatOrganizationAccess({
request,
organizationId: organization.id,
resource: "members",
action: "manage",
});
if (denied) return denied;

const revoked = await revokeInvite({
userId: authenticationResult.userId,
orgSlug: organization.slug,
inviteId,
});

return json({ id: inviteId, email: revoked.email });
} catch (error) {
if (error instanceof Response) throw error;
if (error instanceof Error && error.message === "Invite not found") {
return json({ error: error.message }, { status: 404 });
}
logger.error("Failed to revoke invite", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
106 changes: 106 additions & 0 deletions apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { env } from "~/env.server";
import { inviteMembers } from "~/models/member.server";
import { logger } from "~/services/logger.server";
import {
authorizePatOrganizationAccess,
resolveOrganizationForApiUser,
} from "~/services/organizationApiAccess.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { scheduleEmail } from "~/services/scheduleEmail.server";
import { acceptInvitePath } from "~/utils/pathBuilder";

const ParamsSchema = z.object({
orgParam: z.string(),
});

const InviteRequestBody = z.object({
emails: z.string().email().array().nonempty("At least one email is required"),
});

export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}

const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}

try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}

const organization = await resolveOrganizationForApiUser({
orgParam: parsedParams.data.orgParam,
userId: authenticationResult.userId,
});

if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}

const denied = await authorizePatOrganizationAccess({
request,
organizationId: organization.id,
resource: "members",
action: "manage",
});
if (denied) return denied;

let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return json({ error: "Invalid request body" }, { status: 400 });
}

const body = InviteRequestBody.safeParse(rawBody);

if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}

const invites = await inviteMembers({
slug: organization.slug,
emails: body.data.emails,
userId: authenticationResult.userId,
});

// Send invite emails the same way the dashboard invite action does. A
// failed send must not fail the invite (the row already exists); in local
// dev with no SMTP config, scheduleEmail's transport logs instead.
for (const invite of invites) {
try {
await scheduleEmail({
email: "invite",
to: invite.email,
orgName: invite.organization.title,
inviterName: invite.inviter.name ?? undefined,
inviterEmail: invite.inviter.email,
inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`,
});
} catch (error) {
logger.error("Failed to send invite email", { error });
}
}

return json(
{
invites: invites.map((invite) => ({ id: invite.id, email: invite.email })),
},
{ status: 201 }
);
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to invite org members", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
86 changes: 86 additions & 0 deletions apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { removeTeamMember } from "~/models/member.server";
import { logger } from "~/services/logger.server";
import {
authorizePatOrganizationAccess,
resolveOrganizationForApiUser,
} from "~/services/organizationApiAccess.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";

const ParamsSchema = z.object({
orgParam: z.string(),
memberId: z.string(),
});

export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "DELETE") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}

const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}

const { orgParam, memberId } = parsedParams.data;

try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}

const organization = await resolveOrganizationForApiUser({
orgParam,
userId: authenticationResult.userId,
});

if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}

const denied = await authorizePatOrganizationAccess({
request,
organizationId: organization.id,
resource: "members",
action: "manage",
});
if (denied) return denied;

// An org must keep at least one member. The dashboard guards this in the
// UI only; enforce it here since removeTeamMember doesn't.
const memberCount = await prisma.orgMember.count({
where: { organizationId: organization.id },
});
if (memberCount <= 1) {
return json({ error: "Cannot remove the last member of an organization" }, { status: 400 });
}

const removed = await removeTeamMember({
userId: authenticationResult.userId,
slug: organization.slug,
memberId,
});

return json({
id: removed.id,
user: {
id: removed.user.id,
name: removed.user.name,
email: removed.user.email,
},
});
} catch (error) {
if (error instanceof Response) throw error;
if (error instanceof Error && error.message === "Member not found in this organization") {
return json({ error: error.message }, { status: 404 });
}
logger.error("Failed to remove org member", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
83 changes: 83 additions & 0 deletions apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { getTeamMembersAndInvites } from "~/models/member.server";
import { logger } from "~/services/logger.server";
import {
authorizePatOrganizationAccess,
resolveOrganizationForApiUser,
} from "~/services/organizationApiAccess.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";

const ParamsSchema = z.object({
orgParam: z.string(),
});

export async function loader({ request, params }: LoaderFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}

try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}

const organization = await resolveOrganizationForApiUser({
orgParam: parsedParams.data.orgParam,
userId: authenticationResult.userId,
});

if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}

const denied = await authorizePatOrganizationAccess({
request,
organizationId: organization.id,
resource: "members",
action: "read",
});
if (denied) return denied;

const result = await getTeamMembersAndInvites({
userId: authenticationResult.userId,
organizationId: organization.id,
});

if (!result) {
return json({ error: "Organization not found" }, { status: 404 });
}

return json({
members: result.members.map((member) => ({
id: member.id,
role: member.role,
user: {
id: member.user.id,
name: member.user.name,
email: member.user.email,
avatarUrl: member.user.avatarUrl,
},
})),
invites: result.invites.map((invite) => ({
id: invite.id,
email: invite.email,
updatedAt: invite.updatedAt,
inviter: {
id: invite.inviter.id,
name: invite.inviter.name,
email: invite.inviter.email,
},
})),
});
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to list org members", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
14 changes: 14 additions & 0 deletions apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
},
include: {
organization: true,
defaultWorkerGroup: { select: { name: true } },
},
});

Expand All @@ -54,6 +55,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
defaultRegion: project.defaultWorkerGroup?.name ?? null,
organization: {
id: project.organization.id,
title: project.organization.title,
Expand Down Expand Up @@ -117,12 +119,24 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Failed to create project" }, { status: 400 });
}

// Derive from the stored id rather than assuming new projects are unset,
// so this stays correct if project creation ever inherits a default region.
const defaultRegion = project.defaultWorkerGroupId
? ((
await prisma.workerInstanceGroup.findFirst({
where: { id: project.defaultWorkerGroupId },
select: { name: true },
})
)?.name ?? null)
: null;

const result: GetProjectResponseBody = {
id: project.id,
externalRef: project.externalRef,
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
defaultRegion,
organization: {
id: project.organization.id,
title: project.organization.title,
Expand Down
Loading