Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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 });
}
}
99 changes: 99 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,99 @@
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;

const body = InviteRequestBody.safeParse(await request.json());

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