diff --git a/src/routes/_project/p/$projectId/saved.tsx b/src/routes/_project/p/$projectId/saved.tsx index 11cb57f..a6b8d06 100644 --- a/src/routes/_project/p/$projectId/saved.tsx +++ b/src/routes/_project/p/$projectId/saved.tsx @@ -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 = () => { diff --git a/src/server/features/keywords/repositories/KeywordResearchRepository.ts b/src/server/features/keywords/repositories/KeywordResearchRepository.ts index 5063b1e..97fcdfb 100644 --- a/src/server/features/keywords/repositories/KeywordResearchRepository.ts +++ b/src/server/features/keywords/repositories/KeywordResearchRepository.ts @@ -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; diff --git a/src/server/features/keywords/services/KeywordResearchService.ts b/src/server/features/keywords/services/KeywordResearchService.ts index 5a726a4..a596cba 100644 --- a/src/server/features/keywords/services/KeywordResearchService.ts +++ b/src/server/features/keywords/services/KeywordResearchService.ts @@ -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; diff --git a/src/server/features/keywords/services/research/index.ts b/src/server/features/keywords/services/research/index.ts index c05df5a..ef0fb78 100644 --- a/src/server/features/keywords/services/research/index.ts +++ b/src/server/features/keywords/services/research/index.ts @@ -3,5 +3,5 @@ export { getSerpAnalysis } from "./serp"; export { saveKeywords, getSavedKeywords, - removeSavedKeyword, + removeSavedKeywords, } from "./saved-keywords"; diff --git a/src/server/features/keywords/services/research/saved-keywords.ts b/src/server/features/keywords/services/research/saved-keywords.ts index d8113ba..9de812f 100644 --- a/src/server/features/keywords/services/research/saved-keywords.ts +++ b/src/server/features/keywords/services/research/saved-keywords.ts @@ -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 }; } diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts index a034afc..c59cbf6 100644 --- a/src/serverFunctions/keywords.ts +++ b/src/serverFunctions/keywords.ts @@ -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" }) diff --git a/src/types/schemas/keywords.ts b/src/types/schemas/keywords.ts index cd5da32..0bcb7b6 100644 --- a/src/types/schemas/keywords.ts +++ b/src/types/schemas/keywords.ts @@ -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; export type SaveKeywordsInput = z.infer; -export type RemoveSavedKeywordInput = z.infer; +export type RemoveSavedKeywordsInput = z.infer< + typeof removeSavedKeywordsSchema +>; export const serpAnalysisSchema = z.object({ projectId: z.string().min(1), keyword: z.string().min(1),