Replace project deletion with archive/restore (soft delete) (#257)

Project deletion cascaded through saved keywords, rank tracking, and
audits, and the endpoint had no role check (Codex security finding).
Instead of gating a destructive delete, remove it: archiving stamps
archived_at, hides the project everywhere (lists, project context,
rank-check cron), and preserves all data. Archived projects can be
restored from the Manage projects page.

The restore input is named archivedProjectId because the global
ensureUserMiddleware resolves any projectId in input data against
active projects and would 404 before the handler runs.
This commit is contained in:
Ben Senescu 2026-06-10 15:42:55 -04:00 committed by GitHub
parent f7e5729a09
commit 977c69c3de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 2883 additions and 61 deletions

View File

@ -0,0 +1,3 @@
DROP INDEX `projects_one_default_per_organization_idx`;--> statement-breakpoint
ALTER TABLE `projects` ADD `archived_at` text;--> statement-breakpoint
CREATE UNIQUE INDEX `projects_one_default_per_organization_idx` ON `projects` (`organization_id`) WHERE "projects"."name" = 'Default' AND "projects"."domain" IS NULL AND "projects"."archived_at" IS NULL;

File diff suppressed because it is too large Load Diff

View File

@ -155,6 +155,13 @@
"when": 1780625404317, "when": 1780625404317,
"tag": "0021_autumn_billing", "tag": "0021_autumn_billing",
"breakpoints": true "breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1781107930529,
"tag": "0022_purple_hitman",
"breakpoints": true
} }
] ]
} }

View File

@ -10,7 +10,7 @@ import {
getLastProjectId, getLastProjectId,
} from "@/client/lib/active-project"; } from "@/client/lib/active-project";
import { import {
deleteProject, archiveProject,
getProjects, getProjects,
updateProject, updateProject,
} from "@/serverFunctions/projects"; } from "@/serverFunctions/projects";
@ -60,7 +60,7 @@ export function ProjectSettings({ projectId }: { projectId: string }) {
<SearchConsoleConnectionCard projectId={projectId} /> <SearchConsoleConnectionCard projectId={projectId} />
</section> </section>
<DangerSection project={project} canDelete={projects.length > 1} /> <DangerSection project={project} canArchive={projects.length > 1} />
</div> </div>
); );
} }
@ -146,58 +146,58 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
function DangerSection({ function DangerSection({
project, project,
canDelete, canArchive,
}: { }: {
project: ProjectSummary; project: ProjectSummary;
canDelete: boolean; canArchive: boolean;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [confirming, setConfirming] = React.useState(false); const [confirming, setConfirming] = React.useState(false);
const deleteMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: () => deleteProject({ data: { projectId: project.id } }), mutationFn: () => archiveProject({ data: { projectId: project.id } }),
onSuccess: async () => { onSuccess: async () => {
if (getLastProjectId() === project.id) clearLastProjectId(); if (getLastProjectId() === project.id) clearLastProjectId();
await queryClient.invalidateQueries({ queryKey: ["projects"] }); await queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.success("Project deleted"); toast.success("Project archived");
// Re-resolve to a remaining project via the landing redirect. // Re-resolve to a remaining project via the landing redirect.
void navigate({ to: "/" }); void navigate({ to: "/" });
}, },
onError: (error) => onError: (error) =>
toast.error(getStandardErrorMessage(error, "Failed to delete project")), toast.error(getStandardErrorMessage(error, "Failed to archive project")),
}); });
return ( return (
<section className="space-y-3 border-t border-base-300 pt-8"> <section className="space-y-3 border-t border-base-300 pt-8">
<h2 className="text-sm font-medium text-base-content/50"> <h2 className="text-sm font-medium text-base-content/50">
Delete project Archive project
</h2> </h2>
{confirming ? ( {confirming ? (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-base-content/70"> <p className="text-sm text-base-content/70">
Deleting{" "} Archiving{" "}
<span className="font-medium text-base-content"> <span className="font-medium text-base-content">
{project.name} {project.name}
</span>{" "} </span>{" "}
permanently removes its Search Console connection, rank tracking, removes it from your workspace and stops its scheduled rank
audits, and saved keywords. This can't be undone. tracking. You can restore it later from the Projects page.
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
className="btn btn-error btn-sm" className="btn btn-error btn-sm"
onClick={() => deleteMutation.mutate()} onClick={() => archiveMutation.mutate()}
disabled={deleteMutation.isPending} disabled={archiveMutation.isPending}
> >
Yes, delete project Yes, archive project
</button> </button>
<button <button
type="button" type="button"
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)} onClick={() => setConfirming(false)}
disabled={deleteMutation.isPending} disabled={archiveMutation.isPending}
> >
Cancel Cancel
</button> </button>
@ -206,17 +206,17 @@ function DangerSection({
) : ( ) : (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/60">
{canDelete {canArchive
? "Permanently delete this project and all of its data." ? "Archive this project to remove it from your workspace."
: "You can't delete your only project."} : "You can't archive your only project."}
</p> </p>
<button <button
type="button" type="button"
className="btn btn-outline btn-error btn-sm shrink-0" className="btn btn-outline btn-error btn-sm shrink-0"
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
disabled={!canDelete} disabled={!canArchive}
> >
Delete project Archive project
</button> </button>
</div> </div>
)} )}

View File

@ -53,6 +53,9 @@ export const projects = sqliteTable(
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
// Soft delete: archived projects are hidden everywhere but their data
// (keywords, rank tracking, audits) is preserved.
archivedAt: text("archived_at"),
}, },
(table) => [ (table) => [
// Only the auto-created Default/null-domain project is a singleton. This // Only the auto-created Default/null-domain project is a singleton. This
@ -61,7 +64,9 @@ export const projects = sqliteTable(
// creating multiple projects with the same name or domain later. // creating multiple projects with the same name or domain later.
uniqueIndex("projects_one_default_per_organization_idx") uniqueIndex("projects_one_default_per_organization_idx")
.on(table.organizationId) .on(table.organizationId)
.where(sql`${table.name} = 'Default' AND ${table.domain} IS NULL`), .where(
sql`${table.name} = 'Default' AND ${table.domain} IS NULL AND ${table.archivedAt} IS NULL`,
),
], ],
); );

View File

@ -1,8 +1,14 @@
import * as React from "react"; import * as React from "react";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react"; import { ChevronRight, Plus } from "lucide-react";
import { getProjects } from "@/serverFunctions/projects"; import { toast } from "sonner";
import {
getArchivedProjects,
getProjects,
restoreProject,
} from "@/serverFunctions/projects";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getLastProjectId } from "@/client/lib/active-project"; import { getLastProjectId } from "@/client/lib/active-project";
import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal"; import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal";
@ -80,6 +86,8 @@ function ProjectsPage() {
))} ))}
</ul> </ul>
)} )}
<ArchivedProjects />
</div> </div>
{creating ? ( {creating ? (
@ -88,3 +96,57 @@ function ProjectsPage() {
</div> </div>
); );
} }
function ArchivedProjects() {
const queryClient = useQueryClient();
const archivedQuery = useQuery({
queryKey: ["projects", "archived"],
queryFn: () => getArchivedProjects(),
});
const archived = archivedQuery.data ?? [];
const restoreMutation = useMutation({
mutationFn: (projectId: string) =>
restoreProject({ data: { archivedProjectId: projectId } }),
onSuccess: async () => {
// Prefix match invalidates both the active and archived lists.
await queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.success("Project restored");
},
onError: (error) =>
toast.error(getStandardErrorMessage(error, "Failed to restore project")),
});
if (archived.length === 0) return null;
return (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">Archived</h2>
<ul className="divide-y divide-base-300 overflow-hidden rounded-lg border border-base-300">
{archived.map((project) => (
<li
key={project.id}
className="flex items-center justify-between gap-3 p-3"
>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium text-base-content/70">
{project.name}
</span>
<span className="truncate text-xs text-base-content/50">
{project.domain ?? "No domain set"}
</span>
</span>
<button
type="button"
className="btn btn-ghost btn-sm shrink-0"
onClick={() => restoreMutation.mutate(project.id)}
disabled={restoreMutation.isPending}
>
Restore
</button>
</li>
))}
</ul>
</section>
);
}

View File

@ -1,11 +1,14 @@
import { and, count, desc, eq } from "drizzle-orm"; import { and, count, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { projects } from "@/db/schema"; import { projects } from "@/db/schema";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
async function listProjects(organizationId: string) { async function listProjects(organizationId: string) {
return db.query.projects.findMany({ return db.query.projects.findMany({
where: eq(projects.organizationId, organizationId), where: and(
eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
),
orderBy: [desc(projects.createdAt), desc(projects.id)], orderBy: [desc(projects.createdAt), desc(projects.id)],
}); });
} }
@ -14,7 +17,12 @@ async function countProjects(organizationId: string) {
const [row] = await db const [row] = await db
.select({ value: count() }) .select({ value: count() })
.from(projects) .from(projects)
.where(eq(projects.organizationId, organizationId)); .where(
and(
eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
),
);
return row?.value ?? 0; return row?.value ?? 0;
} }
@ -26,6 +34,7 @@ async function getProjectForOrganization(
where: and( where: and(
eq(projects.id, projectId), eq(projects.id, projectId),
eq(projects.organizationId, organizationId), eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
), ),
}); });
} }
@ -81,28 +90,60 @@ async function tryCreateDefaultProject(organizationId: string) {
return inserted.length > 0 ? id : null; return inserted.length > 0 ? id : null;
} }
async function deleteProject(projectId: string, organizationId: string) { async function listArchivedProjects(organizationId: string) {
const project = await getProjectForOrganization(projectId, organizationId); return db.query.projects.findMany({
if (!project) { where: and(
throw new AppError("NOT_FOUND"); eq(projects.organizationId, organizationId),
isNotNull(projects.archivedAt),
),
orderBy: [desc(projects.archivedAt), desc(projects.id)],
});
} }
await db async function restoreProject(projectId: string, organizationId: string) {
.delete(projects) const [row] = await db
.update(projects)
.set({ archivedAt: null })
.where( .where(
and( and(
eq(projects.id, projectId), eq(projects.id, projectId),
eq(projects.organizationId, organizationId), eq(projects.organizationId, organizationId),
isNotNull(projects.archivedAt),
), ),
); )
.returning({ id: projects.id });
if (!row) {
throw new AppError("NOT_FOUND");
}
}
async function archiveProject(projectId: string, organizationId: string) {
const [row] = await db
.update(projects)
.set({ archivedAt: sql`(current_timestamp)` })
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
),
)
.returning({ id: projects.id });
if (!row) {
throw new AppError("NOT_FOUND");
}
} }
export const ProjectRepository = { export const ProjectRepository = {
listProjects, listProjects,
listArchivedProjects,
countProjects, countProjects,
getProjectForOrganization, getProjectForOrganization,
createProject, createProject,
updateProject, updateProject,
tryCreateDefaultProject, tryCreateDefaultProject,
deleteProject, archiveProject,
restoreProject,
} as const; } as const;

View File

@ -1,9 +1,11 @@
import { import {
archiveProject,
createProject, createProject,
deleteProject,
getProjectForOrganization, getProjectForOrganization,
listArchivedProjects,
listProjects, listProjects,
listProjectsEnsuringOne, listProjectsEnsuringOne,
restoreProject,
updateProject, updateProject,
} from "@/server/features/projects/services/projects"; } from "@/server/features/projects/services/projects";
@ -12,6 +14,8 @@ export const ProjectService = {
listProjectsEnsuringOne, listProjectsEnsuringOne,
createProject, createProject,
updateProject, updateProject,
deleteProject, archiveProject,
restoreProject,
listArchivedProjects,
getProjectForOrganization, getProjectForOrganization,
} as const; } as const;

View File

@ -3,10 +3,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createProject: vi.fn(), createProject: vi.fn(),
updateProject: vi.fn(), updateProject: vi.fn(),
deleteProject: vi.fn(), archiveProject: vi.fn(),
restoreProject: vi.fn(),
countProjects: vi.fn(), countProjects: vi.fn(),
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
listProjects: vi.fn(), listProjects: vi.fn(),
listArchivedProjects: vi.fn(),
tryCreateDefaultProject: vi.fn(), tryCreateDefaultProject: vi.fn(),
})); }));
@ -143,26 +145,57 @@ describe("project service", () => {
}); });
}); });
describe("deleteProject", () => { describe("archiveProject", () => {
it("refuses to delete the org's only project", async () => { it("refuses to archive the org's only project", async () => {
mocks.countProjects.mockResolvedValue(1); mocks.countProjects.mockResolvedValue(1);
const { deleteProject } = await import("./projects"); const { archiveProject } = await import("./projects");
await expect( await expect(
deleteProject("org_1", { projectId: "project_default" }), archiveProject("org_1", { projectId: "project_default" }),
).rejects.toMatchObject({ code: "CONFLICT" }); ).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.deleteProject).not.toHaveBeenCalled(); expect(mocks.archiveProject).not.toHaveBeenCalled();
}); });
it("deletes when more than one project remains", async () => { it("archives when more than one project remains", async () => {
mocks.countProjects.mockResolvedValue(2); mocks.countProjects.mockResolvedValue(2);
mocks.deleteProject.mockResolvedValue(undefined); mocks.archiveProject.mockResolvedValue(undefined);
const { deleteProject } = await import("./projects"); const { archiveProject } = await import("./projects");
await expect( await expect(
deleteProject("org_1", { projectId: "project_acme" }), archiveProject("org_1", { projectId: "project_acme" }),
).resolves.toEqual({ success: true }); ).resolves.toEqual({ success: true });
expect(mocks.deleteProject).toHaveBeenCalledWith("project_acme", "org_1"); expect(mocks.archiveProject).toHaveBeenCalledWith(
"project_acme",
"org_1",
);
});
});
describe("restoreProject", () => {
it("restores an archived project", async () => {
mocks.restoreProject.mockResolvedValue(undefined);
const { restoreProject } = await import("./projects");
await expect(
restoreProject("org_1", { archivedProjectId: "project_acme" }),
).resolves.toEqual({ success: true });
expect(mocks.restoreProject).toHaveBeenCalledWith(
"project_acme",
"org_1",
);
});
it("maps the Default singleton conflict to a friendly CONFLICT", async () => {
mocks.restoreProject.mockRejectedValue(
new Error(
"UNIQUE constraint failed: projects.projects_one_default_per_organization_idx",
),
);
const { restoreProject } = await import("./projects");
await expect(
restoreProject("org_1", { archivedProjectId: "project_default" }),
).rejects.toMatchObject({ code: "CONFLICT" });
}); });
}); });
}); });

View File

@ -1,6 +1,7 @@
import type { import type {
ArchiveProjectInput,
CreateProjectInput, CreateProjectInput,
DeleteProjectInput, RestoreProjectInput,
UpdateProjectInput, UpdateProjectInput,
} from "@/types/schemas/projects"; } from "@/types/schemas/projects";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
@ -95,16 +96,48 @@ export async function updateProject(
} }
} }
export async function deleteProject( export async function archiveProject(
organizationId: string, organizationId: string,
input: DeleteProjectInput, input: ArchiveProjectInput,
) { ) {
const remaining = await ProjectRepository.countProjects(organizationId); const remaining = await ProjectRepository.countProjects(organizationId);
if (remaining <= 1) { if (remaining <= 1) {
throw new AppError("CONFLICT", "You can't delete your only project."); throw new AppError("CONFLICT", "You can't archive your only project.");
} }
await ProjectRepository.deleteProject(input.projectId, organizationId); await ProjectRepository.archiveProject(input.projectId, organizationId);
return { success: true };
}
export async function listArchivedProjects(organizationId: string) {
const rows = await ProjectRepository.listArchivedProjects(organizationId);
return rows.map(mapProject);
}
export async function restoreProject(
organizationId: string,
input: RestoreProjectInput,
) {
try {
await ProjectRepository.restoreProject(
input.archivedProjectId,
organizationId,
);
} catch (error) {
// The Default singleton index is the only unique index on projects, and
// restore only writes archived_at — so a UNIQUE failure can only mean an
// active Default/no-domain project already exists.
if (
error instanceof Error &&
error.message.includes("UNIQUE constraint failed")
) {
throw new AppError(
"CONFLICT",
'An active project named "Default" with no domain already exists. Rename it first, then restore this one.',
);
}
throw error;
}
return { success: true }; return { success: true };
} }

View File

@ -1,4 +1,4 @@
import { and, count, desc, eq, inArray, lte, max } from "drizzle-orm"; import { and, count, desc, eq, inArray, isNull, lte, max } from "drizzle-orm";
import type { InferInsertModel } from "drizzle-orm"; import type { InferInsertModel } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { import {
@ -130,6 +130,7 @@ async function getDueConfigsWithOrganization(nowIso: string) {
and( and(
eq(rankTrackingConfigs.isActive, true), eq(rankTrackingConfigs.isActive, true),
lte(rankTrackingConfigs.nextCheckAt, nowIso), lte(rankTrackingConfigs.nextCheckAt, nowIso),
isNull(projects.archivedAt),
), ),
) )
.limit(50); .limit(50);

View File

@ -5,8 +5,9 @@ import {
requireProjectContext, requireProjectContext,
} from "@/serverFunctions/middleware"; } from "@/serverFunctions/middleware";
import { import {
archiveProjectSchema,
createProjectSchema, createProjectSchema,
deleteProjectSchema, restoreProjectSchema,
updateProjectSchema, updateProjectSchema,
} from "@/types/schemas/projects"; } from "@/types/schemas/projects";
import { z } from "zod"; import { z } from "zod";
@ -31,11 +32,24 @@ export const updateProject = createServerFn({ method: "POST" })
ProjectService.updateProject(context.organizationId, data), ProjectService.updateProject(context.organizationId, data),
); );
export const deleteProject = createServerFn({ method: "POST" }) export const archiveProject = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => deleteProjectSchema.parse(data)) .inputValidator((data: unknown) => archiveProjectSchema.parse(data))
.handler(async ({ data, context }) => .handler(async ({ data, context }) =>
ProjectService.deleteProject(context.organizationId, data), ProjectService.archiveProject(context.organizationId, data),
);
export const getArchivedProjects = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.handler(async ({ context }) =>
ProjectService.listArchivedProjects(context.organizationId),
);
export const restoreProject = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => restoreProjectSchema.parse(data))
.handler(async ({ data, context }) =>
ProjectService.restoreProject(context.organizationId, data),
); );
export const getProjectAccess = createServerFn({ method: "POST" }) export const getProjectAccess = createServerFn({ method: "POST" })

View File

@ -24,10 +24,18 @@ export const updateProjectSchema = z.object({
domain: projectDomainField, domain: projectDomainField,
}); });
export const deleteProjectSchema = z.object({ export const archiveProjectSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
}); });
// Deliberately not named `projectId`: ensureUserMiddleware resolves any
// `projectId` in input data against active projects and 404s on archived
// ones before the handler runs.
export const restoreProjectSchema = z.object({
archivedProjectId: z.string().min(1),
});
export type CreateProjectInput = z.infer<typeof createProjectSchema>; export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>; export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>; export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;