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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
getSavedKeywords, getSavedKeywords,
removeSavedKeyword, removeSavedKeywords,
} from "@/serverFunctions/keywords"; } from "@/serverFunctions/keywords";
import { import {
Download, Download,
@ -48,41 +48,35 @@ function SavedKeywordsPage() {
const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? []; const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? [];
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (savedKeywordId: string) => mutationFn: (savedKeywordIds: string[]) =>
removeSavedKeyword({ data: { projectId, savedKeywordId } }), removeSavedKeywords({ data: { projectId, savedKeywordIds } }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
});
captureClientEvent("saved_keywords:remove");
toast.success("Keyword removed");
},
onError: (error) => {
setRemoveError(getStandardErrorMessage(error, "Remove failed."));
},
}); });
const handleDeleteSelected = async () => { const handleDeleteSelected = async () => {
const ids = [...selected];
if (ids.length === 0) return;
setDeleting(true); setDeleting(true);
setRemoveError(null); setRemoveError(null);
const ids = [...selected];
for (const id of ids) { try {
try { await removeMutation.mutateAsync(ids);
await removeMutation.mutateAsync(id); setSelected(new Set());
} catch { setShowConfirm(false);
break; 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 = () => { 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 { db } from "@/db";
import { keywordMetrics, savedKeywords } from "@/db/schema"; import { keywordMetrics, savedKeywords } from "@/db/schema";
@ -99,21 +99,29 @@ async function listSavedKeywordsByProject(projectId: string) {
.orderBy(desc(savedKeywords.createdAt)); .orderBy(desc(savedKeywords.createdAt));
} }
async function removeSavedKeyword(savedKeywordId: string, projectId: string) { // D1 caps bound parameters at 100 per statement; leave headroom for the
await db // projectId filter.
.delete(savedKeywords) const DELETE_CHUNK_SIZE = 90;
.where(
and(
eq(savedKeywords.id, savedKeywordId),
eq(savedKeywords.projectId, projectId),
),
);
}
async function getSavedKeywordById(savedKeywordId: string) { async function removeSavedKeywords(
return db.query.savedKeywords.findFirst({ savedKeywordIds: string[],
where: eq(savedKeywords.id, savedKeywordId), 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 = { export const KeywordResearchRepository = {
@ -121,6 +129,5 @@ export const KeywordResearchRepository = {
countSavedKeywords, countSavedKeywords,
saveKeywordsToProject, saveKeywordsToProject,
listSavedKeywordsByProject, listSavedKeywordsByProject,
removeSavedKeyword, removeSavedKeywords,
getSavedKeywordById,
} as const; } as const;

View File

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

View File

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

View File

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

View File

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

View File

@ -58,9 +58,9 @@ export const saveKeywordsSchema = z.object({
.optional(), .optional(),
}); });
export const removeSavedKeywordSchema = z.object({ export const removeSavedKeywordsSchema = z.object({
projectId: z.string().min(1), 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({ export const getSavedKeywordsSchema = z.object({
@ -69,7 +69,9 @@ export const getSavedKeywordsSchema = z.object({
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>; export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>; 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({ export const serpAnalysisSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
keyword: z.string().min(1), keyword: z.string().min(1),