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 { 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 = () => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -3,5 +3,5 @@ export { getSerpAnalysis } from "./serp";
|
|||||||
export {
|
export {
|
||||||
saveKeywords,
|
saveKeywords,
|
||||||
getSavedKeywords,
|
getSavedKeywords,
|
||||||
removeSavedKeyword,
|
removeSavedKeywords,
|
||||||
} from "./saved-keywords";
|
} from "./saved-keywords";
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" })
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user