diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 975f447d21b..57b3dfb2abb 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 { @@ -33,6 +33,7 @@ import { projectHttpEndpointsPath, projectPath, projectRunsPath, + projectSettingsPath, projectSetupPath, projectTriggersPath, } from "~/utils/pathBuilder"; @@ -56,9 +57,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 +106,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"} environment.slug === slug); - return environment; } diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 165171a64dc..e5bff604d72 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,21 +105,37 @@ 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; } @@ -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,7 @@ 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 redirect(newProjectPath({ slug: organizationSlug }), request); } return projects[0]; 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, 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..03fc9c547e1 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx @@ -0,0 +1,244 @@ +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"; +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 { 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"; +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 { + 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(); + try { + await deleteProjectService.call({ projectSlug: projectParam, userId }); + + //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), + }); + return redirectWithErrorMessage( + organizationPath({ slug: organizationSlug }), + request, + `Project ${projectParam} could not be 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/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 } />
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/currentProject.server.ts b/apps/webapp/app/services/currentProject.server.ts index 3a45e30008a..cebb20f1081 100644 --- a/apps/webapp/app/services/currentProject.server.ts +++ b/apps/webapp/app/services/currentProject.server.ts @@ -31,3 +31,9 @@ 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; +} 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/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, 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); 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 095da0ea0d0..065e76c52eb 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[] @@ -848,6 +849,8 @@ model JobRun { statuses JobRunStatusRecord[] autoYieldExecution JobRunAutoYieldExecution[] subscriptions JobRunSubscription[] + + @@index([jobId, createdAt(sort: Desc)], map: "idx_jobrun_jobId_createdAt") } enum JobRunStatus {