From 0f02b0f0c681d2c8255c7180d458f2642e309b34 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 30 Jan 2024 18:56:46 +0000 Subject: [PATCH 01/14] Side menu: selector title changed to project. Changed section headers and made it Slack + Discord --- .../app/components/navigation/SideMenu.tsx | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 975f447d21b..f0a23ee834c 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -4,7 +4,6 @@ import { ArrowRightOnRectangleIcon, ChartBarIcon, CursorArrowRaysIcon, - EllipsisHorizontalIcon, ShieldCheckIcon, } from "@heroicons/react/20/solid"; import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid"; @@ -15,6 +14,7 @@ import { useFeatures } from "~/hooks/useFeatures"; import { MatchedOrganization } from "~/hooks/useOrganizations"; import { MatchedProject } from "~/hooks/useProject"; import { User } from "~/models/user.server"; +import { useV3Enabled } from "~/root"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { cn } from "~/utils/cn"; import { @@ -56,9 +56,8 @@ import { PopoverSectionHeader, } from "../primitives/Popover"; import { StepNumber } from "../primitives/StepNumber"; -import { MenuCount, SideMenuItem } from "./SideMenuItem"; import { SideMenuHeader } from "./SideMenuHeader"; -import { useV3Enabled } from "~/root"; +import { MenuCount, SideMenuItem } from "./SideMenuItem"; type SideMenuUser = Pick & { isImpersonating: boolean }; type SideMenuProject = Pick< @@ -106,11 +105,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen showHeaderDivider ? " border-border" : "border-transparent" )} > - +
- +
- +
- {currentPlan?.subscription?.isPaying === true ? ( + {currentPlan?.subscription?.isPaying === true && ( - ) : ( - )} - + - {organization.title ?? "Select an organization"} + {project.name ?? "Select a project"} Date: Wed, 31 Jan 2024 10:52:10 +0000 Subject: [PATCH 02/14] Added project settings page. Renaming is working --- .../app/components/navigation/SideMenu.tsx | 8 + .../route.tsx | 227 ++++++++++++++++++ .../route.tsx | 7 - apps/webapp/app/utils/pathBuilder.ts | 4 + 4 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index f0a23ee834c..57b3dfb2abb 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -33,6 +33,7 @@ import { projectHttpEndpointsPath, projectPath, projectRunsPath, + projectSettingsPath, projectSetupPath, projectTriggersPath, } from "~/utils/pathBuilder"; @@ -163,6 +164,13 @@ export function SideMenu({ user, project, organization, organizations }: SideMen to={projectEnvironmentsPath(organization, project)} data-action="environments & api keys" /> +
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx new file mode 100644 index 00000000000..f60176c2ee2 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx @@ -0,0 +1,227 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData } from "@remix-run/react"; +import { ActionFunction, json } from "@remix-run/server-runtime"; +import { r } from "tar"; +import { z } from "zod"; +import { InlineCode } from "~/components/code/InlineCode"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader"; +import { prisma } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { DeleteProjectService } from "~/services/deleteProject.server"; +import { requireUserId } from "~/services/session.server"; +import { organizationPath, projectPath, projectSettingsPath } from "~/utils/pathBuilder"; + +export function createSchema( + constraints: { + getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; + } = {} +) { + return z.discriminatedUnion("action", [ + z.object({ + action: z.literal("rename"), + projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), + }), + z.object({ + action: z.literal("delete"), + projectSlug: z.string().superRefine((slug, ctx) => { + if (constraints.getSlugMatch === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: conform.VALIDATION_UNDEFINED, + }); + } else { + // Tell zod this is an async validation by returning the promise + const { isMatch, projectSlug } = constraints.getSlugMatch(slug); + if (isMatch) { + return; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The slug must match ${projectSlug}`, + }); + } + }), + }), + ]); +} + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = params; + if (!organizationSlug || !projectParam) { + return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); + } + + const formData = await request.formData(); + + const schema = createSchema({ + getSlugMatch: (slug) => { + return { isMatch: slug === projectParam, projectSlug: projectParam }; + }, + }); + const submission = parse(formData, { schema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + try { + switch (submission.value.action) { + case "rename": { + await prisma.project.update({ + where: { + slug: projectParam, + }, + data: { + name: submission.value.projectName, + }, + }); + + return redirectWithSuccessMessage( + projectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project renamed to ${submission.value.projectName}` + ); + } + case "delete": { + const deleteProjectService = new DeleteProjectService(); + await deleteProjectService.call({ projectSlug: projectParam, userId }); + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + `Project ${projectParam} deleted` + ); + } + } + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } +}; + +export default function Page() { + const organization = useOrganization(); + const project = useProject(); + const lastSubmission = useActionData(); + + const [renameForm, { projectName }] = useForm({ + id: "rename-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema(), + }); + }, + }); + + const [deleteForm, { projectSlug }] = useForm({ + id: "delete-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldValidate: "onInput", + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema({ + getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), + }), + }); + }, + }); + + return ( + + + + + + + + +
+
+
+ +
+ + + + {projectName.error} + + + Rename project + + } + /> +
+
+
+ +
+ Danger zone +
+ +
+ + + + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete project + + } + /> +
+
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx index 469bde29da2..fc88223dced 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx @@ -1,16 +1,9 @@ import { Outlet } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { typedjson } from "remix-typedjson"; -import invariant from "tiny-invariant"; import { RouteErrorDisplay } from "~/components/ErrorDisplay"; import { BreadcrumbLink } from "~/components/navigation/Breadcrumb"; import { organizationMatchId, useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useTypedMatchData } from "~/hooks/useTypedMatchData"; -import { ProjectPresenter } from "~/presenters/ProjectPresenter.server"; -import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server"; -import { requireUserId } from "~/services/session.server"; -import { telemetry } from "~/services/telemetry.server"; import { Handle } from "~/utils/handle"; import { projectPath } from "~/utils/pathBuilder"; import { loader as orgLoader } from "../_app.orgs.$organizationSlug/route"; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index fd4b4208402..ee60278fefe 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -216,6 +216,10 @@ export function projectEventsPath(organization: OrgForPath, project: ProjectForP return `${projectPath(organization, project)}/events`; } +export function projectSettingsPath(organization: OrgForPath, project: ProjectForPath) { + return `${projectPath(organization, project)}/settings`; +} + export function projectEventPath( organization: OrgForPath, project: ProjectForPath, From 627ddd8548a6b18c2ab9589322f6f1f9af707bf7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 31 Jan 2024 10:52:19 +0000 Subject: [PATCH 03/14] Added project.deletedAt column --- packages/database/prisma/schema.prisma | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 095da0ea0d0..ca9e2095712 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -398,8 +398,9 @@ model Project { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) organizationId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? environments RuntimeEnvironment[] endpoints Endpoint[] From ce9506f367ce02f3bf3ff72aa5c131cb10f8d32e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 31 Jan 2024 14:15:33 +0000 Subject: [PATCH 04/14] Deleting projects WIP --- .../route.tsx | 26 +++- ...nvironmentParam.endpoint.$endpointParam.ts | 4 +- .../app/services/deleteProject.server.ts | 142 ++++++++++++++++++ .../endpoints/deleteEndpointService.ts | 2 +- .../app/services/jobs/deleteJob.server.ts | 2 + .../migration.sql | 5 + 6 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 apps/webapp/app/services/deleteProject.server.ts create mode 100644 packages/database/prisma/migrations/20240131105237_project_deleted_at/migration.sql diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx index f60176c2ee2..aad8a5af391 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx @@ -19,8 +19,9 @@ import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/Pag import { prisma } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { redirectWithSuccessMessage } from "~/models/message.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { DeleteProjectService } from "~/services/deleteProject.server"; +import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { organizationPath, projectPath, projectSettingsPath } from "~/utils/pathBuilder"; @@ -99,12 +100,23 @@ export const action: ActionFunction = async ({ request, params }) => { } case "delete": { const deleteProjectService = new DeleteProjectService(); - await deleteProjectService.call({ projectSlug: projectParam, userId }); - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - `Project ${projectParam} deleted` - ); + try { + await deleteProjectService.call({ projectSlug: projectParam, userId }); + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + `Project ${projectParam} deleted` + ); + } catch (error: unknown) { + logger.error("Project could not be deleted", { + error: error instanceof Error ? error.message : JSON.stringify(error), + }); + return redirectWithErrorMessage( + organizationPath({ slug: organizationSlug }), + request, + `Project ${projectParam} could not be deleted` + ); + } } } } catch (error: any) { diff --git a/apps/webapp/app/routes/resources.environments.$environmentParam.endpoint.$endpointParam.ts b/apps/webapp/app/routes/resources.environments.$environmentParam.endpoint.$endpointParam.ts index 9b6fd3aa04e..f4483e331ff 100644 --- a/apps/webapp/app/routes/resources.environments.$environmentParam.endpoint.$endpointParam.ts +++ b/apps/webapp/app/routes/resources.environments.$environmentParam.endpoint.$endpointParam.ts @@ -1,6 +1,6 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { DeleteEndpointIndexService } from "~/services/endpoints/deleteEndpointService"; +import { DeleteEndpointService } from "~/services/endpoints/deleteEndpointService"; import { IndexEndpointService } from "~/services/endpoints/indexEndpoint.server"; import { requireUserId } from "~/services/session.server"; import { workerQueue } from "~/services/worker.server"; @@ -42,7 +42,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ success: true }); } case "delete": { - const service = new DeleteEndpointIndexService(); + const service = new DeleteEndpointService(); await service.call(endpointParam, userId); return json({ success: true }); } diff --git a/apps/webapp/app/services/deleteProject.server.ts b/apps/webapp/app/services/deleteProject.server.ts new file mode 100644 index 00000000000..8b030c3faa2 --- /dev/null +++ b/apps/webapp/app/services/deleteProject.server.ts @@ -0,0 +1,142 @@ +import { PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { DisableJobService } from "./jobs/disableJob.server"; +import { AuthenticatedEnvironment } from "./apiAuth.server"; +import { DeleteJobService } from "./jobs/deleteJob.server"; +import { DeleteEndpointService } from "./endpoints/deleteEndpointService"; +import { logger } from "./logger.server"; +import { DisableScheduleSourceService } from "./schedules/disableScheduleSource.server"; + +type Options = { projectId: string; userId: string } | { projectSlug: string; userId: string }; + +export class DeleteProjectService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call(options: Options) { + const projectId = await this.#getProjectId(options); + const project = await this.#prismaClient.project.findFirst({ + include: { + environments: { + include: { + endpoints: true, + }, + }, + jobs: { + where: { deletedAt: null }, + include: { + aliases: { + where: { + name: "latest", + }, + include: { + version: true, + }, + take: 1, + }, + }, + }, + organization: true, + }, + where: { + id: projectId, + organization: { members: { some: { userId: options.userId } } }, + }, + }); + + if (!project) { + throw new Error("Project not found"); + } + + if (project.deletedAt) { + throw new Error("Project already deleted"); + } + + //disable and delete all jobs + const service = new DisableScheduleSourceService(); + for (const environment of project.environments) { + //disable the event dispatchers + await this.#prismaClient.eventDispatcher.updateMany({ + where: { + environmentId: environment.id, + }, + data: { + enabled: false, + }, + }); + const eventDispatchers = await this.#prismaClient.eventDispatcher.findMany({ + where: { + environmentId: environment.id, + }, + }); + + logger.info("Deleting jobs", { jobs: project.jobs }); + for (const job of project.jobs) { + //disable all the job versions + await this.#prismaClient.jobVersion.updateMany({ + where: { + jobId: job.id, + }, + data: { + status: "DISABLED", + }, + }); + + await this.#prismaClient.job.update({ + where: { + id: job.id, + }, + data: { + deletedAt: new Date(), + }, + }); + + //disable scheduled sources + for (const eventDispatcher of eventDispatchers) { + await service.call({ + key: job.id, + dispatcher: eventDispatcher, + }); + } + } + } + + //delete all endpoints + const deleteEndpointService = new DeleteEndpointService(); + for (const environment of project.environments) { + for (const endpoint of environment.endpoints) { + await deleteEndpointService.call(endpoint.id, options.userId); + } + } + + //mark the project as deleted + await this.#prismaClient.project.update({ + where: { + id: project.id, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + async #getProjectId(options: Options) { + if ("projectId" in options) { + return options.projectId; + } + + const { id } = await this.#prismaClient.project.findFirstOrThrow({ + select: { + id: true, + }, + where: { + slug: options.projectSlug, + }, + }); + + return id; + } +} diff --git a/apps/webapp/app/services/endpoints/deleteEndpointService.ts b/apps/webapp/app/services/endpoints/deleteEndpointService.ts index 53b766d4530..6c222a21938 100644 --- a/apps/webapp/app/services/endpoints/deleteEndpointService.ts +++ b/apps/webapp/app/services/endpoints/deleteEndpointService.ts @@ -1,7 +1,7 @@ import { PrismaClient } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -export class DeleteEndpointIndexService { +export class DeleteEndpointService { #prismaClient: PrismaClient; constructor(prismaClient: PrismaClient = prisma) { diff --git a/apps/webapp/app/services/jobs/deleteJob.server.ts b/apps/webapp/app/services/jobs/deleteJob.server.ts index 975f10f46af..e4b7f409064 100644 --- a/apps/webapp/app/services/jobs/deleteJob.server.ts +++ b/apps/webapp/app/services/jobs/deleteJob.server.ts @@ -2,6 +2,7 @@ import type { Job } from "@trigger.dev/database"; import type { PrismaClient } from "~/db.server"; import { prisma } from "~/db.server"; import { telemetry } from "../telemetry.server"; +import { logger } from "../logger.server"; export class DeleteJobService { #prismaClient: PrismaClient; @@ -25,6 +26,7 @@ export class DeleteJobService { const allDisabled = latestVersions.every((alias) => alias.version.status === "DISABLED"); if (!allDisabled) { + logger.info("Not all latest versions are disabled, cannot delete job", { jobId: job.id }); throw new Error("All latest versions must be disabled before deleting a job"); } diff --git a/packages/database/prisma/migrations/20240131105237_project_deleted_at/migration.sql b/packages/database/prisma/migrations/20240131105237_project_deleted_at/migration.sql new file mode 100644 index 00000000000..dd203bed4ea --- /dev/null +++ b/packages/database/prisma/migrations/20240131105237_project_deleted_at/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "idx_jobrun_jobid_createdat"; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "deletedAt" TIMESTAMP(3); From 79e69bd6f8599e62ec76be23d580597e8e2f8a1b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 31 Jan 2024 18:24:12 +0000 Subject: [PATCH 05/14] Allow clearing the project from the session --- apps/webapp/app/services/currentProject.server.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/webapp/app/services/currentProject.server.ts b/apps/webapp/app/services/currentProject.server.ts index 3a45e30008a..b9c471f0cca 100644 --- a/apps/webapp/app/services/currentProject.server.ts +++ b/apps/webapp/app/services/currentProject.server.ts @@ -31,3 +31,8 @@ export async function setCurrentProjectId(id: string, request: Request) { session.set("currentProjectId", id); return session; } +export async function clearCurrentProjectId(request: Request) { + const session = await getCurrentProjectSession(request); + session.unset("currentProjectId"); + return session; +} From 0697054889388a3d1a30641a9f1b577df0442ce4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 31 Jan 2024 18:30:04 +0000 Subject: [PATCH 06/14] =?UTF-8?q?Don=E2=80=99t=20load=20projects=20that=20?= =?UTF-8?q?are=20deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/presenters/ProjectPresenter.server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/ProjectPresenter.server.ts b/apps/webapp/app/presenters/ProjectPresenter.server.ts index 86c87f2df61..4c0f4926e30 100644 --- a/apps/webapp/app/presenters/ProjectPresenter.server.ts +++ b/apps/webapp/app/presenters/ProjectPresenter.server.ts @@ -23,6 +23,7 @@ export class ProjectPresenter { organizationId: true, createdAt: true, updatedAt: true, + deletedAt: true, _count: { select: { sources: { @@ -53,7 +54,7 @@ export class ProjectPresenter { }, }, }, - where: { id, organization: { members: { some: { userId } } } }, + where: { id, deletedAt: null, organization: { members: { some: { userId } } } }, }); if (!project) { @@ -67,6 +68,7 @@ export class ProjectPresenter { organizationId: project.organizationId, createdAt: project.createdAt, updatedAt: project.updatedAt, + deletedAt: project.deletedAt, hasInactiveExternalTriggers: project._count.sources > 0, jobCount: project._count.jobs, httpEndpointCount: project._count.httpEndpoints, From 01506d07a17631cf229e4b50184e378d5bf8e894 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 31 Jan 2024 18:36:50 +0000 Subject: [PATCH 07/14] Improved the project selection logic --- .../OrganizationsPresenter.server.ts | 64 +++++++++++++++---- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 165171a64dc..a25283dba0c 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -2,6 +2,7 @@ import { PrismaClient } from "@trigger.dev/database"; import { redirect } from "remix-typedjson"; import { prisma } from "~/db.server"; import { + clearCurrentProjectId, commitCurrentProjectSession, getCurrentProjectId, setCurrentProjectId, @@ -9,6 +10,8 @@ import { import { logger } from "~/services/logger.server"; import { newProjectPath } from "~/utils/pathBuilder"; import { ProjectPresenter } from "./ProjectPresenter.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { match } from "assert"; export class OrganizationsPresenter { #prismaClient: PrismaClient; @@ -54,7 +57,11 @@ export class OrganizationsPresenter { }); if (!project) { - throw new Response("Project not found", { status: 404 }); + throw redirectWithErrorMessage( + newProjectPath({ slug: organizationSlug }), + request, + "No projects found in organization" + ); } return { organizations, organization, project }; @@ -75,16 +82,21 @@ export class OrganizationsPresenter { //no project in session, let's set one if (!sessionProjectId) { + //no session id and no project slug so we need to select the best project if (!projectSlug) { - const bestProject = await this.#selectBestProjectForOrganization(organizationSlug, userId); + const bestProject = await this.#selectBestProjectForOrganization( + organizationSlug, + userId, + request + ); const session = await setCurrentProjectId(bestProject.id, request); throw redirect(request.url, { headers: { "Set-Cookie": await commitCurrentProjectSession(session) }, }); } - //use the project param to find the project - const project = await prisma.project.findFirst({ + //get all the projects + const projects = await prisma.project.findMany({ select: { id: true, slug: true, @@ -93,24 +105,40 @@ export class OrganizationsPresenter { organization: { slug: organizationSlug, }, + deletedAt: null, slug: projectSlug, }, + orderBy: { + updatedAt: "desc", + }, }); - if (!project) { - throw redirect(newProjectPath({ slug: organizationSlug })); + if (projects.length === 0) { + throw redirectWithErrorMessage( + newProjectPath({ slug: organizationSlug }), + request, + "No projects in this organization" + ); } - const session = await setCurrentProjectId(project.id, request); + //try get the project which matches the URL + let matchingProject = projects.find((p) => p.slug === projectSlug); + + //if there's no matching project, just use the most recently updated one + if (!matchingProject) { + matchingProject = projects[0]; + } + + //set the session + const session = await setCurrentProjectId(matchingProject.id, request); throw redirect(request.url, { headers: { "Set-Cookie": await commitCurrentProjectSession(session) }, }); } - //no project slug, so just return the session id if (!projectSlug) { - return sessionProjectId; - } + return sessionProjectId; + } //check session id matches the project slug const project = await prisma.project.findFirst({ @@ -123,6 +151,7 @@ export class OrganizationsPresenter { organization: { slug: organizationSlug, }, + deletedAt: null, }, }); @@ -150,6 +179,7 @@ export class OrganizationsPresenter { title: true, runsEnabled: true, projects: { + where: { deletedAt: null }, select: { id: true, slug: true, @@ -202,13 +232,18 @@ export class OrganizationsPresenter { }); } - async #selectBestProjectForOrganization(organizationSlug: string, userId: string) { + async #selectBestProjectForOrganization( + organizationSlug: string, + userId: string, + request: Request + ) { const projects = await this.#prismaClient.project.findMany({ select: { id: true, slug: true, }, where: { + deletedAt: null, organization: { slug: organizationSlug, members: { some: { userId } }, @@ -223,8 +258,11 @@ export class OrganizationsPresenter { }); if (projects.length === 0) { - logger.info("Didn't find a project in this org", { organizationSlug, projects }); - throw new Response("Not Found", { status: 404 }); + throw redirectWithErrorMessage( + newProjectPath({ slug: organizationSlug }), + request, + "No projects found" + ); } return projects[0]; From 322d8ce6165cb8cfcbe2713748ee0e254f29b83e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 31 Jan 2024 18:51:59 +0000 Subject: [PATCH 08/14] Clear the session project id when deleting a project --- .../route.tsx | 16 +++++++++++----- .../webapp/app/services/currentProject.server.ts | 1 + 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx index aad8a5af391..9c815c82286 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx @@ -2,6 +2,7 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { Form, useActionData } from "@remix-run/react"; import { ActionFunction, json } from "@remix-run/server-runtime"; +import { redirect } from "remix-typedjson"; import { r } from "tar"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; @@ -20,6 +21,10 @@ import { prisma } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { + clearCurrentProjectId, + commitCurrentProjectSession, +} from "~/services/currentProject.server"; import { DeleteProjectService } from "~/services/deleteProject.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; @@ -102,11 +107,12 @@ export const action: ActionFunction = async ({ request, params }) => { const deleteProjectService = new DeleteProjectService(); try { await deleteProjectService.call({ projectSlug: projectParam, userId }); - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - `Project ${projectParam} deleted` - ); + + //we need to clear the project from the session + const removeProjectIdSession = await clearCurrentProjectId(request); + return redirect(organizationPath({ slug: organizationSlug }), { + headers: { "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession) }, + }); } catch (error: unknown) { logger.error("Project could not be deleted", { error: error instanceof Error ? error.message : JSON.stringify(error), diff --git a/apps/webapp/app/services/currentProject.server.ts b/apps/webapp/app/services/currentProject.server.ts index b9c471f0cca..cebb20f1081 100644 --- a/apps/webapp/app/services/currentProject.server.ts +++ b/apps/webapp/app/services/currentProject.server.ts @@ -31,6 +31,7 @@ export async function setCurrentProjectId(id: string, request: Request) { session.set("currentProjectId", id); return session; } + export async function clearCurrentProjectId(request: Request) { const session = await getCurrentProjectSession(request); session.unset("currentProjectId"); From 75197ff7923c0dddb1c14892faee2d74187cc22e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 2 Feb 2024 11:15:16 +0000 Subject: [PATCH 09/14] =?UTF-8?q?Deleting=20the=20last=20project=20in=20an?= =?UTF-8?q?=20org=20now=20works.=20The=20new=20project=20page=20doesn?= =?UTF-8?q?=E2=80=99t=20have=20the=20sidebar=20anymore.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OrganizationsPresenter.server.ts | 10 +--- .../route.tsx | 60 ++++++++++++++++--- 2 files changed, 54 insertions(+), 16 deletions(-) rename apps/webapp/app/routes/{_app.orgs.$organizationSlug.projects.new => _app.orgs.$organizationSlug_.projects.new}/route.tsx (63%) diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index a25283dba0c..e5bff604d72 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -137,8 +137,8 @@ export class OrganizationsPresenter { } if (!projectSlug) { - return sessionProjectId; - } + return sessionProjectId; + } //check session id matches the project slug const project = await prisma.project.findFirst({ @@ -258,11 +258,7 @@ export class OrganizationsPresenter { }); if (projects.length === 0) { - throw redirectWithErrorMessage( - newProjectPath({ slug: organizationSlug }), - request, - "No projects found" - ); + throw redirect(newProjectPath({ slug: organizationSlug }), request); } return projects[0]; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx similarity index 63% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.new/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index 1cbaa42f8b3..61bb1cb6db3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -1,12 +1,14 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import type { ActionFunction } from "@remix-run/node"; +import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; import { MainCenteredContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; @@ -14,11 +16,44 @@ import { FormTitle } from "~/components/primitives/FormTitle"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; -import { useOrganization } from "~/hooks/useOrganizations"; +import { prisma } from "~/db.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { createProject } from "~/models/project.server"; import { requireUserId } from "~/services/session.server"; -import { organizationPath, projectPath } from "~/utils/pathBuilder"; +import { OrganizationParamsSchema, organizationPath, projectPath } from "~/utils/pathBuilder"; + +export async function loader({ params, request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const organization = await prisma.organization.findUnique({ + where: { slug: organizationSlug, members: { some: { userId } } }, + select: { + id: true, + title: true, + _count: { + select: { + projects: { + where: { deletedAt: null }, + }, + }, + }, + }, + }); + + if (!organization) { + throw new Response(null, { status: 404, statusText: "Organization not found" }); + } + + return typedjson({ + organization: { + id: organization.id, + title: organization.title, + slug: organizationSlug, + projectsCount: organization._count.projects, + }, + }); +} const schema = z.object({ projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), @@ -54,7 +89,7 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function NewOrganizationPage() { - const organization = useOrganization(); + const { organization } = useTypedLoaderData(); const lastSubmission = useActionData(); const [form, { projectName }] = useForm({ @@ -71,10 +106,15 @@ export default function NewOrganizationPage() {
+ {organization.projectsCount === 0 && ( + + Organizations require at least one project, please create one to continue. + + )}
@@ -93,9 +133,11 @@ export default function NewOrganizationPage() { } cancelButton={ - - Cancel - + organization.projectsCount > 0 ? ( + + Cancel + + ) : undefined } />
From ac6675d55bd8a3f15bec80b0e781ab3589e1df6a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 2 Feb 2024 11:35:03 +0000 Subject: [PATCH 10/14] Removed false code comment --- .../route.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx index 9c815c82286..03fc9c547e1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx @@ -49,7 +49,6 @@ export function createSchema( message: conform.VALIDATION_UNDEFINED, }); } else { - // Tell zod this is an async validation by returning the promise const { isMatch, projectSlug } = constraints.getSlugMatch(slug); if (isMatch) { return; From e1793044083b87fb73519682c34bfbdf00ecede5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 2 Feb 2024 11:53:39 +0000 Subject: [PATCH 11/14] Added the JobRun index back in, using Prisma. Otherwise it tries to remove it on each migration --- .../migration.sql | 2 ++ packages/database/prisma/schema.prisma | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 packages/database/prisma/migrations/20240202115155_added_job_run_index_back_in_using_prisma_schema/migration.sql diff --git a/packages/database/prisma/migrations/20240202115155_added_job_run_index_back_in_using_prisma_schema/migration.sql b/packages/database/prisma/migrations/20240202115155_added_job_run_index_back_in_using_prisma_schema/migration.sql new file mode 100644 index 00000000000..dd1abb874b5 --- /dev/null +++ b/packages/database/prisma/migrations/20240202115155_added_job_run_index_back_in_using_prisma_schema/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "idx_jobrun_jobId_createdAt" ON "JobRun"("jobId", "createdAt" DESC); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index ca9e2095712..065e76c52eb 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -849,6 +849,8 @@ model JobRun { statuses JobRunStatusRecord[] autoYieldExecution JobRunAutoYieldExecution[] subscriptions JobRunSubscription[] + + @@index([jobId, createdAt(sort: Desc)], map: "idx_jobrun_jobId_createdAt") } enum JobRunStatus { From 12c5229f4c161c4d6d7fb1b7d6f32d24fbc1118c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 2 Feb 2024 12:43:47 +0000 Subject: [PATCH 12/14] =?UTF-8?q?Only=20return=20environments=20where=20th?= =?UTF-8?q?e=20project=20isn=E2=80=99t=20deleted.=20This=20will=20block=20?= =?UTF-8?q?API=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/models/runtimeEnvironment.server.ts | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 344716fa89d..007cb040b32 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -7,6 +7,9 @@ export async function findEnvironmentByApiKey(apiKey: string) { const environment = await prisma.runtimeEnvironment.findUnique({ where: { apiKey, + project: { + deletedAt: null, + }, }, include: { project: true, @@ -21,6 +24,9 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) { const environment = await prisma.runtimeEnvironment.findUnique({ where: { pkApiKey: apiKey, + project: { + deletedAt: null, + }, }, include: { project: true, @@ -30,22 +36,3 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) { return environment; } - -export async function getEnvironmentForOrganization(organizationSlug: string, slug: string) { - const organization = await prisma.organization.findUnique({ - where: { - slug: organizationSlug, - }, - include: { - environments: true, - }, - }); - - if (!organization) { - return; - } - - const environment = organization.environments.find((environment) => environment.slug === slug); - - return environment; -} From d76877b46abddd5e9f33b05909b688a2d1e2582e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 2 Feb 2024 16:12:07 +0000 Subject: [PATCH 13/14] Do the project deletedAt check in code, not SQL --- apps/webapp/app/models/runtimeEnvironment.server.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 007cb040b32..161dc519311 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -7,9 +7,6 @@ export async function findEnvironmentByApiKey(apiKey: string) { const environment = await prisma.runtimeEnvironment.findUnique({ where: { apiKey, - project: { - deletedAt: null, - }, }, include: { project: true, @@ -17,6 +14,10 @@ export async function findEnvironmentByApiKey(apiKey: string) { }, }); + if (environment?.project.deletedAt !== null) { + return null; + } + return environment; } From 319b115d4826b640a5c9c18ed54a5065d70ef3fb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 2 Feb 2024 16:57:07 +0000 Subject: [PATCH 14/14] Made it clearer what the code does with a comment and applied the same logic to the public api key --- apps/webapp/app/models/runtimeEnvironment.server.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 161dc519311..6e5c50ccd22 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -14,6 +14,7 @@ export async function findEnvironmentByApiKey(apiKey: string) { }, }); + //don't return deleted projects if (environment?.project.deletedAt !== null) { return null; } @@ -25,9 +26,6 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) { const environment = await prisma.runtimeEnvironment.findUnique({ where: { pkApiKey: apiKey, - project: { - deletedAt: null, - }, }, include: { project: true, @@ -35,5 +33,10 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) { }, }); + //don't return deleted projects + if (environment?.project.deletedAt !== null) { + return null; + } + return environment; }