feat: bulk delete for saved keywords (#122)
This commit is contained in:
parent
a52cf0bf95
commit
5c2b71d340
@ -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;
|
||||
}
|
||||
}
|
||||
setDeleting(false);
|
||||
await removeMutation.mutateAsync(ids);
|
||||
setSelected(new Set());
|
||||
setShowConfirm(false);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["savedKeywords", projectId],
|
||||
captureClientEvent("saved_keywords:bulk_remove", {
|
||||
count: ids.length,
|
||||
});
|
||||
captureClientEvent("saved_keywords:bulk_remove");
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySelected = () => {
|
||||
|
||||
@ -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
|
||||
// D1 caps bound parameters at 100 per statement; leave headroom for the
|
||||
// projectId filter.
|
||||
const DELETE_CHUNK_SIZE = 90;
|
||||
|
||||
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(
|
||||
eq(savedKeywords.id, savedKeywordId),
|
||||
inArray(savedKeywords.id, chunk),
|
||||
eq(savedKeywords.projectId, projectId),
|
||||
),
|
||||
);
|
||||
)
|
||||
.returning({ id: savedKeywords.id });
|
||||
deletedCount += deleted.length;
|
||||
}
|
||||
|
||||
async function getSavedKeywordById(savedKeywordId: string) {
|
||||
return db.query.savedKeywords.findFirst({
|
||||
where: eq(savedKeywords.id, savedKeywordId),
|
||||
});
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
export const KeywordResearchRepository = {
|
||||
@ -121,6 +129,5 @@ export const KeywordResearchRepository = {
|
||||
countSavedKeywords,
|
||||
saveKeywordsToProject,
|
||||
listSavedKeywordsByProject,
|
||||
removeSavedKeyword,
|
||||
getSavedKeywordById,
|
||||
removeSavedKeywords,
|
||||
} as const;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -3,5 +3,5 @@ export { getSerpAnalysis } from "./serp";
|
||||
export {
|
||||
saveKeywords,
|
||||
getSavedKeywords,
|
||||
removeSavedKeyword,
|
||||
removeSavedKeywords,
|
||||
} from "./saved-keywords";
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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" })
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user