feat: bulk delete for saved keywords (#122)

This commit is contained in:
Ben Senescu 2026-04-16 19:14:50 -04:00 committed by GitHub
parent a52cf0bf95
commit 5c2b71d340
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 66 additions and 75 deletions

View File

@ -4,7 +4,7 @@ import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getSavedKeywords,
removeSavedKeyword,
removeSavedKeywords,
} from "@/serverFunctions/keywords";
import {
Download,
@ -48,41 +48,35 @@ function SavedKeywordsPage() {
const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? [];
const removeMutation = useMutation({
mutationFn: (savedKeywordId: string) =>
removeSavedKeyword({ data: { projectId, savedKeywordId } }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
captureClientEvent("saved_keywords:remove");
toast.success("Keyword removed");
},
onError: (error) => {
setRemoveError(getStandardErrorMessage(error, "Remove failed."));
},
mutationFn: (savedKeywordIds: string[]) =>
removeSavedKeywords({ data: { projectId, savedKeywordIds } }),
});
const handleDeleteSelected = async () => {
const ids = [...selected];
if (ids.length === 0) return;
setDeleting(true);
setRemoveError(null);
const ids = [...selected];
for (const id of ids) {
try {
await removeMutation.mutateAsync(id);
} catch {
break;
}
try {
await removeMutation.mutateAsync(ids);
setSelected(new Set());
setShowConfirm(false);
captureClientEvent("saved_keywords:bulk_remove", {
count: ids.length,
});
toast.success(
`${ids.length} keyword${ids.length !== 1 ? "s" : ""} removed`,
);
} catch (error) {
setRemoveError(getStandardErrorMessage(error, "Remove failed."));
} finally {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
setDeleting(false);
}
setDeleting(false);
setSelected(new Set());
setShowConfirm(false);
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
captureClientEvent("saved_keywords:bulk_remove");
toast.success(
`${ids.length} keyword${ids.length !== 1 ? "s" : ""} removed`,
);
};
const handleCopySelected = () => {

View File

@ -1,4 +1,4 @@
import { and, count, desc, eq } from "drizzle-orm";
import { and, count, desc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { keywordMetrics, savedKeywords } from "@/db/schema";
@ -99,21 +99,29 @@ async function listSavedKeywordsByProject(projectId: string) {
.orderBy(desc(savedKeywords.createdAt));
}
async function removeSavedKeyword(savedKeywordId: string, projectId: string) {
await db
.delete(savedKeywords)
.where(
and(
eq(savedKeywords.id, savedKeywordId),
eq(savedKeywords.projectId, projectId),
),
);
}
// D1 caps bound parameters at 100 per statement; leave headroom for the
// projectId filter.
const DELETE_CHUNK_SIZE = 90;
async function getSavedKeywordById(savedKeywordId: string) {
return db.query.savedKeywords.findFirst({
where: eq(savedKeywords.id, savedKeywordId),
});
async function removeSavedKeywords(
savedKeywordIds: string[],
projectId: string,
) {
let deletedCount = 0;
for (let i = 0; i < savedKeywordIds.length; i += DELETE_CHUNK_SIZE) {
const chunk = savedKeywordIds.slice(i, i + DELETE_CHUNK_SIZE);
const deleted = await db
.delete(savedKeywords)
.where(
and(
inArray(savedKeywords.id, chunk),
eq(savedKeywords.projectId, projectId),
),
)
.returning({ id: savedKeywords.id });
deletedCount += deleted.length;
}
return deletedCount;
}
export const KeywordResearchRepository = {
@ -121,6 +129,5 @@ export const KeywordResearchRepository = {
countSavedKeywords,
saveKeywordsToProject,
listSavedKeywordsByProject,
removeSavedKeyword,
getSavedKeywordById,
removeSavedKeywords,
} as const;

View File

@ -1,7 +1,7 @@
import {
getSavedKeywords,
getSerpAnalysis,
removeSavedKeyword,
removeSavedKeywords,
research,
saveKeywords,
} from "@/server/features/keywords/services/research";
@ -11,5 +11,5 @@ export const KeywordResearchService = {
getSerpAnalysis,
saveKeywords,
getSavedKeywords,
removeSavedKeyword,
removeSavedKeywords,
} as const;

View File

@ -3,5 +3,5 @@ export { getSerpAnalysis } from "./serp";
export {
saveKeywords,
getSavedKeywords,
removeSavedKeyword,
removeSavedKeywords,
} from "./saved-keywords";

View File

@ -1,9 +1,8 @@
import { AppError } from "@/server/lib/errors";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { jsonCodec } from "@/shared/json";
import type {
GetSavedKeywordsInput,
RemoveSavedKeywordInput,
RemoveSavedKeywordsInput,
SaveKeywordsInput,
} from "@/types/schemas/keywords";
import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
@ -106,24 +105,13 @@ export async function getSavedKeywords(
};
}
export async function removeSavedKeyword(
export async function removeSavedKeywords(
projectId: string,
input: RemoveSavedKeywordInput,
input: RemoveSavedKeywordsInput,
) {
const savedKw = await KeywordResearchRepository.getSavedKeywordById(
input.savedKeywordId,
);
if (!savedKw) {
throw new AppError("NOT_FOUND");
}
if (savedKw.projectId !== projectId) {
throw new AppError("FORBIDDEN");
}
await KeywordResearchRepository.removeSavedKeyword(
input.savedKeywordId,
const deletedCount = await KeywordResearchRepository.removeSavedKeywords(
input.savedKeywordIds,
projectId,
);
return { success: true };
return { success: true, deletedCount };
}

View File

@ -3,7 +3,7 @@ import {
researchKeywordsSchema,
saveKeywordsSchema,
getSavedKeywordsSchema,
removeSavedKeywordSchema,
removeSavedKeywordsSchema,
serpAnalysisSchema,
} from "@/types/schemas/keywords";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
@ -42,13 +42,13 @@ export const getSavedKeywords = createServerFn({ method: "POST" })
});
});
export const removeSavedKeyword = createServerFn({
export const removeSavedKeywords = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
.inputValidator((data: unknown) => removeSavedKeywordsSchema.parse(data))
.handler(async ({ data, context }) => {
return KeywordResearchService.removeSavedKeyword(context.projectId, data);
return KeywordResearchService.removeSavedKeywords(context.projectId, data);
});
export const getSerpAnalysis = createServerFn({ method: "POST" })

View File

@ -58,9 +58,9 @@ export const saveKeywordsSchema = z.object({
.optional(),
});
export const removeSavedKeywordSchema = z.object({
export const removeSavedKeywordsSchema = z.object({
projectId: z.string().min(1),
savedKeywordId: z.string().min(1),
savedKeywordIds: z.array(z.string().min(1)).min(1).max(2000),
});
export const getSavedKeywordsSchema = z.object({
@ -69,7 +69,9 @@ export const getSavedKeywordsSchema = z.object({
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
export type RemoveSavedKeywordInput = z.infer<typeof removeSavedKeywordSchema>;
export type RemoveSavedKeywordsInput = z.infer<
typeof removeSavedKeywordsSchema
>;
export const serpAnalysisSchema = z.object({
projectId: z.string().min(1),
keyword: z.string().min(1),