-
Saved Keywords
-
- Keywords you've saved from keyword research.
-
+
+
+
void exporter.exportFilteredCsv()}
+ onExportSheets={() => void exporter.exportFilteredSheets()}
+ />
+
+
+
setShowFilters((v) => !v)}
+ onResetAllFilters={handleClearAllFilters}
+ availableTags={availableTags}
+ selectedTagIds={selectedTagIds}
+ busyTagIds={tagManage.busyTagIds}
+ onToggleTagFilter={(tagId) => {
+ setSelectedTagIds((current) =>
+ current.includes(tagId)
+ ? current.filter((id) => id !== tagId)
+ : [...current, tagId],
+ );
+ setPage(1);
+ }}
+ onClearTagSelection={() => {
+ setSelectedTagIds([]);
+ setPage(1);
+ }}
+ onUpdateTag={(input) => void tagManage.updateTag(input)}
+ onDeleteTag={(tagId) => void handleDeleteTag(tagId)}
+ />
+
+
+ {removeError ? (
+
+ ) : null}
+
+
- {savedKeywords.length > 0 && (
-
-
-
-
- )}
+
+ {
+ setPageSize(nextPageSize);
+ setPage(1);
+ }}
+ />
- {isLoading ? (
-
-
-
- {Array.from({ length: 8 }).map((_, index) => (
-
- ))}
-
-
- ) : savedKeywords.length === 0 ? (
-
-
-
-
- No saved keywords yet. Use the Keyword Research page to find and
- save keywords.
-
-
-
- ) : (
-
-
- {removeError ? (
-
- ) : null}
+
{
+ void navigator.clipboard.writeText(
+ selectedRows.map((row) => row.keyword).join("\n"),
+ );
+ toast.success(
+ `${selectedCount} keyword${selectedCount !== 1 ? "s" : ""} copied`,
+ );
+ }}
+ onOpenTags={() => setShowTagModal(true)}
+ onExportCsv={() => exporter.exportSelectionCsv(selectedRows)}
+ onExportSheets={() =>
+ void exporter.exportSelectionSheets(selectedRows)
+ }
+ onDelete={() => setShowConfirm(true)}
+ onClear={() => setRowSelection({})}
+ />
- {/* Bulk action bar or keyword count */}
- {selected.size > 0 ? (
-
-
- {selected.size} keyword
- {selected.size !== 1 ? "s" : ""} selected
-
-
-
-
-
- ) : (
-
- {savedKeywords.length} saved keyword
- {savedKeywords.length !== 1 ? "s" : ""}
-
- )}
+ {showConfirm ? (
+ setShowConfirm(false)}
+ onConfirm={() => removeMutation.mutate(selectedIds)}
+ />
+ ) : null}
-
-
-
- )}
-
- {/* Confirm delete modal */}
- {showConfirm && (
-
-
-
-
Delete keywords?
-
- This will permanently delete {selected.size} saved keyword
- {selected.size !== 1 ? "s" : ""}.
-
-
-
-
-
-
-
-
- )}
+ {showTagModal ? (
+ setShowTagModal(false)}
+ onApply={({ addTags, removeTagIds }) =>
+ tagMutation.mutate({
+ savedKeywordIds: selectedIds,
+ addTags,
+ removeTagIds,
+ })
+ }
+ />
+ ) : null}
);
}
-
-function DifficultyBadge({ value }: { value: number | null }) {
- if (value == null)
- return
-;
- if (value < 30)
- return
{value};
- if (value <= 60)
- return
{value};
- return
{value};
-}
-
-function formatNumber(value: number | null | undefined) {
- if (value == null) return "-";
- return new Intl.NumberFormat().format(value);
-}
diff --git a/src/server/features/keywords/repositories/KeywordResearchRepository.ts b/src/server/features/keywords/repositories/KeywordResearchRepository.ts
index 97fcdfb..e769000 100644
--- a/src/server/features/keywords/repositories/KeywordResearchRepository.ts
+++ b/src/server/features/keywords/repositories/KeywordResearchRepository.ts
@@ -1,6 +1,61 @@
-import { and, count, desc, eq, inArray } from "drizzle-orm";
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ gte,
+ inArray,
+ lte,
+ sql,
+ type SQL,
+} from "drizzle-orm";
import { db } from "@/db";
-import { keywordMetrics, savedKeywords } from "@/db/schema";
+import {
+ keywordMetrics,
+ savedKeywordTagAssignments,
+ savedKeywords,
+} from "@/db/schema";
+import {
+ SavedKeywordTagsRepository,
+ type SavedKeywordTagRecord,
+} from "./SavedKeywordTagsRepository";
+
+type SavedKeywordRecord = typeof savedKeywords.$inferSelect;
+type KeywordMetricRecord = typeof keywordMetrics.$inferSelect;
+type SavedKeywordsListParams = {
+ projectId: string;
+ search?: string;
+ includeTerms?: string[];
+ excludeTerms?: string[];
+ minVolume?: number | null;
+ maxVolume?: number | null;
+ minCpc?: number | null;
+ maxCpc?: number | null;
+ minDifficulty?: number | null;
+ maxDifficulty?: number | null;
+ tagIds?: string[];
+ tagNames?: string[];
+ page?: number;
+ pageSize?: number;
+ sort?: SavedKeywordSortField;
+ order?: "asc" | "desc";
+};
+
+type SavedKeywordSortField =
+ | "createdAt"
+ | "keyword"
+ | "searchVolume"
+ | "cpc"
+ | "competition"
+ | "keywordDifficulty"
+ | "fetchedAt";
+
+type SavedKeywordListRow = {
+ row: SavedKeywordRecord;
+ metric: KeywordMetricRecord | null;
+ tags: SavedKeywordTagRecord[];
+};
async function upsertKeywordMetric(params: {
projectId: string;
@@ -63,8 +118,8 @@ async function saveKeywordsToProject(params: {
keywords: string[];
locationCode: number;
languageCode: string;
-}) {
- if (params.keywords.length === 0) return;
+}): Promise
{
+ if (params.keywords.length === 0) return [];
const [first, ...rest] = params.keywords.map((keyword) =>
db
@@ -80,27 +135,208 @@ async function saveKeywordsToProject(params: {
);
await db.batch([first, ...rest]);
+
+ return listSavedKeywordRowsByKeywords(params);
}
-async function listSavedKeywordsByProject(projectId: string) {
- return db
+function escapeLike(value: string) {
+ return value.replace(/[\\%_]/g, (char) => `\\${char}`);
+}
+
+function buildSavedKeywordWhere(params: {
+ projectId: string;
+ search?: string;
+ includeTerms?: string[];
+ excludeTerms?: string[];
+ minVolume?: number | null;
+ maxVolume?: number | null;
+ minCpc?: number | null;
+ maxCpc?: number | null;
+ minDifficulty?: number | null;
+ maxDifficulty?: number | null;
+ tagIds?: string[];
+}) {
+ const clauses: SQL[] = [eq(savedKeywords.projectId, params.projectId)];
+ const search = params.search?.trim();
+ if (search) {
+ clauses.push(
+ sql`lower(${savedKeywords.keyword}) like ${`%${escapeLike(search.toLocaleLowerCase())}%`} escape '\\'`,
+ );
+ }
+ for (const term of params.includeTerms ?? []) {
+ const trimmed = term.trim();
+ if (!trimmed) continue;
+ clauses.push(
+ sql`lower(${savedKeywords.keyword}) like ${`%${escapeLike(trimmed.toLocaleLowerCase())}%`} escape '\\'`,
+ );
+ }
+ for (const term of params.excludeTerms ?? []) {
+ const trimmed = term.trim();
+ if (!trimmed) continue;
+ clauses.push(
+ sql`lower(${savedKeywords.keyword}) not like ${`%${escapeLike(trimmed.toLocaleLowerCase())}%`} escape '\\'`,
+ );
+ }
+ if (params.minVolume != null) {
+ clauses.push(gte(keywordMetrics.searchVolume, params.minVolume));
+ }
+ if (params.maxVolume != null) {
+ clauses.push(lte(keywordMetrics.searchVolume, params.maxVolume));
+ }
+ if (params.minCpc != null) {
+ clauses.push(gte(keywordMetrics.cpc, params.minCpc));
+ }
+ if (params.maxCpc != null) {
+ clauses.push(lte(keywordMetrics.cpc, params.maxCpc));
+ }
+ if (params.minDifficulty != null) {
+ clauses.push(gte(keywordMetrics.keywordDifficulty, params.minDifficulty));
+ }
+ if (params.maxDifficulty != null) {
+ clauses.push(lte(keywordMetrics.keywordDifficulty, params.maxDifficulty));
+ }
+ if (params.tagIds && params.tagIds.length > 0) {
+ clauses.push(
+ sql`exists (
+ select 1
+ from ${savedKeywordTagAssignments}
+ where ${savedKeywordTagAssignments.savedKeywordId} = ${savedKeywords.id}
+ and ${inArray(savedKeywordTagAssignments.tagId, params.tagIds)}
+ )`,
+ );
+ }
+ return and(...clauses);
+}
+
+function buildSavedKeywordOrderBy(
+ sort: SavedKeywordSortField = "createdAt",
+ order: "asc" | "desc" = "desc",
+) {
+ const direction = order === "asc" ? asc : desc;
+ switch (sort) {
+ case "keyword":
+ return direction(savedKeywords.keyword);
+ case "searchVolume":
+ return direction(keywordMetrics.searchVolume);
+ case "cpc":
+ return direction(keywordMetrics.cpc);
+ case "competition":
+ return direction(keywordMetrics.competition);
+ case "keywordDifficulty":
+ return direction(keywordMetrics.keywordDifficulty);
+ case "fetchedAt":
+ return direction(keywordMetrics.fetchedAt);
+ case "createdAt":
+ default:
+ return direction(savedKeywords.createdAt);
+ }
+}
+
+async function listSavedKeywordsByProject(
+ params: SavedKeywordsListParams,
+): Promise<{
+ rows: SavedKeywordListRow[];
+ totalCount: number;
+ tags: (SavedKeywordTagRecord & { keywordCount: number })[];
+}> {
+ const [{ tagIds, emptyTagNameMatch }, tags] = await Promise.all([
+ SavedKeywordTagsRepository.getTagFilterIds(params),
+ SavedKeywordTagsRepository.listSavedKeywordTagsByProject(params.projectId),
+ ]);
+
+ if (emptyTagNameMatch) {
+ return { rows: [], totalCount: 0, tags };
+ }
+
+ const where = buildSavedKeywordWhere({
+ projectId: params.projectId,
+ search: params.search,
+ includeTerms: params.includeTerms,
+ excludeTerms: params.excludeTerms,
+ minVolume: params.minVolume,
+ maxVolume: params.maxVolume,
+ minCpc: params.minCpc,
+ maxCpc: params.maxCpc,
+ minDifficulty: params.minDifficulty,
+ maxDifficulty: params.maxDifficulty,
+ tagIds,
+ });
+
+ const metricJoin = and(
+ eq(keywordMetrics.keyword, savedKeywords.keyword),
+ eq(keywordMetrics.projectId, savedKeywords.projectId),
+ eq(keywordMetrics.locationCode, savedKeywords.locationCode),
+ eq(keywordMetrics.languageCode, savedKeywords.languageCode),
+ );
+
+ const [{ value: totalCount } = { value: 0 }] = await db
+ .select({ value: count() })
+ .from(savedKeywords)
+ .leftJoin(keywordMetrics, metricJoin)
+ .where(where);
+
+ const baseQuery = db
.select({ row: savedKeywords, metric: keywordMetrics })
.from(savedKeywords)
- .leftJoin(
- keywordMetrics,
- and(
- eq(keywordMetrics.keyword, savedKeywords.keyword),
- eq(keywordMetrics.projectId, savedKeywords.projectId),
- eq(keywordMetrics.locationCode, savedKeywords.locationCode),
- eq(keywordMetrics.languageCode, savedKeywords.languageCode),
- ),
- )
- .where(eq(savedKeywords.projectId, projectId))
- .orderBy(desc(savedKeywords.createdAt));
+ .leftJoin(keywordMetrics, metricJoin)
+ .where(where)
+ .orderBy(
+ buildSavedKeywordOrderBy(params.sort, params.order),
+ asc(savedKeywords.id),
+ );
+
+ const rows =
+ params.pageSize == null
+ ? await baseQuery
+ : await baseQuery
+ .limit(params.pageSize)
+ .offset(((params.page ?? 1) - 1) * params.pageSize);
+ const tagsByKeywordId =
+ await SavedKeywordTagsRepository.listTagsBySavedKeywordIds(
+ params.projectId,
+ rows.map(({ row }) => row.id),
+ );
+
+ return {
+ totalCount,
+ tags,
+ rows: rows.map(({ row, metric }) => ({
+ row,
+ metric,
+ tags: tagsByKeywordId.get(row.id) ?? [],
+ })),
+ };
+}
+
+async function listSavedKeywordRowsByKeywords(params: {
+ projectId: string;
+ keywords: string[];
+ locationCode: number;
+ languageCode: string;
+}) {
+ const rows: SavedKeywordRecord[] = [];
+ for (let i = 0; i < params.keywords.length; i += QUERY_CHUNK_SIZE) {
+ const chunk = params.keywords.slice(i, i + QUERY_CHUNK_SIZE);
+ rows.push(
+ ...(await db
+ .select()
+ .from(savedKeywords)
+ .where(
+ and(
+ eq(savedKeywords.projectId, params.projectId),
+ eq(savedKeywords.locationCode, params.locationCode),
+ eq(savedKeywords.languageCode, params.languageCode),
+ inArray(savedKeywords.keyword, chunk),
+ ),
+ )),
+ );
+ }
+ return rows;
}
// D1 caps bound parameters at 100 per statement; leave headroom for the
// projectId filter.
+const QUERY_CHUNK_SIZE = 80;
const DELETE_CHUNK_SIZE = 90;
async function removeSavedKeywords(
@@ -129,5 +365,14 @@ export const KeywordResearchRepository = {
countSavedKeywords,
saveKeywordsToProject,
listSavedKeywordsByProject,
+ addTagsToSavedKeywords: SavedKeywordTagsRepository.addTagsToSavedKeywords,
+ replaceTagsForSavedKeywords:
+ SavedKeywordTagsRepository.replaceTagsForSavedKeywords,
+ removeTagsFromSavedKeywords:
+ SavedKeywordTagsRepository.removeTagsFromSavedKeywords,
+ removeAllTagsFromSavedKeywords:
+ SavedKeywordTagsRepository.removeAllTagsFromSavedKeywords,
+ updateSavedKeywordTag: SavedKeywordTagsRepository.updateSavedKeywordTag,
+ deleteSavedKeywordTag: SavedKeywordTagsRepository.deleteSavedKeywordTag,
removeSavedKeywords,
} as const;
diff --git a/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts b/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts
new file mode 100644
index 0000000..8e8455d
--- /dev/null
+++ b/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts
@@ -0,0 +1,413 @@
+import { and, asc, count, eq, inArray, notInArray } from "drizzle-orm";
+import { db } from "@/db";
+import {
+ savedKeywordTagAssignments,
+ savedKeywordTags,
+ savedKeywords,
+} from "@/db/schema";
+import {
+ normalizeSavedKeywordTag,
+ normalizeSavedKeywordTags,
+} from "@/shared/saved-keyword-tags";
+
+export type SavedKeywordTagRecord = typeof savedKeywordTags.$inferSelect;
+
+const QUERY_CHUNK_SIZE = 80;
+const DELETE_PAIR_CHUNK_SIZE = 45;
+const ASSIGNMENT_INSERT_CHUNK_SIZE = 40;
+const REPLACE_DELETE_KEYWORD_CHUNK_SIZE = 70;
+
+async function getTagFilterIds(params: {
+ projectId: string;
+ tagIds?: string[];
+ tagNames?: string[];
+}): Promise<{ tagIds: string[]; emptyTagNameMatch: boolean }> {
+ const directTagIds = params.tagIds ?? [];
+ const normalizedTags = normalizeSavedKeywordTags(params.tagNames);
+ if (normalizedTags.length === 0) {
+ return { tagIds: [...new Set(directTagIds)], emptyTagNameMatch: false };
+ }
+
+ const rows = await db
+ .select({ id: savedKeywordTags.id })
+ .from(savedKeywordTags)
+ .where(
+ and(
+ eq(savedKeywordTags.projectId, params.projectId),
+ inArray(
+ savedKeywordTags.normalizedName,
+ normalizedTags.map((tag) => tag.normalizedName),
+ ),
+ ),
+ );
+
+ return {
+ tagIds: [...new Set([...directTagIds, ...rows.map((row) => row.id)])],
+ emptyTagNameMatch: directTagIds.length === 0 && rows.length === 0,
+ };
+}
+
+async function listSavedKeywordTagsByProject(projectId: string) {
+ return db
+ .select({
+ id: savedKeywordTags.id,
+ projectId: savedKeywordTags.projectId,
+ name: savedKeywordTags.name,
+ normalizedName: savedKeywordTags.normalizedName,
+ color: savedKeywordTags.color,
+ createdAt: savedKeywordTags.createdAt,
+ keywordCount: count(savedKeywordTagAssignments.savedKeywordId),
+ })
+ .from(savedKeywordTags)
+ .leftJoin(
+ savedKeywordTagAssignments,
+ eq(savedKeywordTagAssignments.tagId, savedKeywordTags.id),
+ )
+ .where(eq(savedKeywordTags.projectId, projectId))
+ .groupBy(
+ savedKeywordTags.id,
+ savedKeywordTags.projectId,
+ savedKeywordTags.name,
+ savedKeywordTags.normalizedName,
+ savedKeywordTags.color,
+ savedKeywordTags.createdAt,
+ )
+ .orderBy(asc(savedKeywordTags.normalizedName));
+}
+
+async function listTagsBySavedKeywordIds(
+ projectId: string,
+ savedKeywordIds: string[],
+) {
+ const tagsByKeywordId = new Map();
+ if (savedKeywordIds.length === 0) return tagsByKeywordId;
+
+ for (let i = 0; i < savedKeywordIds.length; i += QUERY_CHUNK_SIZE) {
+ const chunk = savedKeywordIds.slice(i, i + QUERY_CHUNK_SIZE);
+ const rows = await db
+ .select({
+ savedKeywordId: savedKeywordTagAssignments.savedKeywordId,
+ tag: savedKeywordTags,
+ })
+ .from(savedKeywordTagAssignments)
+ .innerJoin(
+ savedKeywordTags,
+ eq(savedKeywordTags.id, savedKeywordTagAssignments.tagId),
+ )
+ .where(
+ and(
+ eq(savedKeywordTags.projectId, projectId),
+ inArray(savedKeywordTagAssignments.savedKeywordId, chunk),
+ ),
+ )
+ .orderBy(asc(savedKeywordTags.normalizedName));
+
+ for (const { savedKeywordId, tag } of rows) {
+ const tags = tagsByKeywordId.get(savedKeywordId) ?? [];
+ tags.push(tag);
+ tagsByKeywordId.set(savedKeywordId, tags);
+ }
+ }
+
+ return tagsByKeywordId;
+}
+
+async function addTagsToSavedKeywords(params: {
+ projectId: string;
+ savedKeywordIds: string[];
+ tagNames: string[];
+}) {
+ const savedKeywordRows = await listSavedKeywordRowsByIds(
+ params.projectId,
+ params.savedKeywordIds,
+ );
+ if (savedKeywordRows.length === 0) {
+ return { savedKeywordCount: 0, tags: [] };
+ }
+
+ const tags = await upsertSavedKeywordTags(params.projectId, params.tagNames);
+
+ const assignments = savedKeywordRows.flatMap((row) =>
+ tags.map((tag) => ({ savedKeywordId: row.id, tagId: tag.id })),
+ );
+ for (let i = 0; i < assignments.length; i += ASSIGNMENT_INSERT_CHUNK_SIZE) {
+ const chunk = assignments.slice(i, i + ASSIGNMENT_INSERT_CHUNK_SIZE);
+ await db
+ .insert(savedKeywordTagAssignments)
+ .values(chunk)
+ .onConflictDoNothing();
+ }
+
+ return { savedKeywordCount: savedKeywordRows.length, tags };
+}
+
+async function replaceTagsForSavedKeywords(params: {
+ projectId: string;
+ savedKeywordIds: string[];
+ tagNames: string[];
+}) {
+ const addResult = await addTagsToSavedKeywords(params);
+ const keepTagIds = addResult.tags.map((tag) => tag.id);
+ if (addResult.savedKeywordCount === 0 || keepTagIds.length === 0) {
+ return { ...addResult, removedCount: 0 };
+ }
+
+ let removedCount = 0;
+ for (
+ let i = 0;
+ i < params.savedKeywordIds.length;
+ i += REPLACE_DELETE_KEYWORD_CHUNK_SIZE
+ ) {
+ const chunk = params.savedKeywordIds.slice(
+ i,
+ i + REPLACE_DELETE_KEYWORD_CHUNK_SIZE,
+ );
+ const savedKeywordRows = await listSavedKeywordRowsByIds(
+ params.projectId,
+ chunk,
+ );
+ const savedKeywordIds = savedKeywordRows.map((row) => row.id);
+ if (savedKeywordIds.length === 0) continue;
+
+ const deleted = await db
+ .delete(savedKeywordTagAssignments)
+ .where(
+ and(
+ inArray(savedKeywordTagAssignments.savedKeywordId, savedKeywordIds),
+ notInArray(savedKeywordTagAssignments.tagId, keepTagIds),
+ ),
+ )
+ .returning({ id: savedKeywordTagAssignments.savedKeywordId });
+ removedCount += deleted.length;
+ }
+
+ return { ...addResult, removedCount };
+}
+
+async function removeTagsFromSavedKeywords(params: {
+ projectId: string;
+ savedKeywordIds: string[];
+ tagIds: string[];
+}) {
+ const [savedKeywordRows, tags] = await Promise.all([
+ listSavedKeywordRowsByIds(params.projectId, params.savedKeywordIds),
+ listSavedKeywordTagsByIds(params.projectId, params.tagIds),
+ ]);
+ const savedKeywordIds = savedKeywordRows.map((row) => row.id);
+ const tagIds = tags.map((tag) => tag.id);
+ let removedCount = 0;
+
+ for (let i = 0; i < savedKeywordIds.length; i += DELETE_PAIR_CHUNK_SIZE) {
+ const savedKeywordChunk = savedKeywordIds.slice(
+ i,
+ i + DELETE_PAIR_CHUNK_SIZE,
+ );
+ for (let j = 0; j < tagIds.length; j += DELETE_PAIR_CHUNK_SIZE) {
+ const tagChunk = tagIds.slice(j, j + DELETE_PAIR_CHUNK_SIZE);
+ const deleted = await db
+ .delete(savedKeywordTagAssignments)
+ .where(
+ and(
+ inArray(
+ savedKeywordTagAssignments.savedKeywordId,
+ savedKeywordChunk,
+ ),
+ inArray(savedKeywordTagAssignments.tagId, tagChunk),
+ ),
+ )
+ .returning({ tagId: savedKeywordTagAssignments.tagId });
+ removedCount += deleted.length;
+ }
+ }
+
+ return { removedCount, savedKeywordCount: savedKeywordRows.length, tags };
+}
+
+async function removeAllTagsFromSavedKeywords(params: {
+ projectId: string;
+ savedKeywordIds: string[];
+}) {
+ const savedKeywordRows = await listSavedKeywordRowsByIds(
+ params.projectId,
+ params.savedKeywordIds,
+ );
+ let removedCount = 0;
+
+ for (let i = 0; i < savedKeywordRows.length; i += QUERY_CHUNK_SIZE) {
+ const chunk = savedKeywordRows.slice(i, i + QUERY_CHUNK_SIZE);
+ const deleted = await db
+ .delete(savedKeywordTagAssignments)
+ .where(
+ inArray(
+ savedKeywordTagAssignments.savedKeywordId,
+ chunk.map((row) => row.id),
+ ),
+ )
+ .returning({ id: savedKeywordTagAssignments.savedKeywordId });
+ removedCount += deleted.length;
+ }
+
+ return { removedCount, savedKeywordCount: savedKeywordRows.length };
+}
+
+async function upsertSavedKeywordTags(
+ projectId: string,
+ tagNames: readonly string[] | undefined,
+) {
+ const normalizedTags = normalizeSavedKeywordTags(tagNames);
+ if (normalizedTags.length === 0) return [];
+
+ const [first, ...rest] = normalizedTags.map((tag) =>
+ db
+ .insert(savedKeywordTags)
+ .values({
+ id: crypto.randomUUID(),
+ projectId,
+ name: tag.name,
+ normalizedName: tag.normalizedName,
+ })
+ .onConflictDoNothing(),
+ );
+ await db.batch([first, ...rest]);
+
+ return db
+ .select()
+ .from(savedKeywordTags)
+ .where(
+ and(
+ eq(savedKeywordTags.projectId, projectId),
+ inArray(
+ savedKeywordTags.normalizedName,
+ normalizedTags.map((tag) => tag.normalizedName),
+ ),
+ ),
+ )
+ .orderBy(asc(savedKeywordTags.normalizedName));
+}
+
+async function listSavedKeywordRowsByIds(
+ projectId: string,
+ savedKeywordIds: string[],
+) {
+ const rows: (typeof savedKeywords.$inferSelect)[] = [];
+ for (let i = 0; i < savedKeywordIds.length; i += QUERY_CHUNK_SIZE) {
+ const chunk = savedKeywordIds.slice(i, i + QUERY_CHUNK_SIZE);
+ rows.push(
+ ...(await db
+ .select()
+ .from(savedKeywords)
+ .where(
+ and(
+ eq(savedKeywords.projectId, projectId),
+ inArray(savedKeywords.id, chunk),
+ ),
+ )),
+ );
+ }
+ return rows;
+}
+
+async function listSavedKeywordTagsByIds(projectId: string, tagIds: string[]) {
+ if (tagIds.length === 0) return [];
+ const rows: SavedKeywordTagRecord[] = [];
+ for (let i = 0; i < tagIds.length; i += QUERY_CHUNK_SIZE) {
+ const chunk = tagIds.slice(i, i + QUERY_CHUNK_SIZE);
+ rows.push(
+ ...(await db
+ .select()
+ .from(savedKeywordTags)
+ .where(
+ and(
+ eq(savedKeywordTags.projectId, projectId),
+ inArray(savedKeywordTags.id, chunk),
+ ),
+ )),
+ );
+ }
+ return rows;
+}
+
+async function updateSavedKeywordTag(params: {
+ projectId: string;
+ tagId: string;
+ name?: string;
+ color?: string | null;
+}) {
+ const updates: Partial = {};
+ if (params.name !== undefined) {
+ const normalizedTag = normalizeSavedKeywordTag(params.name);
+ if (!normalizedTag) return null;
+ updates.name = normalizedTag.name;
+ updates.normalizedName = normalizedTag.normalizedName;
+ }
+ if (params.color !== undefined) {
+ updates.color = params.color;
+ }
+ if (Object.keys(updates).length === 0) return null;
+
+ const [updated] = await db
+ .update(savedKeywordTags)
+ .set(updates)
+ .where(
+ and(
+ eq(savedKeywordTags.projectId, params.projectId),
+ eq(savedKeywordTags.id, params.tagId),
+ ),
+ )
+ .returning();
+ return updated ?? null;
+}
+
+async function deleteSavedKeywordTag(params: {
+ projectId: string;
+ tagId: string;
+}): Promise<
+ | { status: "deleted" }
+ | { status: "not_found" }
+ | { status: "in_use"; assignmentCount: number }
+> {
+ const [tag] = await db
+ .select({ id: savedKeywordTags.id })
+ .from(savedKeywordTags)
+ .where(
+ and(
+ eq(savedKeywordTags.projectId, params.projectId),
+ eq(savedKeywordTags.id, params.tagId),
+ ),
+ );
+ if (!tag) return { status: "not_found" };
+
+ // Guard: refuse to delete a tag that's still attached to saved keywords.
+ // FK cascade would otherwise silently drop assignments.
+ const [{ value: assignmentCount } = { value: 0 }] = await db
+ .select({ value: count() })
+ .from(savedKeywordTagAssignments)
+ .where(eq(savedKeywordTagAssignments.tagId, params.tagId));
+
+ if (assignmentCount > 0) {
+ return { status: "in_use", assignmentCount };
+ }
+
+ const deleted = await db
+ .delete(savedKeywordTags)
+ .where(
+ and(
+ eq(savedKeywordTags.projectId, params.projectId),
+ eq(savedKeywordTags.id, params.tagId),
+ ),
+ )
+ .returning({ id: savedKeywordTags.id });
+ return deleted.length > 0 ? { status: "deleted" } : { status: "not_found" };
+}
+
+export const SavedKeywordTagsRepository = {
+ getTagFilterIds,
+ listSavedKeywordTagsByProject,
+ listTagsBySavedKeywordIds,
+ addTagsToSavedKeywords,
+ replaceTagsForSavedKeywords,
+ removeTagsFromSavedKeywords,
+ removeAllTagsFromSavedKeywords,
+ updateSavedKeywordTag,
+ deleteSavedKeywordTag,
+} as const;
diff --git a/src/server/features/keywords/services/KeywordResearchService.ts b/src/server/features/keywords/services/KeywordResearchService.ts
index a596cba..98c7488 100644
--- a/src/server/features/keywords/services/KeywordResearchService.ts
+++ b/src/server/features/keywords/services/KeywordResearchService.ts
@@ -1,9 +1,13 @@
import {
+ deleteSavedKeywordTag,
getSavedKeywords,
getSerpAnalysis,
removeSavedKeywords,
research,
saveKeywords,
+ exportSavedKeywords,
+ updateSavedKeywordTag,
+ updateSavedKeywordTags,
} from "@/server/features/keywords/services/research";
export const KeywordResearchService = {
@@ -11,5 +15,9 @@ export const KeywordResearchService = {
getSerpAnalysis,
saveKeywords,
getSavedKeywords,
+ exportSavedKeywords,
+ updateSavedKeywordTags,
+ updateSavedKeywordTag,
+ deleteSavedKeywordTag,
removeSavedKeywords,
} as const;
diff --git a/src/server/features/keywords/services/research/index.ts b/src/server/features/keywords/services/research/index.ts
index ef0fb78..8eb7e20 100644
--- a/src/server/features/keywords/services/research/index.ts
+++ b/src/server/features/keywords/services/research/index.ts
@@ -3,5 +3,9 @@ export { getSerpAnalysis } from "./serp";
export {
saveKeywords,
getSavedKeywords,
+ exportSavedKeywords,
+ updateSavedKeywordTags,
+ updateSavedKeywordTag,
+ deleteSavedKeywordTag,
removeSavedKeywords,
} from "./saved-keywords";
diff --git a/src/server/features/keywords/services/research/saved-keywords.test.ts b/src/server/features/keywords/services/research/saved-keywords.test.ts
new file mode 100644
index 0000000..e323a92
--- /dev/null
+++ b/src/server/features/keywords/services/research/saved-keywords.test.ts
@@ -0,0 +1,248 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ addTagsToSavedKeywords: vi.fn(),
+ listSavedKeywordsByProject: vi.fn(),
+ removeAllTagsFromSavedKeywords: vi.fn(),
+ removeSavedKeywords: vi.fn(),
+ removeTagsFromSavedKeywords: vi.fn(),
+ replaceTagsForSavedKeywords: vi.fn(),
+ saveKeywordsToProject: vi.fn(),
+ upsertKeywordMetric: vi.fn(),
+}));
+
+vi.mock(
+ "@/server/features/keywords/repositories/KeywordResearchRepository",
+ () => ({
+ KeywordResearchRepository: mocks,
+ }),
+);
+
+const savedKeywordRow = {
+ id: "saved_1",
+ projectId: "project_1",
+ keyword: "technical seo",
+ locationCode: 2840,
+ languageCode: "en",
+ createdAt: "2026-05-11T00:00:00.000Z",
+};
+
+describe("saved keyword service", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ for (const mock of Object.values(mocks)) mock.mockReset();
+ });
+
+ it("attaches tags to saved keyword rows after saving", async () => {
+ mocks.saveKeywordsToProject.mockResolvedValue([
+ savedKeywordRow,
+ { ...savedKeywordRow, id: "saved_2", keyword: "content seo" },
+ ]);
+ mocks.addTagsToSavedKeywords.mockResolvedValue({
+ savedKeywordCount: 2,
+ tags: [],
+ });
+ const { saveKeywords } = await import("./saved-keywords");
+
+ await saveKeywords({
+ projectId: "project_1",
+ keywords: [" Technical SEO ", "technical seo", "Content SEO"],
+ locationCode: 2840,
+ languageCode: "en",
+ tagMode: "append",
+ tags: ["Content", "BOFU"],
+ });
+
+ expect(mocks.saveKeywordsToProject).toHaveBeenCalledWith({
+ projectId: "project_1",
+ keywords: ["technical seo", "content seo"],
+ locationCode: 2840,
+ languageCode: "en",
+ });
+ expect(mocks.addTagsToSavedKeywords).toHaveBeenCalledWith({
+ projectId: "project_1",
+ savedKeywordIds: ["saved_1", "saved_2"],
+ tagNames: ["Content", "BOFU"],
+ });
+ });
+
+ it("does not call tag assignment when no tags are provided", async () => {
+ mocks.saveKeywordsToProject.mockResolvedValue([savedKeywordRow]);
+ const { saveKeywords } = await import("./saved-keywords");
+
+ await saveKeywords({
+ projectId: "project_1",
+ keywords: ["technical seo"],
+ locationCode: 2840,
+ languageCode: "en",
+ tagMode: "append",
+ });
+
+ expect(mocks.addTagsToSavedKeywords).not.toHaveBeenCalled();
+ });
+
+ it("maps paged saved keyword rows with attached tags", async () => {
+ mocks.listSavedKeywordsByProject.mockResolvedValue({
+ totalCount: 1,
+ tags: [
+ {
+ id: "tag_1",
+ projectId: "project_1",
+ name: "Content",
+ normalizedName: "content",
+ color: null,
+ createdAt: "2026-05-11T00:00:00.000Z",
+ keywordCount: 1,
+ },
+ ],
+ rows: [
+ {
+ row: savedKeywordRow,
+ metric: {
+ id: 1,
+ projectId: "project_1",
+ keyword: "technical seo",
+ locationCode: 2840,
+ languageCode: "en",
+ searchVolume: 120,
+ cpc: 2.5,
+ competition: 0.2,
+ keywordDifficulty: 18,
+ intent: "informational",
+ monthlySearches: null,
+ fetchedAt: "2026-05-10T00:00:00.000Z",
+ },
+ tags: [
+ {
+ id: "tag_1",
+ projectId: "project_1",
+ name: "Content",
+ normalizedName: "content",
+ color: null,
+ createdAt: "2026-05-11T00:00:00.000Z",
+ },
+ ],
+ },
+ ],
+ });
+ const { getSavedKeywords } = await import("./saved-keywords");
+
+ const result = await getSavedKeywords({
+ projectId: "project_1",
+ tagIds: ["tag_1"],
+ page: 1,
+ pageSize: 50,
+ sort: "createdAt",
+ order: "desc",
+ });
+
+ expect(mocks.listSavedKeywordsByProject).toHaveBeenCalledWith({
+ projectId: "project_1",
+ search: undefined,
+ tagIds: ["tag_1"],
+ tagNames: undefined,
+ page: 1,
+ pageSize: 50,
+ sort: "createdAt",
+ order: "desc",
+ });
+ expect(result.rows[0]?.tags).toEqual([
+ { id: "tag_1", name: "Content", normalizedName: "content", color: null },
+ ]);
+ expect(result.tags).toEqual([
+ {
+ id: "tag_1",
+ name: "Content",
+ normalizedName: "content",
+ color: null,
+ keywordCount: 1,
+ },
+ ]);
+ });
+
+ it("updates saved keyword tags through add and remove operations", async () => {
+ mocks.addTagsToSavedKeywords.mockResolvedValue({
+ savedKeywordCount: 2,
+ tags: [
+ {
+ id: "tag_1",
+ name: "Content",
+ normalizedName: "content",
+ },
+ ],
+ });
+ mocks.removeTagsFromSavedKeywords.mockResolvedValue({
+ savedKeywordCount: 2,
+ removedCount: 2,
+ tags: [{ id: "tag_2" }],
+ });
+ const { updateSavedKeywordTags } = await import("./saved-keywords");
+
+ const result = await updateSavedKeywordTags({
+ projectId: "project_1",
+ savedKeywordIds: ["saved_1", "saved_2"],
+ addTags: ["Content"],
+ removeTagIds: ["tag_2"],
+ });
+
+ expect(result).toMatchObject({
+ success: true,
+ taggedCount: 2,
+ addedTags: [
+ {
+ id: "tag_1",
+ name: "Content",
+ normalizedName: "content",
+ color: null,
+ },
+ ],
+ removedTagIds: ["tag_2"],
+ removedAssignments: 2,
+ });
+ });
+
+ it("replaces tags only for the exact saved keyword rows returned by save", async () => {
+ mocks.saveKeywordsToProject.mockResolvedValue([
+ { ...savedKeywordRow, id: "saved_us", keyword: "technical seo" },
+ ]);
+ mocks.replaceTagsForSavedKeywords.mockResolvedValue({
+ savedKeywordCount: 1,
+ removedCount: 1,
+ tags: [{ id: "tag_new", name: "US", normalizedName: "us" }],
+ });
+ const { saveKeywords } = await import("./saved-keywords");
+
+ const result = await saveKeywords({
+ projectId: "project_1",
+ keywords: ["technical seo"],
+ locationCode: 2840,
+ languageCode: "en",
+ tags: ["US"],
+ tagMode: "replace",
+ });
+
+ expect(mocks.replaceTagsForSavedKeywords).toHaveBeenCalledWith({
+ projectId: "project_1",
+ savedKeywordIds: ["saved_us"],
+ tagNames: ["US"],
+ });
+ expect(mocks.addTagsToSavedKeywords).not.toHaveBeenCalled();
+ expect(result.savedKeywordIds).toEqual(["saved_us"]);
+ });
+
+ it("rejects replace mode without replacement tags", async () => {
+ mocks.saveKeywordsToProject.mockResolvedValue([savedKeywordRow]);
+ const { saveKeywords } = await import("./saved-keywords");
+
+ await expect(
+ saveKeywords({
+ projectId: "project_1",
+ keywords: ["technical seo"],
+ locationCode: 2840,
+ languageCode: "en",
+ tagMode: "replace",
+ }),
+ ).rejects.toThrow("Replacement tags are required");
+ expect(mocks.replaceTagsForSavedKeywords).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/server/features/keywords/services/research/saved-keywords.ts b/src/server/features/keywords/services/research/saved-keywords.ts
index 9de812f..e5ecc67 100644
--- a/src/server/features/keywords/services/research/saved-keywords.ts
+++ b/src/server/features/keywords/services/research/saved-keywords.ts
@@ -1,11 +1,19 @@
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { jsonCodec } from "@/shared/json";
import type {
+ DeleteSavedKeywordTagInput,
+ ExportSavedKeywordsInput,
GetSavedKeywordsInput,
RemoveSavedKeywordsInput,
SaveKeywordsInput,
+ UpdateSavedKeywordTagInput,
+ UpdateSavedKeywordTagsInput,
} from "@/types/schemas/keywords";
-import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
+import type {
+ MonthlySearch,
+ SavedKeywordRow,
+ SavedKeywordTagSummary,
+} from "@/types/keywords";
import { normalizeKeyword } from "./helpers";
import { z } from "zod";
@@ -69,42 +77,212 @@ export async function saveKeywords(input: SaveKeywordsInput) {
);
}
- await KeywordResearchRepository.saveKeywordsToProject({
+ const savedRows = await KeywordResearchRepository.saveKeywordsToProject({
projectId: input.projectId,
keywords: normalizedKeywords,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
+ const savedKeywordIds = savedRows.map((row) => row.id);
- return { success: true };
-}
-
-export async function getSavedKeywords(
- input: GetSavedKeywordsInput,
-): Promise<{ rows: SavedKeywordRow[] }> {
- const rows = await KeywordResearchRepository.listSavedKeywordsByProject(
- input.projectId,
- );
+ if (input.tagMode === "replace") {
+ const replacementTags = input.tags ?? [];
+ if (replacementTags.length === 0) {
+ throw new Error("Replacement tags are required when tagMode is replace.");
+ }
+ await KeywordResearchRepository.replaceTagsForSavedKeywords({
+ projectId: input.projectId,
+ savedKeywordIds,
+ tagNames: replacementTags,
+ });
+ } else if ((input.tags?.length ?? 0) > 0) {
+ await KeywordResearchRepository.addTagsToSavedKeywords({
+ projectId: input.projectId,
+ savedKeywordIds,
+ tagNames: input.tags ?? [],
+ });
+ }
return {
- rows: rows.map(({ row, metric }) => ({
- id: row.id,
- projectId: row.projectId,
- keyword: row.keyword,
- locationCode: row.locationCode,
- languageCode: row.languageCode,
- createdAt: row.createdAt,
- searchVolume: metric?.searchVolume ?? null,
- cpc: metric?.cpc ?? null,
- competition: metric?.competition ?? null,
- keywordDifficulty: metric?.keywordDifficulty ?? null,
- intent: metric?.intent ?? null,
- monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null),
- fetchedAt: metric?.fetchedAt ?? null,
- })),
+ success: true,
+ savedKeywordIds,
};
}
+export async function getSavedKeywords(input: GetSavedKeywordsInput): Promise<{
+ rows: SavedKeywordRow[];
+ totalCount: number;
+ tags: SavedKeywordTagSummary[];
+}> {
+ const result = await KeywordResearchRepository.listSavedKeywordsByProject({
+ projectId: input.projectId,
+ search: input.search,
+ includeTerms: input.includeTerms,
+ excludeTerms: input.excludeTerms,
+ minVolume: input.minVolume,
+ maxVolume: input.maxVolume,
+ minCpc: input.minCpc,
+ maxCpc: input.maxCpc,
+ minDifficulty: input.minDifficulty,
+ maxDifficulty: input.maxDifficulty,
+ tagIds: input.tagIds,
+ tagNames: input.tagNames,
+ page: input.page,
+ pageSize: input.pageSize,
+ sort: input.sort,
+ order: input.order,
+ });
+
+ return {
+ rows: mapSavedKeywordRows(result.rows),
+ totalCount: result.totalCount,
+ tags: mapSavedKeywordTags(result.tags),
+ };
+}
+
+export async function exportSavedKeywords(
+ input: ExportSavedKeywordsInput,
+): Promise<{ rows: SavedKeywordRow[] }> {
+ const result = await KeywordResearchRepository.listSavedKeywordsByProject({
+ projectId: input.projectId,
+ search: input.search,
+ includeTerms: input.includeTerms,
+ excludeTerms: input.excludeTerms,
+ minVolume: input.minVolume,
+ maxVolume: input.maxVolume,
+ minCpc: input.minCpc,
+ maxCpc: input.maxCpc,
+ minDifficulty: input.minDifficulty,
+ maxDifficulty: input.maxDifficulty,
+ tagIds: input.tagIds,
+ tagNames: input.tagNames,
+ sort: input.sort,
+ order: input.order,
+ });
+
+ return { rows: mapSavedKeywordRows(result.rows) };
+}
+
+export async function updateSavedKeywordTags(
+ input: UpdateSavedKeywordTagsInput,
+) {
+ const addResult =
+ (input.addTags?.length ?? 0) > 0
+ ? await KeywordResearchRepository.addTagsToSavedKeywords({
+ projectId: input.projectId,
+ savedKeywordIds: input.savedKeywordIds,
+ tagNames: input.addTags ?? [],
+ })
+ : { savedKeywordCount: 0, tags: [] };
+
+ const removeResult =
+ (input.removeTagIds?.length ?? 0) > 0
+ ? await KeywordResearchRepository.removeTagsFromSavedKeywords({
+ projectId: input.projectId,
+ savedKeywordIds: input.savedKeywordIds,
+ tagIds: input.removeTagIds ?? [],
+ })
+ : { removedCount: 0, savedKeywordCount: 0, tags: [] };
+
+ return {
+ success: true,
+ taggedCount: Math.max(
+ addResult.savedKeywordCount,
+ removeResult.savedKeywordCount,
+ ),
+ addedTags: addResult.tags.map((tag) => ({
+ id: tag.id,
+ name: tag.name,
+ normalizedName: tag.normalizedName,
+ color: tag.color ?? null,
+ })),
+ removedTagIds: removeResult.tags.map((tag) => tag.id),
+ removedAssignments: removeResult.removedCount,
+ };
+}
+
+function mapSavedKeywordRows(
+ rows: Awaited<
+ ReturnType
+ >["rows"],
+): SavedKeywordRow[] {
+ return rows.map(({ row, metric, tags }) => ({
+ id: row.id,
+ projectId: row.projectId,
+ keyword: row.keyword,
+ locationCode: row.locationCode,
+ languageCode: row.languageCode,
+ createdAt: row.createdAt,
+ searchVolume: metric?.searchVolume ?? null,
+ cpc: metric?.cpc ?? null,
+ competition: metric?.competition ?? null,
+ keywordDifficulty: metric?.keywordDifficulty ?? null,
+ intent: metric?.intent ?? null,
+ monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null),
+ fetchedAt: metric?.fetchedAt ?? null,
+ tags: tags.map((tag) => ({
+ id: tag.id,
+ name: tag.name,
+ normalizedName: tag.normalizedName,
+ color: tag.color ?? null,
+ })),
+ }));
+}
+
+function mapSavedKeywordTags(
+ tags: Awaited<
+ ReturnType
+ >["tags"],
+): SavedKeywordTagSummary[] {
+ return tags.map((tag) => ({
+ id: tag.id,
+ name: tag.name,
+ normalizedName: tag.normalizedName,
+ color: tag.color ?? null,
+ keywordCount: tag.keywordCount,
+ }));
+}
+
+export async function updateSavedKeywordTag(input: UpdateSavedKeywordTagInput) {
+ const updated = await KeywordResearchRepository.updateSavedKeywordTag({
+ projectId: input.projectId,
+ tagId: input.tagId,
+ name: input.name,
+ color: input.color,
+ });
+ if (!updated) return { success: false as const };
+ return {
+ success: true as const,
+ tag: {
+ id: updated.id,
+ name: updated.name,
+ normalizedName: updated.normalizedName,
+ color: updated.color ?? null,
+ },
+ };
+}
+
+export async function deleteSavedKeywordTag(input: DeleteSavedKeywordTagInput) {
+ const result = await KeywordResearchRepository.deleteSavedKeywordTag({
+ projectId: input.projectId,
+ tagId: input.tagId,
+ });
+ if (result.status === "in_use") {
+ throw new TagInUseError(result.assignmentCount);
+ }
+ return { success: result.status === "deleted" };
+}
+
+class TagInUseError extends Error {
+ readonly code = "TAG_IN_USE" as const;
+ constructor(readonly assignmentCount: number) {
+ super(
+ `Tag is attached to ${assignmentCount} keyword${assignmentCount === 1 ? "" : "s"}. Remove the tag from those keywords first.`,
+ );
+ this.name = "TagInUseError";
+ }
+}
+
export async function removeSavedKeywords(
projectId: string,
input: RemoveSavedKeywordsInput,
diff --git a/src/server/mcp/tools/list-saved-keywords.ts b/src/server/mcp/tools/list-saved-keywords.ts
index 3e8fcd3..7ffd63f 100644
--- a/src/server/mcp/tools/list-saved-keywords.ts
+++ b/src/server/mcp/tools/list-saved-keywords.ts
@@ -1,4 +1,4 @@
-import type { z } from "zod";
+import { z } from "zod";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
@@ -7,6 +7,21 @@ import { projectIdSchema } from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
+ search: z
+ .string()
+ .min(1)
+ .max(200)
+ .optional()
+ .describe("Optional keyword text filter."),
+ tags: z
+ .array(z.string().min(1).max(64))
+ .max(20)
+ .optional()
+ .describe("Optional tag-name filters. Multiple tags match ANY tag."),
+ limit: z
+ .union([z.literal(50), z.literal(100), z.literal(250)])
+ .optional()
+ .describe("Maximum rows to return. Defaults to 100."),
} as const;
export const listSavedKeywordsTool = {
@@ -14,23 +29,33 @@ export const listSavedKeywordsTool = {
config: {
title: "List saved keywords",
description:
- "Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC if available). Free — reads from OpenSEO's database, no DataForSEO call.",
+ "Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Free — reads from OpenSEO's database, no DataForSEO call. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.",
inputSchema,
},
handler: withMcpProjectAuth(
async (args: z.infer>, context) => {
- const { rows } = await KeywordResearchService.getSavedKeywords({
- projectId: args.projectId,
- });
+ const { rows, totalCount, tags } =
+ await KeywordResearchService.getSavedKeywords({
+ projectId: args.projectId,
+ search: args.search,
+ tagNames: args.tags,
+ page: 1,
+ pageSize: args.limit ?? 100,
+ sort: "createdAt",
+ order: "desc",
+ });
const text =
rows.length === 0
? "No saved keywords yet."
- : `Saved keywords (${rows.length}):\n` +
+ : `Saved keywords (${rows.length} of ${totalCount}):\n` +
rows
- .map(
- (r) =>
- `- ${r.keyword} vol:${r.searchVolume ?? "?"} kd:${r.keywordDifficulty ?? "?"} cpc:${r.cpc != null ? `$${r.cpc.toFixed(2)}` : "?"}`,
- )
+ .map((row) => {
+ const tagText =
+ row.tags.length > 0
+ ? ` tags:${row.tags.map((tag) => tag.name).join(",")}`
+ : "";
+ return `- ${row.keyword} vol:${row.searchVolume ?? "?"} kd:${row.keywordDifficulty ?? "?"} cpc:${row.cpc != null ? `$${row.cpc.toFixed(2)}` : "?"}${tagText}`;
+ })
.join("\n");
return mcpResponse({
text,
@@ -39,7 +64,7 @@ export const listSavedKeywordsTool = {
args.projectId,
`/p/${args.projectId}/saved`,
),
- structuredContent: { rows },
+ structuredContent: { rows, totalCount, tags },
});
},
),
diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts
index a796921..35bc633 100644
--- a/src/server/mcp/tools/save-keywords.ts
+++ b/src/server/mcp/tools/save-keywords.ts
@@ -18,6 +18,19 @@ const inputSchema = {
.min(1)
.max(100)
.describe("Keywords to save (1-100)."),
+ tags: z
+ .array(z.string().min(1).max(64))
+ .max(20)
+ .optional()
+ .describe(
+ "Optional tags to attach to every saved keyword. Ask the user for explicit confirmation before using this, especially when saving many keywords or creating new tag names.",
+ ),
+ tagMode: z
+ .enum(["append", "replace"])
+ .optional()
+ .describe(
+ "How to apply tags. Defaults to append. Use replace to remove existing tags from these saved keywords before applying the provided tags.",
+ ),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
} as const;
@@ -29,18 +42,34 @@ export const saveKeywordsTool = {
config: {
title: "Save keywords",
description:
- "Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op.",
+ "Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
+ if (args.tagMode === "replace" && (args.tags?.length ?? 0) === 0) {
+ throw new Error("Replacement tags are required when tagMode is replace.");
+ }
+
+ const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
+ const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
+
await KeywordResearchService.saveKeywords({
projectId: args.projectId,
keywords: args.keywords,
- locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
- languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
+ tags: args.tags,
+ tagMode: args.tagMode ?? "append",
+ locationCode,
+ languageCode,
});
+
+ const tagText =
+ args.tags && args.tags.length > 0
+ ? ` with tag(s): ${args.tags.join(", ")}`
+ : "";
+ const modeText = args.tagMode === "replace" ? " Replaced tags." : "";
+
return mcpResponse({
- text: `Saved ${args.keywords.length} keyword(s) to project ${args.projectId}.`,
+ text: `Saved ${args.keywords.length} keyword(s)${tagText} to project ${args.projectId}.${modeText}`,
meta: buildProjectMeta(
context,
args.projectId,
@@ -50,8 +79,10 @@ export const saveKeywordsTool = {
projectId: args.projectId,
savedCount: args.keywords.length,
keywords: args.keywords,
- locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
- languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
+ tags: args.tags ?? [],
+ tagMode: args.tagMode ?? "append",
+ locationCode,
+ languageCode,
},
});
}),
diff --git a/src/server/mcp/tools/saved-keywords-tools.test.ts b/src/server/mcp/tools/saved-keywords-tools.test.ts
new file mode 100644
index 0000000..af1ee83
--- /dev/null
+++ b/src/server/mcp/tools/saved-keywords-tools.test.ts
@@ -0,0 +1,191 @@
+import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
+import type { ToolExtra } from "@/server/mcp/context";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
+
+const mocks = vi.hoisted(() => ({
+ getProjectForOrganization: vi.fn(),
+ getSavedKeywords: vi.fn(),
+ saveKeywords: vi.fn(),
+}));
+
+vi.mock("@/server/features/projects/services/ProjectService", () => ({
+ ProjectService: {
+ getProjectForOrganization: mocks.getProjectForOrganization,
+ },
+}));
+
+vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
+ KeywordResearchService: {
+ getSavedKeywords: mocks.getSavedKeywords,
+ saveKeywords: mocks.saveKeywords,
+ },
+}));
+
+const authContext = {
+ userId: "user_123",
+ userEmail: "alice@example.com",
+ organizationId: "org_123",
+ clientId: "client_123",
+ scopes: ["mcp"],
+ audience: "https://open-seo.test/mcp",
+ subject: "user_123",
+ baseUrl: "https://open-seo.test",
+};
+
+const toolExtra: ToolExtra = {
+ signal: new AbortController().signal,
+ requestId: 1,
+ sendNotification: vi.fn(),
+ sendRequest: vi.fn(),
+ authInfo: {
+ token: "token",
+ clientId: "client_123",
+ scopes: ["mcp"],
+ resource: new URL("https://open-seo.test/mcp"),
+ extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
+ } satisfies AuthInfo,
+};
+
+describe("saved keyword MCP tools", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ mocks.getProjectForOrganization.mockReset();
+ mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
+ mocks.getSavedKeywords.mockReset();
+ mocks.saveKeywords.mockReset();
+ });
+
+ it("passes tags through save_keywords", async () => {
+ mocks.saveKeywords.mockResolvedValue({
+ success: true,
+ savedKeywordIds: ["saved_1"],
+ });
+ const { saveKeywordsTool } = await import("./save-keywords");
+
+ const result = await saveKeywordsTool.handler(
+ {
+ projectId: "project_1",
+ keywords: ["technical seo"],
+ tags: ["Content"],
+ },
+ toolExtra,
+ );
+
+ expect(mocks.saveKeywords).toHaveBeenCalledWith({
+ projectId: "project_1",
+ keywords: ["technical seo"],
+ tags: ["Content"],
+ tagMode: "append",
+ locationCode: 2840,
+ languageCode: "en",
+ });
+ expect(result.structuredContent).toMatchObject({
+ savedCount: 1,
+ tags: ["Content"],
+ tagMode: "append",
+ });
+ });
+
+ it("replaces tags through save_keywords when requested", async () => {
+ mocks.saveKeywords.mockResolvedValue({
+ success: true,
+ savedKeywordIds: ["saved_1", "saved_2"],
+ });
+ const { saveKeywordsTool } = await import("./save-keywords");
+
+ const result = await saveKeywordsTool.handler(
+ {
+ projectId: "project_1",
+ keywords: ["semrush alternative", "semrush pricing"],
+ tags: ["cluster: affordable semrush alternatives"],
+ tagMode: "replace",
+ },
+ toolExtra,
+ );
+
+ expect(mocks.saveKeywords).toHaveBeenCalledWith({
+ projectId: "project_1",
+ keywords: ["semrush alternative", "semrush pricing"],
+ tags: ["cluster: affordable semrush alternatives"],
+ tagMode: "replace",
+ locationCode: 2840,
+ languageCode: "en",
+ });
+ expect(result.structuredContent).toMatchObject({
+ savedCount: 2,
+ tags: ["cluster: affordable semrush alternatives"],
+ tagMode: "replace",
+ });
+ });
+
+ it("rejects replace mode without replacement tags before saving", async () => {
+ const { saveKeywordsTool } = await import("./save-keywords");
+
+ await expect(() =>
+ saveKeywordsTool.handler(
+ {
+ projectId: "project_1",
+ keywords: ["semrush alternative"],
+ tagMode: "replace",
+ },
+ toolExtra,
+ ),
+ ).rejects.toThrow("Replacement tags are required");
+ expect(mocks.saveKeywords).not.toHaveBeenCalled();
+ });
+
+ it("filters list_saved_keywords by search and tag names", async () => {
+ mocks.getSavedKeywords.mockResolvedValue({
+ totalCount: 1,
+ tags: [
+ {
+ id: "tag_1",
+ name: "Content",
+ normalizedName: "content",
+ keywordCount: 1,
+ },
+ ],
+ rows: [
+ {
+ id: "saved_1",
+ keyword: "technical seo",
+ searchVolume: 120,
+ keywordDifficulty: 18,
+ cpc: 2.5,
+ tags: [{ id: "tag_1", name: "Content", normalizedName: "content" }],
+ },
+ ],
+ });
+ const { listSavedKeywordsTool } = await import("./list-saved-keywords");
+
+ const result = await listSavedKeywordsTool.handler(
+ {
+ projectId: "project_1",
+ search: "technical",
+ tags: ["Content"],
+ limit: 50,
+ },
+ toolExtra,
+ );
+
+ expect(mocks.getSavedKeywords).toHaveBeenCalledWith({
+ projectId: "project_1",
+ search: "technical",
+ tagNames: ["Content"],
+ page: 1,
+ pageSize: 50,
+ sort: "createdAt",
+ order: "desc",
+ });
+ expect(result.structuredContent).toMatchObject({
+ totalCount: 1,
+ rows: [{ keyword: "technical seo" }],
+ });
+ const [content] = result.content;
+ expect(content).toMatchObject({ type: "text" });
+ expect(content?.type === "text" ? content.text : "").toContain(
+ "tags:Content",
+ );
+ });
+});
diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts
index c59cbf6..2e93901 100644
--- a/src/serverFunctions/keywords.ts
+++ b/src/serverFunctions/keywords.ts
@@ -1,10 +1,14 @@
import { createServerFn } from "@tanstack/react-start";
import {
+ deleteSavedKeywordTagSchema,
researchKeywordsSchema,
saveKeywordsSchema,
getSavedKeywordsSchema,
+ exportSavedKeywordsSchema,
removeSavedKeywordsSchema,
serpAnalysisSchema,
+ updateSavedKeywordTagSchema,
+ updateSavedKeywordTagsSchema,
} from "@/types/schemas/keywords";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { requireProjectContext } from "@/serverFunctions/middleware";
@@ -42,6 +46,46 @@ export const getSavedKeywords = createServerFn({ method: "POST" })
});
});
+export const exportSavedKeywords = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => exportSavedKeywordsSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ return KeywordResearchService.exportSavedKeywords({
+ ...data,
+ projectId: context.projectId,
+ });
+ });
+
+export const updateSavedKeywordTags = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => updateSavedKeywordTagsSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ return KeywordResearchService.updateSavedKeywordTags({
+ ...data,
+ projectId: context.projectId,
+ });
+ });
+
+export const updateSavedKeywordTag = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => updateSavedKeywordTagSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ return KeywordResearchService.updateSavedKeywordTag({
+ ...data,
+ projectId: context.projectId,
+ });
+ });
+
+export const deleteSavedKeywordTag = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => deleteSavedKeywordTagSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ return KeywordResearchService.deleteSavedKeywordTag({
+ ...data,
+ projectId: context.projectId,
+ });
+ });
+
export const removeSavedKeywords = createServerFn({
method: "POST",
})
diff --git a/src/shared/saved-keyword-tags.test.ts b/src/shared/saved-keyword-tags.test.ts
new file mode 100644
index 0000000..9b9a9eb
--- /dev/null
+++ b/src/shared/saved-keyword-tags.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeSavedKeywordTag,
+ normalizeSavedKeywordTags,
+ parseSavedKeywordTagInput,
+} from "./saved-keyword-tags";
+
+describe("saved keyword tag helpers", () => {
+ it("normalizes display and lookup names", () => {
+ expect(normalizeSavedKeywordTag(" Technical SEO ")).toEqual({
+ name: "Technical SEO",
+ normalizedName: "technical seo",
+ });
+ });
+
+ it("dedupes tags by normalized name", () => {
+ expect(
+ normalizeSavedKeywordTags(["Content", "content", " technical seo "]),
+ ).toEqual([
+ { name: "Content", normalizedName: "content" },
+ { name: "technical seo", normalizedName: "technical seo" },
+ ]);
+ });
+
+ it("parses comma and newline separated tag input", () => {
+ expect(parseSavedKeywordTagInput("content, technical seo\nBOFU")).toEqual([
+ "content",
+ "technical seo",
+ "BOFU",
+ ]);
+ });
+});
diff --git a/src/shared/saved-keyword-tags.ts b/src/shared/saved-keyword-tags.ts
new file mode 100644
index 0000000..71a16c5
--- /dev/null
+++ b/src/shared/saved-keyword-tags.ts
@@ -0,0 +1,35 @@
+const TAG_SEPARATOR = /[\n,]+/;
+
+type NormalizedSavedKeywordTag = {
+ name: string;
+ normalizedName: string;
+};
+
+export function normalizeSavedKeywordTag(
+ value: string,
+): NormalizedSavedKeywordTag | null {
+ const name = value.trim().replace(/\s+/g, " ");
+ if (name.length === 0) return null;
+ return {
+ name,
+ normalizedName: name.toLocaleLowerCase(),
+ };
+}
+
+export function normalizeSavedKeywordTags(
+ values: readonly string[] | undefined,
+): NormalizedSavedKeywordTag[] {
+ const tags = new Map();
+ for (const value of values ?? []) {
+ const tag = normalizeSavedKeywordTag(value);
+ if (!tag || tags.has(tag.normalizedName)) continue;
+ tags.set(tag.normalizedName, tag);
+ }
+ return [...tags.values()];
+}
+
+export function parseSavedKeywordTagInput(value: string): string[] {
+ return normalizeSavedKeywordTags(value.split(TAG_SEPARATOR)).map(
+ (tag) => tag.name,
+ );
+}
diff --git a/src/shared/tag-colors.ts b/src/shared/tag-colors.ts
new file mode 100644
index 0000000..a642fc1
--- /dev/null
+++ b/src/shared/tag-colors.ts
@@ -0,0 +1,58 @@
+export const TAG_COLOR_KEYS = [
+ "slate",
+ "rose",
+ "amber",
+ "lime",
+ "emerald",
+ "sky",
+ "violet",
+ "fuchsia",
+] as const;
+
+export type TagColorKey = (typeof TAG_COLOR_KEYS)[number];
+
+function isTagColorKey(value: unknown): value is TagColorKey {
+ return (
+ typeof value === "string" &&
+ (TAG_COLOR_KEYS as readonly string[]).includes(value)
+ );
+}
+
+function hashString(value: string): number {
+ let hash = 0;
+ for (let i = 0; i < value.length; i++) {
+ hash = (hash * 31 + value.charCodeAt(i)) | 0;
+ }
+ return Math.abs(hash);
+}
+
+export function resolveTagColor(tag: {
+ id: string;
+ color?: string | null;
+}): TagColorKey {
+ if (isTagColorKey(tag.color)) return tag.color;
+ return TAG_COLOR_KEYS[hashString(tag.id) % TAG_COLOR_KEYS.length];
+}
+
+const COLOR_CLASS: Record = {
+ slate: "bg-slate-500",
+ rose: "bg-rose-500",
+ amber: "bg-amber-500",
+ lime: "bg-lime-500",
+ emerald: "bg-emerald-500",
+ sky: "bg-sky-500",
+ violet: "bg-violet-500",
+ fuchsia: "bg-fuchsia-500",
+};
+
+export function tagChipClass(color: TagColorKey): string {
+ return `tag-chip-${color} ring-1 ring-inset`;
+}
+
+export function tagDotClass(color: TagColorKey): string {
+ return COLOR_CLASS[color];
+}
+
+export function tagSwatchClass(color: TagColorKey): string {
+ return COLOR_CLASS[color];
+}
diff --git a/src/types/keywords.ts b/src/types/keywords.ts
index b6b4fb7..0a29ea8 100644
--- a/src/types/keywords.ts
+++ b/src/types/keywords.ts
@@ -35,6 +35,19 @@ export type SavedKeywordRow = {
intent: string | null;
monthlySearches: MonthlySearch[];
fetchedAt: string | null;
+ tags: SavedKeywordTag[];
+};
+
+export type SavedKeywordTag = {
+ id: string;
+ name: string;
+ normalizedName: string;
+ /** Palette key (e.g. "blue"). Null = derive a stable color from the id. */
+ color: string | null;
+};
+
+export type SavedKeywordTagSummary = SavedKeywordTag & {
+ keywordCount: number;
};
export type SerpResultItem = {
diff --git a/src/types/schemas/keywords.ts b/src/types/schemas/keywords.ts
index 0bcb7b6..1bc30ed 100644
--- a/src/types/schemas/keywords.ts
+++ b/src/types/schemas/keywords.ts
@@ -1,4 +1,18 @@
import { z } from "zod";
+import { TAG_COLOR_KEYS } from "@/shared/tag-colors";
+
+const savedKeywordTagSchema = z.string().trim().min(1).max(64);
+const tagColorSchema = z.enum(TAG_COLOR_KEYS);
+const savedKeywordSortFields = [
+ "createdAt",
+ "keyword",
+ "searchVolume",
+ "cpc",
+ "competition",
+ "keywordDifficulty",
+ "fetchedAt",
+] as const;
+const sortDirs = ["asc", "desc"] as const;
export const researchKeywordsSchema = z.object({
projectId: z.string().min(1),
@@ -14,49 +28,56 @@ export const researchKeywordsSchema = z.object({
.default("auto"),
});
-export const saveKeywordsSchema = z.object({
- projectId: z.string().min(1),
- keywords: z.array(z.string().min(1)).min(1).max(500),
- locationCode: z.number().int().positive().default(2840),
- languageCode: z.string().min(2).max(8).default("en"),
- metrics: z
- .array(
- z.object({
- keyword: z.string().min(1),
- searchVolume: z.number().int().nonnegative().nullable().optional(),
- cpc: z.number().nonnegative().nullable().optional(),
- competition: z.number().min(0).max(1).nullable().optional(),
- keywordDifficulty: z
- .number()
- .int()
- .min(0)
- .max(100)
- .nullable()
- .optional(),
- intent: z
- .enum([
- "informational",
- "commercial",
- "transactional",
- "navigational",
- "unknown",
- ])
- .nullable()
- .optional(),
- monthlySearches: z
- .array(
- z.object({
- year: z.number().int().positive(),
- month: z.number().int().min(1).max(12),
- searchVolume: z.number().int().nonnegative(),
- }),
- )
- .optional(),
- }),
- )
- .max(500)
- .optional(),
-});
+export const saveKeywordsSchema = z
+ .object({
+ projectId: z.string().min(1),
+ keywords: z.array(z.string().min(1)).min(1).max(500),
+ locationCode: z.number().int().positive().default(2840),
+ languageCode: z.string().min(2).max(8).default("en"),
+ tags: z.array(savedKeywordTagSchema).max(20).optional(),
+ tagMode: z.enum(["append", "replace"]).optional(),
+ metrics: z
+ .array(
+ z.object({
+ keyword: z.string().min(1),
+ searchVolume: z.number().int().nonnegative().nullable().optional(),
+ cpc: z.number().nonnegative().nullable().optional(),
+ competition: z.number().min(0).max(1).nullable().optional(),
+ keywordDifficulty: z
+ .number()
+ .int()
+ .min(0)
+ .max(100)
+ .nullable()
+ .optional(),
+ intent: z
+ .enum([
+ "informational",
+ "commercial",
+ "transactional",
+ "navigational",
+ "unknown",
+ ])
+ .nullable()
+ .optional(),
+ monthlySearches: z
+ .array(
+ z.object({
+ year: z.number().int().positive(),
+ month: z.number().int().min(1).max(12),
+ searchVolume: z.number().int().nonnegative(),
+ }),
+ )
+ .optional(),
+ }),
+ )
+ .max(500)
+ .optional(),
+ })
+ .refine(
+ (value) => value.tagMode !== "replace" || (value.tags?.length ?? 0) > 0,
+ "Replacement tags are required when tagMode is replace.",
+ );
export const removeSavedKeywordsSchema = z.object({
projectId: z.string().min(1),
@@ -65,6 +86,58 @@ export const removeSavedKeywordsSchema = z.object({
export const getSavedKeywordsSchema = z.object({
projectId: z.string().min(1),
+ search: z.string().trim().max(200).optional(),
+ includeTerms: z.array(z.string().trim().min(1)).max(20).optional(),
+ excludeTerms: z.array(z.string().trim().min(1)).max(20).optional(),
+ minVolume: z.number().int().nonnegative().nullable().optional(),
+ maxVolume: z.number().int().nonnegative().nullable().optional(),
+ minCpc: z.number().nonnegative().nullable().optional(),
+ maxCpc: z.number().nonnegative().nullable().optional(),
+ minDifficulty: z.number().int().min(0).max(100).nullable().optional(),
+ maxDifficulty: z.number().int().min(0).max(100).nullable().optional(),
+ tagIds: z.array(z.string().min(1)).max(50).optional(),
+ tagNames: z.array(savedKeywordTagSchema).max(50).optional(),
+ page: z.number().int().positive().default(1),
+ pageSize: z
+ .union([z.literal(50), z.literal(100), z.literal(250)])
+ .default(50),
+ sort: z.enum(savedKeywordSortFields).default("createdAt"),
+ order: z.enum(sortDirs).default("desc"),
+});
+
+export const exportSavedKeywordsSchema = getSavedKeywordsSchema.omit({
+ page: true,
+ pageSize: true,
+});
+
+export const updateSavedKeywordTagsSchema = z
+ .object({
+ projectId: z.string().min(1),
+ savedKeywordIds: z.array(z.string().min(1)).min(1).max(2000),
+ addTags: z.array(savedKeywordTagSchema).max(20).optional(),
+ removeTagIds: z.array(z.string().min(1)).max(50).optional(),
+ })
+ .refine(
+ (value) =>
+ (value.addTags?.length ?? 0) > 0 || (value.removeTagIds?.length ?? 0) > 0,
+ "Add or remove at least one tag.",
+ );
+
+export const updateSavedKeywordTagSchema = z
+ .object({
+ projectId: z.string().min(1),
+ tagId: z.string().min(1),
+ name: savedKeywordTagSchema.optional(),
+ color: tagColorSchema.nullable().optional(),
+ })
+ .refine(
+ (value) => value.name !== undefined || value.color !== undefined,
+ "Provide a name or color to update.",
+ );
+
+export const deleteSavedKeywordTagSchema = z.object({
+ projectId: z.string().min(1),
+ tagId: z.string().min(1),
});
export type ResearchKeywordsInput = z.infer;
@@ -72,6 +145,19 @@ export type SaveKeywordsInput = z.infer;
export type RemoveSavedKeywordsInput = z.infer<
typeof removeSavedKeywordsSchema
>;
+export type GetSavedKeywordsInput = z.infer;
+export type ExportSavedKeywordsInput = z.infer<
+ typeof exportSavedKeywordsSchema
+>;
+export type UpdateSavedKeywordTagsInput = z.infer<
+ typeof updateSavedKeywordTagsSchema
+>;
+export type UpdateSavedKeywordTagInput = z.infer<
+ typeof updateSavedKeywordTagSchema
+>;
+export type DeleteSavedKeywordTagInput = z.infer<
+ typeof deleteSavedKeywordTagSchema
+>;
export const serpAnalysisSchema = z.object({
projectId: z.string().min(1),
keyword: z.string().min(1),
@@ -79,8 +165,6 @@ export const serpAnalysisSchema = z.object({
languageCode: z.string().min(2).max(8).default("en"),
});
-export type GetSavedKeywordsInput = z.infer;
-
/* ------------------------------------------------------------------ */
/* URL search params schema for /p/$projectId/keywords */
/* ------------------------------------------------------------------ */
@@ -93,7 +177,6 @@ const keywordSortFields = [
"keywordDifficulty",
] as const;
-const sortDirs = ["asc", "desc"] as const;
const keywordModes = ["auto", "related", "suggestions", "ideas"] as const;
export const keywordsSearchSchema = z.object({