feat: configurable default market for location/language fallbacks (#72)

This commit is contained in:
bookingseo 2026-07-16 21:46:49 +07:00 committed by GitHub
parent c1121bdcab
commit 0f08437cd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 1490 additions and 363 deletions

View File

@ -1,5 +1,5 @@
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { ResearchKeywordsInput } from "@/types/schemas/keywords"; import type { ResolvedResearchKeywordsInput } from "@/types/schemas/keywords";
const MONTHLY_SEARCHES = [ const MONTHLY_SEARCHES = [
{ year: 2025, month: 4, searchVolume: 1200 }, { year: 2025, month: 4, searchVolume: 1200 },
@ -33,7 +33,7 @@ function makeRow(
}; };
} }
export function getKeywordResearchFixture(data: ResearchKeywordsInput) { export function getKeywordResearchFixture(data: ResolvedResearchKeywordsInput) {
const seedKeyword = data.keywords[0] ?? "keyword research"; const seedKeyword = data.keywords[0] ?? "keyword research";
const rows = [ const rows = [
makeRow(seedKeyword, 0, { makeRow(seedKeyword, 0, {

View File

@ -8,9 +8,7 @@ import {
type DomainSearchParams, type DomainSearchParams,
} from "@/types/schemas/domain"; } from "@/types/schemas/domain";
import { import {
DEFAULT_LOCATION_CODE,
LOCATIONS, LOCATIONS,
getLanguageCode,
isLabsLocationCode, isLabsLocationCode,
} from "@/client/features/keywords/locations"; } from "@/client/features/keywords/locations";
import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory"; import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory";
@ -80,10 +78,13 @@ function getSortSearchUpdate(
}; };
} }
function getLocationSearchUpdate(nextLocationCode: number): DomainSearchUpdate { function getLocationSearchUpdate(
nextLocationCode: number,
defaultLocationCode: number,
): DomainSearchUpdate {
return { return {
loc: loc:
nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode, nextLocationCode === defaultLocationCode ? undefined : nextLocationCode,
page: undefined, page: undefined,
}; };
} }
@ -122,11 +123,12 @@ function getTabSearchUpdate(
function getHistorySearchUpdate( function getHistorySearchUpdate(
item: DomainSearchHistoryItem, item: DomainSearchHistoryItem,
defaultLocationCode: number,
): DomainSearchUpdate { ): DomainSearchUpdate {
const historyLocation = const historyLocation =
item.locationCode != null && isLabsLocationCode(item.locationCode) item.locationCode != null && isLabsLocationCode(item.locationCode)
? item.locationCode ? item.locationCode
: DEFAULT_LOCATION_CODE; : defaultLocationCode;
return { return {
...buildDomainFiltersClearSearchUpdate(), ...buildDomainFiltersClearSearchUpdate(),
@ -135,8 +137,7 @@ function getHistorySearchUpdate(
sort: toSortSearchParam(item.sort), sort: toSortSearchParam(item.sort),
order: undefined, order: undefined,
tab: item.tab === "keywords" ? undefined : item.tab, tab: item.tab === "keywords" ? undefined : item.tab,
loc: loc: historyLocation === defaultLocationCode ? undefined : historyLocation,
historyLocation === DEFAULT_LOCATION_CODE ? undefined : historyLocation,
size: undefined, size: undefined,
}; };
} }
@ -148,6 +149,7 @@ function getSearchSubmitUpdate({
locationCode, locationCode,
currentOrder, currentOrder,
activeTab, activeTab,
defaultLocationCode,
}: { }: {
domain: string; domain: string;
subdomains: boolean; subdomains: boolean;
@ -155,6 +157,7 @@ function getSearchSubmitUpdate({
locationCode: number; locationCode: number;
currentOrder: SortOrder; currentOrder: SortOrder;
activeTab: DomainActiveTab; activeTab: DomainActiveTab;
defaultLocationCode: number;
}): DomainSearchUpdate { }): DomainSearchUpdate {
return { return {
...buildDomainFiltersClearSearchUpdate(), ...buildDomainFiltersClearSearchUpdate(),
@ -163,7 +166,7 @@ function getSearchSubmitUpdate({
sort: toSortSearchParam(sort), sort: toSortSearchParam(sort),
order: toSortOrderSearchParam(sort, currentOrder), order: toSortOrderSearchParam(sort, currentOrder),
tab: activeTab === "keywords" ? undefined : activeTab, tab: activeTab === "keywords" ? undefined : activeTab,
loc: locationCode === DEFAULT_LOCATION_CODE ? undefined : locationCode, loc: locationCode === defaultLocationCode ? undefined : locationCode,
size: undefined, size: undefined,
}; };
} }
@ -205,9 +208,14 @@ function useDomainOverviewState({
const applyLocationChange = useCallback( const applyLocationChange = useCallback(
(nextLocationCode: number) => { (nextLocationCode: number) => {
setSearchParams(getLocationSearchUpdate(nextLocationCode)); setSearchParams(
getLocationSearchUpdate(
nextLocationCode,
routeState.defaultLocationCode,
),
);
}, },
[setSearchParams], [routeState.defaultLocationCode, setSearchParams],
); );
const handleSortColumnClick = useCallback( const handleSortColumnClick = useCallback(
@ -246,21 +254,21 @@ function useDomainOverviewState({
const handleHistorySelect = useCallback( const handleHistorySelect = useCallback(
(item: DomainSearchHistoryItem) => { (item: DomainSearchHistoryItem) => {
setSearchParams(getHistorySearchUpdate(item)); setSearchParams(
getHistorySearchUpdate(item, routeState.defaultLocationCode),
);
}, },
[setSearchParams], [routeState.defaultLocationCode, setSearchParams],
); );
const languageCode = getLanguageCode(routeState.locationCode);
const overviewQuery = useDomainOverviewQuery({ const overviewQuery = useDomainOverviewQuery({
projectId, projectId,
domain: routeState.domain, domain: routeState.domain,
includeSubdomains: routeState.subdomains, includeSubdomains: routeState.subdomains,
locationCode: routeState.locationCode, locationCode: routeState.sentLocationCode,
languageCode,
}); });
const overview = overviewQuery.data ?? null; const overview = overviewQuery.data ?? null;
const isLoading = overviewQuery.isLoading; const isLoading = routeState.domain.trim() !== "" && overviewQuery.isLoading;
const controlsForm = useForm({ const controlsForm = useForm({
defaultValues: { defaultValues: {
@ -290,6 +298,7 @@ function useDomainOverviewState({
locationCode: value.locationCode, locationCode: value.locationCode,
currentOrder: routeState.order, currentOrder: routeState.order,
activeTab: routeState.tab, activeTab: routeState.tab,
defaultLocationCode: routeState.defaultLocationCode,
}), }),
); );
}, },
@ -389,7 +398,6 @@ function useDomainOverviewState({
history, history,
historyLoaded, historyLoaded,
removeHistoryItem, removeHistoryItem,
languageCode,
setSearchParams, setSearchParams,
applySort, applySort,
applyLocationChange, applyLocationChange,
@ -412,16 +420,20 @@ export function DomainOverviewPage({
navigate, navigate,
onShowRecentSearches, onShowRecentSearches,
}: Props) { }: Props) {
const state = useDomainOverviewState({ navigate, routeState, projectId }); const state = useDomainOverviewState({
navigate,
routeState,
projectId,
});
const urlTabInput = useMemo<SearchTabInput | null>(() => { const urlTabInput = useMemo<SearchTabInput | null>(() => {
if (routeState.domain.trim() === "") return null; if (routeState.domain.trim() === "") return null;
return { return {
type: "domain", type: "domain",
domain: routeState.domain, domain: routeState.domain,
subdomains: routeState.subdomains, subdomains: routeState.subdomains,
locationCode: routeState.locationCode, locationCode: routeState.sentLocationCode,
}; };
}, [routeState.domain, routeState.locationCode, routeState.subdomains]); }, [routeState.domain, routeState.sentLocationCode, routeState.subdomains]);
const navigateToSearchTab = useCallback( const navigateToSearchTab = useCallback(
(input: SearchTabInput | null) => { (input: SearchTabInput | null) => {
@ -443,10 +455,7 @@ export function DomainOverviewPage({
order: undefined, order: undefined,
tab: undefined, tab: undefined,
page: undefined, page: undefined,
loc: loc: input.locationCode,
input.locationCode === DEFAULT_LOCATION_CODE
? undefined
: input.locationCode,
size: undefined, size: undefined,
}), }),
replace: true, replace: true,
@ -458,14 +467,18 @@ export function DomainOverviewPage({
const searchTabs = useSearchTabNavigation({ const searchTabs = useSearchTabNavigation({
storageKey: `domain:${projectId}`, storageKey: `domain:${projectId}`,
urlInput: urlTabInput, urlInput: urlTabInput,
getLabel: useCallback((input) => { getLabel: useCallback(
(input) => {
if (input.type !== "domain") return ""; if (input.type !== "domain") return "";
const locationSuffix = const locationSuffix =
input.locationCode === DEFAULT_LOCATION_CODE input.locationCode == null ||
input.locationCode === routeState.defaultLocationCode
? "" ? ""
: ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`; : ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`;
return `${input.domain}${locationSuffix}`; return `${input.domain}${locationSuffix}`;
}, []), },
[routeState.defaultLocationCode],
),
navigateToInput: navigateToSearchTab, navigateToInput: navigateToSearchTab,
}); });
@ -623,7 +636,6 @@ export function DomainOverviewPage({
key="keywords" key="keywords"
projectId={projectId} projectId={projectId}
domain={state.overview.domain} domain={state.overview.domain}
languageCode={state.languageCode}
routeState={routeState} routeState={routeState}
canSaveKeywords={state.canSaveKeywords} canSaveKeywords={state.canSaveKeywords}
setSearchParams={state.setSearchParams} setSearchParams={state.setSearchParams}
@ -636,7 +648,6 @@ export function DomainOverviewPage({
key="pages" key="pages"
projectId={projectId} projectId={projectId}
domain={state.overview.domain} domain={state.overview.domain}
languageCode={state.languageCode}
routeState={routeState} routeState={routeState}
setSearchParams={state.setSearchParams} setSearchParams={state.setSearchParams}
onSortClick={state.handleSortColumnClick} onSortClick={state.handleSortColumnClick}

View File

@ -65,7 +65,6 @@ const KEYWORD_RANGE_FILTERS = [
type Props = { type Props = {
projectId: string; projectId: string;
domain: string; domain: string;
languageCode: string;
routeState: DomainOverviewRouteState; routeState: DomainOverviewRouteState;
canSaveKeywords: boolean; canSaveKeywords: boolean;
setSearchParams: (updates: SearchUpdate) => void; setSearchParams: (updates: SearchUpdate) => void;
@ -77,7 +76,6 @@ type Props = {
export function KeywordsTab({ export function KeywordsTab({
projectId, projectId,
domain, domain,
languageCode,
routeState, routeState,
canSaveKeywords, canSaveKeywords,
setSearchParams, setSearchParams,
@ -106,8 +104,7 @@ export function KeywordsTab({
projectId, projectId,
domain, domain,
includeSubdomains: routeState.subdomains, includeSubdomains: routeState.subdomains,
locationCode: routeState.locationCode, locationCode: routeState.sentLocationCode,
languageCode,
page: routeState.page, page: routeState.page,
pageSize: routeState.pageSize, pageSize: routeState.pageSize,
sortMode: routeState.sort, sortMode: routeState.sort,
@ -159,13 +156,11 @@ export function KeywordsTab({
filteredKeywords: rows, filteredKeywords: rows,
save: saveMutation.mutate, save: saveMutation.mutate,
projectId, projectId,
locationCode: routeState.locationCode, locationCode: routeState.sentLocationCode,
languageCode,
}); });
}, [ }, [
languageCode,
projectId, projectId,
routeState.locationCode, routeState.sentLocationCode,
rows, rows,
saveMutation.mutate, saveMutation.mutate,
selectedKeywords, selectedKeywords,

View File

@ -55,7 +55,6 @@ const PAGE_RANGE_FILTERS = [
type Props = { type Props = {
projectId: string; projectId: string;
domain: string; domain: string;
languageCode: string;
routeState: DomainOverviewRouteState; routeState: DomainOverviewRouteState;
setSearchParams: (updates: SearchUpdate) => void; setSearchParams: (updates: SearchUpdate) => void;
onSortClick: (sort: DomainSortMode) => void; onSortClick: (sort: DomainSortMode) => void;
@ -66,7 +65,6 @@ type Props = {
export function PagesTab({ export function PagesTab({
projectId, projectId,
domain, domain,
languageCode,
routeState, routeState,
setSearchParams, setSearchParams,
onSortClick, onSortClick,
@ -98,8 +96,7 @@ export function PagesTab({
projectId, projectId,
domain, domain,
includeSubdomains: routeState.subdomains, includeSubdomains: routeState.subdomains,
locationCode: routeState.locationCode, locationCode: routeState.sentLocationCode,
languageCode,
page: routeState.page, page: routeState.page,
pageSize: routeState.pageSize, pageSize: routeState.pageSize,
sortMode: routeState.sort, sortMode: routeState.sort,

View File

@ -6,8 +6,7 @@ import type { KeywordRow } from "@/client/features/domain/types";
type SaveMutation = (payload: { type SaveMutation = (payload: {
projectId: string; projectId: string;
keywords: string[]; keywords: string[];
locationCode: number; locationCode?: number;
languageCode: string;
metrics?: Array<{ metrics?: Array<{
keyword: string; keyword: string;
searchVolume?: number | null; searchVolume?: number | null;
@ -27,14 +26,12 @@ export function saveSelectedKeywords({
save, save,
projectId, projectId,
locationCode, locationCode,
languageCode,
}: { }: {
selectedKeywords: Set<string>; selectedKeywords: Set<string>;
filteredKeywords: KeywordRow[]; filteredKeywords: KeywordRow[];
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void; save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
projectId: string; projectId: string;
locationCode: number; locationCode?: number;
languageCode: string;
}) { }) {
if (selectedKeywords.size === 0) { if (selectedKeywords.size === 0) {
toast.error("Select at least one keyword first"); toast.error("Select at least one keyword first");
@ -49,7 +46,6 @@ export function saveSelectedKeywords({
projectId, projectId,
keywords: [...selectedKeywords], keywords: [...selectedKeywords],
locationCode, locationCode,
languageCode,
metrics: selectedRows.map((row) => ({ metrics: selectedRows.map((row) => ({
keyword: row.keyword, keyword: row.keyword,
searchVolume: row.searchVolume, searchVolume: row.searchVolume,

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { getDomainRouteState } from "./domainRouteState";
describe("getDomainRouteState", () => {
it("uses a Labs-backed project market when the URL omits loc", () => {
const state = getDomainRouteState(
{},
{ locationCode: 2704, languageCode: "vi" },
);
expect(state.defaultLocationCode).toBe(2704);
expect(state.locationCode).toBe(2704);
expect(state.sentLocationCode).toBeUndefined();
});
it("keeps an explicit Labs-backed URL location", () => {
const state = getDomainRouteState(
{ loc: 2840 },
{ locationCode: 2704, languageCode: "vi" },
);
expect(state.defaultLocationCode).toBe(2704);
expect(state.locationCode).toBe(2840);
expect(state.sentLocationCode).toBe(2840);
});
it("falls back to US for a Google-Ads-only project market", () => {
const state = getDomainRouteState(
{},
{ locationCode: 2352, languageCode: "is" },
);
expect(state.defaultLocationCode).toBe(2840);
expect(state.locationCode).toBe(2840);
expect(state.sentLocationCode).toBeUndefined();
});
it("ignores a Google-Ads-only URL location", () => {
const state = getDomainRouteState(
{ loc: 2352 },
{ locationCode: 2704, languageCode: "vi" },
);
expect(state.defaultLocationCode).toBe(2704);
expect(state.locationCode).toBe(2704);
expect(state.sentLocationCode).toBe(2352);
});
});

View File

@ -6,6 +6,7 @@ import {
DEFAULT_LOCATION_CODE, DEFAULT_LOCATION_CODE,
isLabsLocationCode, isLabsLocationCode,
} from "@/client/features/keywords/locations"; } from "@/client/features/keywords/locations";
import type { ProjectMarket } from "@/client/features/projects/types";
import { import {
EMPTY_DOMAIN_FILTERS, EMPTY_DOMAIN_FILTERS,
type DomainActiveTab, type DomainActiveTab,
@ -28,7 +29,9 @@ export type DomainOverviewRouteState = {
sort: DomainSortMode; sort: DomainSortMode;
order: SortOrder; order: SortOrder;
tab: DomainActiveTab; tab: DomainActiveTab;
defaultLocationCode: number;
locationCode: number; locationCode: number;
sentLocationCode: number | undefined;
page: number; page: number;
pageSize: number; pageSize: number;
appliedFilters: DomainFilterValues; appliedFilters: DomainFilterValues;
@ -44,13 +47,18 @@ function numberToFilterString(value: number | undefined): string {
export function getDomainRouteState( export function getDomainRouteState(
search: DomainSearchParams, search: DomainSearchParams,
projectMarket?: ProjectMarket,
): DomainOverviewRouteState { ): DomainOverviewRouteState {
const normalizedSort = toSortMode(search.sort ?? null) ?? "traffic"; const normalizedSort = toSortMode(search.sort ?? null) ?? "traffic";
const defaultLocationCode =
projectMarket && isLabsLocationCode(projectMarket.locationCode)
? projectMarket.locationCode
: DEFAULT_LOCATION_CODE;
// Domain analytics is Labs-backed; Google-Ads-only countries aren't valid. // Domain analytics is Labs-backed; Google-Ads-only countries aren't valid.
const normalizedLocationCode = const normalizedLocationCode =
search.loc != null && isLabsLocationCode(search.loc) search.loc != null && isLabsLocationCode(search.loc)
? search.loc ? search.loc
: DEFAULT_LOCATION_CODE; : defaultLocationCode;
return { return {
domain: search.domain ?? "", domain: search.domain ?? "",
@ -58,7 +66,9 @@ export function getDomainRouteState(
sort: normalizedSort, sort: normalizedSort,
order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)), order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)),
tab: search.tab ?? "keywords", tab: search.tab ?? "keywords",
defaultLocationCode,
locationCode: normalizedLocationCode, locationCode: normalizedLocationCode,
sentLocationCode: search.loc,
page: search.page != null && search.page > 0 ? search.page : 1, page: search.page != null && search.page > 0 ? search.page : 1,
pageSize: search.size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE, pageSize: search.size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
appliedFilters: { appliedFilters: {

View File

@ -12,8 +12,7 @@ type DomainKeywordsQueryInput = {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; includeSubdomains: boolean;
locationCode: number; locationCode: number | undefined;
languageCode: string;
page: number; page: number;
pageSize: number; pageSize: number;
sortMode: DomainSortMode; sortMode: DomainSortMode;
@ -60,7 +59,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
input.domain, input.domain,
input.includeSubdomains, input.includeSubdomains,
input.locationCode, input.locationCode,
input.languageCode,
input.page, input.page,
input.pageSize, input.pageSize,
input.sortMode, input.sortMode,
@ -71,7 +69,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
filtersPayload, filtersPayload,
input.domain, input.domain,
input.includeSubdomains, input.includeSubdomains,
input.languageCode,
input.locationCode, input.locationCode,
input.page, input.page,
input.pageSize, input.pageSize,
@ -98,7 +95,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
domain: input.domain, domain: input.domain,
includeSubdomains: input.includeSubdomains, includeSubdomains: input.includeSubdomains,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode,
page: input.page, page: input.page,
pageSize: input.pageSize, pageSize: input.pageSize,
sortMode: input.sortMode, sortMode: input.sortMode,

View File

@ -5,8 +5,7 @@ type Input = {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; includeSubdomains: boolean;
locationCode: number; locationCode: number | undefined;
languageCode: string;
}; };
export function useDomainOverviewQuery(input: Input) { export function useDomainOverviewQuery(input: Input) {
@ -20,7 +19,6 @@ export function useDomainOverviewQuery(input: Input) {
trimmedDomain, trimmedDomain,
input.includeSubdomains, input.includeSubdomains,
input.locationCode, input.locationCode,
input.languageCode,
], ],
queryFn: () => queryFn: () =>
getDomainOverview({ getDomainOverview({
@ -29,7 +27,6 @@ export function useDomainOverviewQuery(input: Input) {
domain: trimmedDomain, domain: trimmedDomain,
includeSubdomains: input.includeSubdomains, includeSubdomains: input.includeSubdomains,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode,
}, },
}), }),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,

View File

@ -13,8 +13,7 @@ type DomainPagesQueryInput = {
projectId: string; projectId: string;
domain: string; domain: string;
includeSubdomains: boolean; includeSubdomains: boolean;
locationCode: number; locationCode: number | undefined;
languageCode: string;
page: number; page: number;
pageSize: number; pageSize: number;
sortMode: DomainSortMode; sortMode: DomainSortMode;
@ -32,7 +31,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
input.domain, input.domain,
input.includeSubdomains, input.includeSubdomains,
input.locationCode, input.locationCode,
input.languageCode,
input.page, input.page,
input.pageSize, input.pageSize,
pageSortMode, pageSortMode,
@ -43,7 +41,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
input.appliedFilters, input.appliedFilters,
input.domain, input.domain,
input.includeSubdomains, input.includeSubdomains,
input.languageCode,
input.locationCode, input.locationCode,
input.page, input.page,
input.pageSize, input.pageSize,
@ -70,7 +67,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
domain: input.domain, domain: input.domain,
includeSubdomains: input.includeSubdomains, includeSubdomains: input.includeSubdomains,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode,
page: input.page, page: input.page,
pageSize: input.pageSize, pageSize: input.pageSize,
sortMode: pageSortMode, sortMode: pageSortMode,

View File

@ -12,8 +12,7 @@ export function useSaveKeywordsMutation({
mutationFn: (data: { mutationFn: (data: {
projectId: string; projectId: string;
keywords: string[]; keywords: string[];
locationCode: number; locationCode?: number;
languageCode: string;
metrics?: Array<{ metrics?: Array<{
keyword: string; keyword: string;
searchVolume?: number | null; searchVolume?: number | null;

View File

@ -13,7 +13,7 @@ import { parseKeywordInput } from "@/client/features/keywords/state/keywordContr
type KeywordTabValidationInput = { type KeywordTabValidationInput = {
keyword: string; keyword: string;
locationCode: number; locationCode: number | undefined;
resultLimit: ResultLimit; resultLimit: ResultLimit;
mode: KeywordMode; mode: KeywordMode;
clickstream: boolean; clickstream: boolean;

View File

@ -0,0 +1,35 @@
import { describe, expect, it, vi } from "vitest";
// The hook module pulls in the server functions it calls, whose graph reaches
// Workers-only bindings that don't resolve outside workerd.
vi.mock("cloudflare:workers", () => ({ env: {} }));
import { buildKeywordResearchRequest } from "./useKeywordResearchData";
const baseInput = {
projectId: "project_1",
keywordInput: "technical seo",
locationCode: 2704,
resultLimit: 150 as const,
mode: "auto" as const,
clickstream: false,
};
describe("buildKeywordResearchRequest", () => {
it("carries an explicitly selected location without a language", () => {
const request = buildKeywordResearchRequest(baseInput);
expect(request).toMatchObject({ locationCode: 2704 });
expect(request).not.toHaveProperty("languageCode");
});
it("leaves the location undefined for the server to resolve", () => {
const request = buildKeywordResearchRequest({
...baseInput,
locationCode: undefined,
});
expect(request).toMatchObject({ locationCode: undefined });
expect(request).not.toHaveProperty("languageCode");
});
});

View File

@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils"; import { LOCATIONS } from "@/client/features/keywords/utils";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions"; import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
import { researchKeywords } from "@/serverFunctions/keywords"; import { researchKeywords } from "@/serverFunctions/keywords";
import type { import type {
@ -18,21 +17,24 @@ type AddSearchFn = (
locationName: string, locationName: string,
) => void; ) => void;
type KeywordResearchQueryInput = { type KeywordResearchRequestInput = {
projectId: string; projectId: string;
keywordInput: string; keywordInput: string;
locationCode: number; locationCode: number | undefined;
resultLimit: ResultLimit; resultLimit: ResultLimit;
mode: KeywordMode; mode: KeywordMode;
clickstream: boolean; clickstream: boolean;
}; };
type KeywordResearchQueryInput = KeywordResearchRequestInput & {
displayedLocationCode: number;
};
type KeywordResearchRequest = { type KeywordResearchRequest = {
projectId: string; projectId: string;
keywords: string[]; keywords: string[];
seedKeyword: string; seedKeyword: string;
locationCode: number; locationCode: number | undefined;
languageCode: string;
resultLimit: ResultLimit; resultLimit: ResultLimit;
mode: KeywordMode; mode: KeywordMode;
clickstream: boolean; clickstream: boolean;
@ -41,7 +43,7 @@ type KeywordResearchRequest = {
export const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000; export const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000;
export function buildKeywordResearchRequest( export function buildKeywordResearchRequest(
input: KeywordResearchQueryInput, input: KeywordResearchRequestInput,
): KeywordResearchRequest | null { ): KeywordResearchRequest | null {
const keywords = parseKeywordInput(input.keywordInput); const keywords = parseKeywordInput(input.keywordInput);
const seedKeyword = keywords[0] ?? ""; const seedKeyword = keywords[0] ?? "";
@ -52,7 +54,6 @@ export function buildKeywordResearchRequest(
keywords, keywords,
seedKeyword, seedKeyword,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: getLanguageCode(input.locationCode),
resultLimit: input.resultLimit, resultLimit: input.resultLimit,
mode: input.mode, mode: input.mode,
clickstream: input.clickstream, clickstream: input.clickstream,
@ -68,7 +69,6 @@ export function buildKeywordResearchQueryKey(
request.projectId, request.projectId,
request.keywords, request.keywords,
request.locationCode, request.locationCode,
request.languageCode,
request.resultLimit, request.resultLimit,
request.mode, request.mode,
request.clickstream, request.clickstream,
@ -82,7 +82,6 @@ export function keywordResearchQueryFn(request: KeywordResearchRequest) {
projectId: request.projectId, projectId: request.projectId,
keywords: request.keywords, keywords: request.keywords,
locationCode: request.locationCode, locationCode: request.locationCode,
languageCode: request.languageCode,
resultLimit: request.resultLimit, resultLimit: request.resultLimit,
mode: request.mode, mode: request.mode,
clickstream: request.clickstream, clickstream: request.clickstream,
@ -96,6 +95,7 @@ export function useKeywordResearchData(
) { ) {
const { const {
clickstream, clickstream,
displayedLocationCode,
keywordInput, keywordInput,
locationCode, locationCode,
mode, mode,
@ -144,7 +144,7 @@ export function useKeywordResearchData(
handledSuccessKeyRef.current = queryKeyString; handledSuccessKeyRef.current = queryKeyString;
captureClientEvent("keyword_research:search_complete", { captureClientEvent("keyword_research:search_complete", {
location_code: request.locationCode, location_code: displayedLocationCode,
search_mode: request.mode, search_mode: request.mode,
clickstream: request.clickstream, clickstream: request.clickstream,
result_count: researchQuery.data.rows.length, result_count: researchQuery.data.rows.length,
@ -152,18 +152,19 @@ export function useKeywordResearchData(
addSearch( addSearch(
request.seedKeyword, request.seedKeyword,
request.locationCode, displayedLocationCode,
LOCATIONS[request.locationCode] || "Unknown", LOCATIONS[displayedLocationCode] || "Unknown",
); );
}, [ }, [
addSearch, addSearch,
displayedLocationCode,
queryKeyString, queryKeyString,
request, request,
researchQuery.data, researchQuery.data,
researchQuery.isSuccess, researchQuery.isSuccess,
]); ]);
const hasSearched = request !== null; const hasSearched = parseKeywordInput(keywordInput).length > 0;
const rows = hasSearched ? (researchQuery.data?.rows ?? []) : []; const rows = hasSearched ? (researchQuery.data?.rows ?? []) : [];
const researchError = const researchError =
hasSearched && researchQuery.isError hasSearched && researchQuery.isError
@ -178,7 +179,7 @@ export function useKeywordResearchData(
researchQuery.data?.source ?? ("related" as ResearchSource), researchQuery.data?.source ?? ("related" as ResearchSource),
lastUsedFallback: researchQuery.data?.usedFallback ?? false, lastUsedFallback: researchQuery.data?.usedFallback ?? false,
lastSearchKeyword: request?.seedKeyword ?? "", lastSearchKeyword: request?.seedKeyword ?? "",
lastSearchLocationCode: request?.locationCode ?? DEFAULT_LOCATION_CODE, lastSearchLocationCode: displayedLocationCode,
researchError, researchError,
researchMutationError: researchQuery.error, researchMutationError: researchQuery.error,
searchedKeyword: request?.seedKeyword ?? "", searchedKeyword: request?.seedKeyword ?? "",

View File

@ -1,12 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getLanguageCode } from "@/client/features/keywords/utils";
import { getSerpAnalysis } from "@/serverFunctions/keywords"; import { getSerpAnalysis } from "@/serverFunctions/keywords";
export function useKeywordSerpAnalysis( export function useKeywordSerpAnalysis(
projectId: string, projectId: string,
locationCode: number, locationCode: number | undefined,
) { ) {
const [serpKeyword, setSerpKeyword] = useState<string | null>(null); const [serpKeyword, setSerpKeyword] = useState<string | null>(null);
const [serpPage, setSerpPage] = useState(0); const [serpPage, setSerpPage] = useState(0);
@ -20,7 +19,6 @@ export function useKeywordSerpAnalysis(
projectId, projectId,
keyword: serpKeyword!, keyword: serpKeyword!,
locationCode, locationCode,
languageCode: getLanguageCode(locationCode),
}, },
}), }),
enabled: !!serpKeyword, enabled: !!serpKeyword,
@ -29,7 +27,7 @@ export function useKeywordSerpAnalysis(
const serpResults = serpQuery.data?.items ?? []; const serpResults = serpQuery.data?.items ?? [];
const activeSerpKeyword = const activeSerpKeyword =
serpKeyword ?? serpQuery.data?.requestedKeyword ?? null; serpKeyword ?? serpQuery.data?.requestedKeyword ?? null;
const serpLoading = serpQuery.isLoading; const serpLoading = !!serpKeyword && serpQuery.isLoading;
const serpError = serpQuery.isError const serpError = serpQuery.isError
? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.") ? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.")
: null; : null;

View File

@ -5,12 +5,15 @@ import {
isSupportedLocationCode, isSupportedLocationCode,
} from "@/client/features/keywords/locations"; } from "@/client/features/keywords/locations";
const STORAGE_KEY = "keyword-preferred-location"; // Scoped per project: a location picked while working on one project must not
// shadow another project's own default market.
const storageKey = (projectId: string) =>
`keyword-preferred-location:${projectId}`;
const locationCodeSchema = z.number().int().positive(); const locationCodeSchema = z.number().int().positive();
function loadPreferredLocationCode() { function loadPreferredLocationCode(projectId: string) {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(storageKey(projectId));
if (!raw) return null; if (!raw) return null;
const parsed = locationCodeSchema.parse(JSON.parse(raw)); const parsed = locationCodeSchema.parse(JSON.parse(raw));
@ -20,31 +23,49 @@ function loadPreferredLocationCode() {
} }
} }
function savePreferredLocationCode(locationCode: number) { function savePreferredLocationCode(projectId: string, locationCode: number) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(locationCode)); localStorage.setItem(storageKey(projectId), JSON.stringify(locationCode));
} catch { } catch {
// storage full or unavailable - silently ignore // storage full or unavailable - silently ignore
} }
} }
export function usePreferredKeywordLocation() { /**
const [preferredLocationCode, setPreferredLocationCodeState] = useState( * Preference order: the user's explicit choice for this project (persisted per
DEFAULT_LOCATION_CODE, * browser) > the project's default market (may arrive async from the projects
); * query) > the US fallback.
*/
export function usePreferredKeywordLocation(
projectId: string,
projectDefaultLocationCode?: number,
) {
const [preference, setPreference] = useState(() => ({
projectId,
locationCode: loadPreferredLocationCode(projectId),
}));
const chosenLocationCode =
preference.projectId === projectId
? preference.locationCode
: loadPreferredLocationCode(projectId);
useEffect(() => { useEffect(() => {
const savedLocationCode = loadPreferredLocationCode(); if (preference.projectId === projectId) return;
if (savedLocationCode != null) { setPreference({ projectId, locationCode: chosenLocationCode });
setPreferredLocationCodeState(savedLocationCode); }, [chosenLocationCode, preference.projectId, projectId]);
}
}, []); const preferredLocationCode =
chosenLocationCode ?? projectDefaultLocationCode ?? DEFAULT_LOCATION_CODE;
function setPreferredLocationCode(locationCode: number) { function setPreferredLocationCode(locationCode: number) {
if (!isSupportedLocationCode(locationCode)) return; if (!isSupportedLocationCode(locationCode)) return;
setPreferredLocationCodeState(locationCode); setPreference({ projectId, locationCode });
savePreferredLocationCode(locationCode); savePreferredLocationCode(projectId, locationCode);
} }
return { preferredLocationCode, setPreferredLocationCode }; return {
preferredLocationCode,
selectedLocationCode: chosenLocationCode ?? undefined,
setPreferredLocationCode,
};
} }

View File

@ -1,6 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Clock, Globe, History, Search, X } from "lucide-react"; import { Clock, Globe, History, Search, X } from "lucide-react";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { LOCATIONS } from "@/client/features/keywords/utils"; import { LOCATIONS } from "@/client/features/keywords/utils";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
@ -89,10 +88,7 @@ function SearchHistoryState({
params={{ projectId }} params={{ projectId }}
search={{ search={{
q: item.keyword, q: item.keyword,
loc: loc: item.locationCode,
item.locationCode === DEFAULT_LOCATION_CODE
? undefined
: item.locationCode,
}} }}
replace replace
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200" className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"

View File

@ -7,21 +7,30 @@ import { useKeywordResearchController } from "@/client/features/keywords/state/u
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController"; import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm"; import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions"; import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
import { useKeywordSearchParams } from "@/client/features/keywords/state/keywordControllerInternals"; import {
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; useKeywordSearchParams,
useResolvedKeywordLocation,
} from "@/client/features/keywords/state/keywordControllerInternals";
import type { import type {
KeywordSearchTabInput, KeywordSearchTabInput,
SearchTab, SearchTab,
} from "@/client/features/search-tabs/types"; } from "@/client/features/search-tabs/types";
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip"; import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation"; import {
tabInputKey,
useSearchTabNavigation,
} from "@/client/features/search-tabs/useSearchTabNavigation";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState"; import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState"; import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
import { KeywordResearchResults } from "./KeywordResearchResults"; import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar"; import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">; type ControllerProps = Omit<KeywordResearchControllerInput, "onFormSubmit">;
type Props = Omit<
ControllerProps,
"locationCode" | "displayedLocationCode" | "setPreferredLocationCode"
> & { locationCode?: number };
type KeywordSearchTab = SearchTab & { input: KeywordSearchTabInput }; type KeywordSearchTab = SearchTab & { input: KeywordSearchTabInput };
function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab { function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab {
@ -31,6 +40,11 @@ function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab {
export function KeywordResearchPage(input: Props) { export function KeywordResearchPage(input: Props) {
const setSearchParams = useKeywordSearchParams(); const setSearchParams = useKeywordSearchParams();
const projectId = input.projectId; const projectId = input.projectId;
const { locationCode, displayedLocationCode, setPreferredLocationCode } =
useResolvedKeywordLocation({
projectId,
locationCode: input.locationCode,
});
const navigateToKeywordInput = useCallback( const navigateToKeywordInput = useCallback(
(tabInput: KeywordSearchTabInput | null) => { (tabInput: KeywordSearchTabInput | null) => {
@ -47,10 +61,7 @@ export function KeywordResearchPage(input: Props) {
setSearchParams({ setSearchParams({
q: tabInput.keyword, q: tabInput.keyword,
loc: loc: tabInput.locationCode,
tabInput.locationCode === DEFAULT_LOCATION_CODE
? undefined
: tabInput.locationCode,
kLimit: tabInput.resultLimit === 150 ? undefined : tabInput.resultLimit, kLimit: tabInput.resultLimit === 150 ? undefined : tabInput.resultLimit,
mode: tabInput.mode === "auto" ? undefined : tabInput.mode, mode: tabInput.mode === "auto" ? undefined : tabInput.mode,
cs: tabInput.clickstream ? true : undefined, cs: tabInput.clickstream ? true : undefined,
@ -66,7 +77,7 @@ export function KeywordResearchPage(input: Props) {
return { return {
type: "keyword", type: "keyword",
keyword, keyword,
locationCode: input.locationCode, locationCode,
resultLimit: input.resultLimit, resultLimit: input.resultLimit,
mode: input.keywordMode, mode: input.keywordMode,
clickstream: input.clickstream, clickstream: input.clickstream,
@ -75,7 +86,7 @@ export function KeywordResearchPage(input: Props) {
input.clickstream, input.clickstream,
input.keywordInput, input.keywordInput,
input.keywordMode, input.keywordMode,
input.locationCode, locationCode,
input.resultLimit, input.resultLimit,
]); ]);
const searchTabs = useSearchTabNavigation({ const searchTabs = useSearchTabNavigation({
@ -98,7 +109,13 @@ export function KeywordResearchPage(input: Props) {
const tab = searchTabs.tabs.find( const tab = searchTabs.tabs.find(
(candidate) => candidate.id === searchTabs.activeTabId, (candidate) => candidate.id === searchTabs.activeTabId,
); );
return tab && isKeywordSearchTab(tab) ? tab : null; // activeTabId syncs in an effect, so it trails urlInput by a render; the
// stale tab must not drive a paid query for a market the URL no longer names.
return tab &&
isKeywordSearchTab(tab) &&
tabInputKey(tab.input) === tabInputKey(urlInput)
? tab
: null;
}, [searchTabs.activeTabId, searchTabs.tabs, urlInput]); }, [searchTabs.activeTabId, searchTabs.tabs, urlInput]);
const onFormSubmit = useCallback( const onFormSubmit = useCallback(
@ -148,14 +165,16 @@ export function KeywordResearchPage(input: Props) {
[searchTabs.tabs], [searchTabs.tabs],
); );
const controllerInput = useMemo<Props>( const controllerInput = useMemo<ControllerProps>(
() => () =>
activeTab activeTab
? { ? {
...input, ...input,
keywordInput: activeTab.input.keyword, keywordInput: activeTab.input.keyword,
locationCode: activeTab.input.locationCode, locationCode: activeTab.input.locationCode,
hasExplicitLocationCode: true, displayedLocationCode:
activeTab.input.locationCode ?? displayedLocationCode,
setPreferredLocationCode,
resultLimit: activeTab.input.resultLimit, resultLimit: activeTab.input.resultLimit,
keywordMode: activeTab.input.mode, keywordMode: activeTab.input.mode,
clickstream: activeTab.input.clickstream, clickstream: activeTab.input.clickstream,
@ -164,10 +183,21 @@ export function KeywordResearchPage(input: Props) {
} }
: { : {
...input, ...input,
locationCode,
displayedLocationCode,
setPreferredLocationCode,
getOpenKeywordTabs, getOpenKeywordTabs,
keywordTabsLimit: searchTabs.limit, keywordTabsLimit: searchTabs.limit,
}, },
[activeTab, getOpenKeywordTabs, input, searchTabs.limit], [
activeTab,
getOpenKeywordTabs,
input,
displayedLocationCode,
locationCode,
searchTabs.limit,
setPreferredLocationCode,
],
); );
const controller = useKeywordResearchController({ const controller = useKeywordResearchController({
...controllerInput, ...controllerInput,

View File

@ -3,7 +3,6 @@ import { toast } from "sonner";
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv"; import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { getLanguageCode } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { SaveKeywordsInput } from "@/types/schemas/keywords"; import type { SaveKeywordsInput } from "@/types/schemas/keywords";
import type { SortDir, SortField } from "@/client/features/keywords/components"; import type { SortDir, SortField } from "@/client/features/keywords/components";
@ -62,7 +61,7 @@ export function parseKeywordInput(value: string) {
*/ */
export function buildKeywordSearchKey(params: { export function buildKeywordSearchKey(params: {
keyword: string; keyword: string;
locationCode: number; locationCode: number | undefined;
resultLimit: ResultLimit; resultLimit: ResultLimit;
mode: KeywordMode; mode: KeywordMode;
clickstream: boolean; clickstream: boolean;
@ -127,7 +126,6 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
projectId: input.projectId, projectId: input.projectId,
keywords: [...selectedRows], keywords: [...selectedRows],
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: getLanguageCode(input.locationCode),
metrics, metrics,
}, },
{ {

View File

@ -2,22 +2,25 @@ import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation"; import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation";
import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
import { saveKeywords } from "@/serverFunctions/keywords"; import { saveKeywords } from "@/serverFunctions/keywords";
import type { SaveKeywordsInput } from "@/types/schemas/keywords"; import type { SaveKeywordsInput } from "@/types/schemas/keywords";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
export function useResolvedKeywordLocation( export function useResolvedKeywordLocation(input: {
input: KeywordResearchControllerInput, projectId: string;
) { locationCode?: number;
const { preferredLocationCode, setPreferredLocationCode } = }) {
usePreferredKeywordLocation(); const projectMarket = useProjectMarket(input.projectId);
const locationCode = const {
!input.hasExplicitLocationCode && input.keywordInput === "" preferredLocationCode,
? preferredLocationCode selectedLocationCode,
: input.locationCode; setPreferredLocationCode,
} = usePreferredKeywordLocation(input.projectId, projectMarket?.locationCode);
const locationCode = input.locationCode ?? selectedLocationCode;
const displayedLocationCode = input.locationCode ?? preferredLocationCode;
return { locationCode, setPreferredLocationCode }; return { locationCode, displayedLocationCode, setPreferredLocationCode };
} }
export function useKeywordUiState(initialShowFilters: boolean) { export function useKeywordUiState(initialShowFilters: boolean) {

View File

@ -25,13 +25,12 @@ import {
useKeywordSaveMutation, useKeywordSaveMutation,
useKeywordSearchParams, useKeywordSearchParams,
useKeywordUiState, useKeywordUiState,
useResolvedKeywordLocation,
} from "./keywordControllerInternals"; } from "./keywordControllerInternals";
import { useKeywordOverviewState } from "./useKeywordOverviewState"; import { useKeywordOverviewState } from "./useKeywordOverviewState";
type OpenKeywordTabInput = { type OpenKeywordTabInput = {
keyword: string; keyword: string;
locationCode: number; locationCode: number | undefined;
resultLimit: ResultLimit; resultLimit: ResultLimit;
mode: KeywordMode; mode: KeywordMode;
clickstream: boolean; clickstream: boolean;
@ -40,8 +39,9 @@ type OpenKeywordTabInput = {
export type KeywordResearchControllerInput = { export type KeywordResearchControllerInput = {
projectId: string; projectId: string;
keywordInput: string; keywordInput: string;
locationCode: number; locationCode: number | undefined;
hasExplicitLocationCode: boolean; displayedLocationCode: number;
setPreferredLocationCode: (locationCode: number) => void;
resultLimit: ResultLimit; resultLimit: ResultLimit;
keywordMode: KeywordMode; keywordMode: KeywordMode;
clickstream: boolean; clickstream: boolean;
@ -60,8 +60,8 @@ export type KeywordResearchControllerInput = {
export function useKeywordResearchController( export function useKeywordResearchController(
input: KeywordResearchControllerInput, input: KeywordResearchControllerInput,
) { ) {
const { locationCode, setPreferredLocationCode } = const { displayedLocationCode, locationCode, setPreferredLocationCode } =
useResolvedKeywordLocation(input); input;
const { const {
filtersForm, filtersForm,
values: filterValues, values: filterValues,
@ -115,6 +115,7 @@ export function useKeywordResearchController(
projectId: input.projectId, projectId: input.projectId,
keywordInput: input.keywordInput, keywordInput: input.keywordInput,
locationCode, locationCode,
displayedLocationCode,
resultLimit: input.resultLimit, resultLimit: input.resultLimit,
mode: input.keywordMode, mode: input.keywordMode,
clickstream: input.clickstream, clickstream: input.clickstream,
@ -148,7 +149,7 @@ export function useKeywordResearchController(
const controlsForm = useKeywordControlsForm( const controlsForm = useKeywordControlsForm(
{ {
...input, ...input,
locationCode, locationCode: displayedLocationCode,
getOpenKeywordTabs: input.getOpenKeywordTabs, getOpenKeywordTabs: input.getOpenKeywordTabs,
keywordTabsLimit: input.keywordTabsLimit, keywordTabsLimit: input.keywordTabsLimit,
}, },

View File

@ -1,4 +1,4 @@
export { LOCATIONS, getLanguageCode } from "./locations"; export { LOCATIONS } from "./locations";
export function scoreTierClass(value: number | null): string { export function scoreTierClass(value: number | null): string {
if (value == null) return "score-tier-na"; if (value == null) return "score-tier-na";

View File

@ -11,12 +11,14 @@ import {
import { startGscLink } from "@/client/features/gsc/startGscLink"; import { startGscLink } from "@/client/features/gsc/startGscLink";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
import type { ProjectMarket } from "@/client/features/projects/types";
import { import {
getGscConnection, getGscConnection,
listGscSites, listGscSites,
setGscSite, setGscSite,
} from "@/serverFunctions/gsc"; } from "@/serverFunctions/gsc";
import { getProjects } from "@/serverFunctions/projects"; import { getProjects, setProjectMarket } from "@/serverFunctions/projects";
const GRANT_STATUS_KEY = ["gscGrantStatus"]; const GRANT_STATUS_KEY = ["gscGrantStatus"];
@ -31,21 +33,68 @@ export function SearchConsoleOnboardingStep() {
queryKey: ["projects"], queryKey: ["projects"],
queryFn: () => getProjects(), queryFn: () => getProjects(),
}); });
const projectId = projectsQuery.data?.[0]?.id; const project = projectsQuery.data?.[0];
return ( return (
<div className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
Connect with Google Search Console now? Connect with Google Search Console now?
</h2> </h2>
{projectId ? <GscConnect projectId={projectId} /> : <Checking />} {project ? <GscConnect projectId={project.id} /> : <Checking />}
<p className="text-xs leading-relaxed text-base-content/55"> <p className="text-xs leading-relaxed text-base-content/55">
For now, Search Console data flows through the OpenSEO MCP. We're For now, Search Console data flows through the OpenSEO MCP. We're
building it into the OpenSEO app soon too. building it into the OpenSEO app soon too.
</p> </p>
</div> </div>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Choose country &amp; language</h2>
{project ? <DefaultMarketPicker project={project} /> : <Checking />}
</div>
</div>
);
}
/**
* Sets the project's default market during onboarding, so keyword, SERP, and
* domain data lands on the user's market from their first search instead of
* defaulting to the US. Saves on change the step's Continue button belongs
* to the wizard, so a separate Save here would be easy to walk past.
*/
function DefaultMarketPicker({
project,
}: {
project: { id: string; locationCode: number; languageCode: string };
}) {
const queryClient = useQueryClient();
const [market, setMarket] = React.useState<ProjectMarket>({
locationCode: project.locationCode,
languageCode: project.languageCode,
});
const saveMutation = useMutation({
mutationFn: (next: ProjectMarket) =>
setProjectMarket({ data: { projectId: project.id, ...next } }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["projects"] }),
onError: (error) => toast.error(getStandardErrorMessage(error)),
});
const handleChange = (next: ProjectMarket) => {
setMarket(next);
saveMutation.mutate(next);
};
return (
<div className="space-y-2">
<ProjectMarketFields value={market} onChange={handleChange} />
<p className="text-xs leading-relaxed text-base-content/55">
We'll use this country and language for keyword, SERP, and domain data
unless you pick a different one. You can change it in project settings.
</p>
</div>
); );
} }

View File

@ -5,6 +5,11 @@ import { toast } from "sonner";
import { Modal } from "@/client/components/Modal"; import { Modal } from "@/client/components/Modal";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { setLastProjectId } from "@/client/lib/active-project"; import { setLastProjectId } from "@/client/lib/active-project";
import {
DEFAULT_LOCATION_CODE,
getLanguageCode,
} from "@/client/features/keywords/locations";
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
import { createProject } from "@/serverFunctions/projects"; import { createProject } from "@/serverFunctions/projects";
export function CreateProjectModal({ onClose }: { onClose: () => void }) { export function CreateProjectModal({ onClose }: { onClose: () => void }) {
@ -12,11 +17,19 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [name, setName] = React.useState(""); const [name, setName] = React.useState("");
const [domain, setDomain] = React.useState(""); const [domain, setDomain] = React.useState("");
const [market, setMarket] = React.useState({
locationCode: DEFAULT_LOCATION_CODE,
languageCode: getLanguageCode(DEFAULT_LOCATION_CODE),
});
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => mutationFn: () =>
createProject({ createProject({
data: { name: name.trim(), domain: domain.trim() || undefined }, data: {
name: name.trim(),
domain: domain.trim() || undefined,
...market,
},
}), }),
onSuccess: async (created) => { onSuccess: async (created) => {
setLastProjectId(created.id); setLastProjectId(created.id);
@ -88,6 +101,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</span> </span>
</label> </label>
<div className="flex flex-col gap-1.5">
<ProjectMarketFields value={market} onChange={setMarket} />
<span className="text-xs text-base-content/50">
Keyword, SERP, and domain data uses this country and language unless
a call asks for a different one. Change it later in project
settings.
</span>
</div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="button" type="button"

View File

@ -0,0 +1,58 @@
import { LocationSelect } from "@/client/components/LocationSelect";
import {
getLanguageCode,
getLanguageOptions,
} from "@/client/features/keywords/locations";
import type { ProjectMarket } from "@/client/features/projects/types";
/**
* The project's default market: country plus the language served for it.
* Shared by project settings and onboarding so the pair and the rule that
* changing the country snaps the language to that country's native one
* stays identical in both places.
*/
export function ProjectMarketFields({
value,
onChange,
}: {
value: ProjectMarket;
onChange: (market: ProjectMarket) => void;
}) {
const languageOptions = getLanguageOptions(value.locationCode);
return (
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Country</span>
<LocationSelect
value={value.locationCode}
onChange={(locationCode) =>
onChange({
locationCode,
languageCode: getLanguageCode(locationCode),
})
}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Language</span>
<select
value={value.languageCode}
onChange={(event) =>
onChange({ ...value, languageCode: event.target.value })
}
// Most countries have exactly one language DataForSEO serves, so the
// select is only a real choice where there's more than one.
disabled={languageOptions.length <= 1}
className="select select-bordered w-full"
>
{languageOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</label>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard"; import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { import {
clearLastProjectId, clearLastProjectId,
@ -69,6 +70,10 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [name, setName] = React.useState(project.name); const [name, setName] = React.useState(project.name);
const [domain, setDomain] = React.useState(project.domain ?? ""); const [domain, setDomain] = React.useState(project.domain ?? "");
const [market, setMarket] = React.useState({
locationCode: project.locationCode,
languageCode: project.languageCode,
});
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: () => mutationFn: () =>
@ -77,6 +82,7 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
projectId: project.id, projectId: project.id,
name: name.trim(), name: name.trim(),
domain: domain.trim() || undefined, domain: domain.trim() || undefined,
...market,
}, },
}), }),
onSuccess: async () => { onSuccess: async () => {
@ -89,7 +95,9 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
const isDirty = const isDirty =
name.trim() !== project.name || name.trim() !== project.name ||
(domain.trim() || "") !== (project.domain ?? ""); (domain.trim() || "") !== (project.domain ?? "") ||
market.locationCode !== project.locationCode ||
market.languageCode !== project.languageCode;
const handleSubmit = (event: React.FormEvent) => { const handleSubmit = (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
@ -130,6 +138,14 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
/> />
</label> </label>
<div className="flex flex-col gap-1.5">
<ProjectMarketFields value={market} onChange={setMarket} />
<span className="text-xs text-base-content/50">
Keyword, SERP, and domain data uses this country and language unless
a call asks for a different one.
</span>
</div>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="submit" type="submit"

View File

@ -1,7 +1,14 @@
// The project's default market: the country/language pair its data calls
// fall back to. Mirrors resolveMarket's project argument in shared/.
export type ProjectMarket = { locationCode: number; languageCode: string };
// Shape returned by the getProjects server function (a mapped project row). // Shape returned by the getProjects server function (a mapped project row).
export type ProjectSummary = { export type ProjectSummary = {
id: string; id: string;
name: string; name: string;
domain: string | null; domain: string | null;
// Default market for the project's data calls.
locationCode: number;
languageCode: string;
createdAt: string; createdAt: string;
}; };

View File

@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getProjects } from "@/serverFunctions/projects";
import type { ProjectMarket } from "./types";
/** The project's default market, or undefined until the projects query resolves. */
export function useProjectMarket(projectId: string): ProjectMarket | undefined {
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
});
return projectsQuery.data?.find((project) => project.id === projectId);
}

View File

@ -140,7 +140,6 @@ export function KeywordSuggestionStep({
projectId, projectId,
domain, domain,
locationCode, locationCode,
languageCode,
onDone, onDone,
onClose, onClose,
}: Props) { }: Props) {
@ -163,16 +162,10 @@ export function KeywordSuggestionStep({
// Ads keyword data (e.g. Iceland) have no ranking data to suggest from. // Ads keyword data (e.g. Iceland) have no ranking data to suggest from.
const labsSupported = isLabsLocationCode(locationCode); const labsSupported = isLabsLocationCode(locationCode);
const suggestionsQuery = useQuery({ const suggestionsQuery = useQuery({
queryKey: [ queryKey: ["domainKeywordSuggestions", projectId, domain, locationCode],
"domainKeywordSuggestions",
projectId,
domain,
locationCode,
languageCode,
],
queryFn: () => queryFn: () =>
getDomainKeywordSuggestions({ getDomainKeywordSuggestions({
data: { projectId, domain, locationCode, languageCode }, data: { projectId, domain, locationCode },
}), }),
enabled: labsSupported, enabled: labsSupported,
}); });

View File

@ -10,12 +10,13 @@ import {
estimateRankCheckCredits, estimateRankCheckCredits,
} from "@/shared/rank-tracking"; } from "@/shared/rank-tracking";
import { import {
DEFAULT_LOCATION_CODE,
getLanguageCode, getLanguageCode,
getLanguageOptions, getLanguageOptions,
} from "@/client/features/keywords/locations"; } from "@/client/features/keywords/locations";
import { getIsoCountryCode } from "@/shared/keyword-locations"; import { getIsoCountryCode } from "@/shared/keyword-locations";
import { LocationSelect } from "@/client/components/LocationSelect"; import { LocationSelect } from "@/client/components/LocationSelect";
import type { ProjectMarket } from "@/client/features/projects/types";
import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
import { SearchTargetingField } from "./SearchTargetingField"; import { SearchTargetingField } from "./SearchTargetingField";
import { KeywordSuggestionStep } from "./KeywordSuggestionStep"; import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
import { useSaveConfigMutations } from "./useSaveConfigMutations"; import { useSaveConfigMutations } from "./useSaveConfigMutations";
@ -35,6 +36,45 @@ export function RankTrackingConfigModal({
onSaved, onSaved,
onConfigCreated, onConfigCreated,
}: Props) { }: Props) {
const projectMarket = useProjectMarket(projectId);
if (!existingConfig && !projectMarket) {
return (
<Modal
maxWidth="max-w-lg"
onClose={onClose}
labelledBy="rank-config-modal-title"
>
<h2 id="rank-config-modal-title" className="sr-only">
Add Domain
</h2>
<div className="flex min-h-40 items-center justify-center">
<Loader2 className="size-5 animate-spin text-base-content/50" />
</div>
</Modal>
);
}
return (
<RankTrackingConfigModalContent
projectId={projectId}
existingConfig={existingConfig}
initialMarket={existingConfig ?? projectMarket!}
onClose={onClose}
onSaved={onSaved}
onConfigCreated={onConfigCreated}
/>
);
}
function RankTrackingConfigModalContent({
projectId,
existingConfig,
initialMarket,
onClose,
onSaved,
onConfigCreated,
}: Props & { initialMarket: ProjectMarket }) {
const isEdit = !!existingConfig; const isEdit = !!existingConfig;
const [step, setStep] = useState<"config" | "keywords">("config"); const [step, setStep] = useState<"config" | "keywords">("config");
const [domain, setDomain] = useState(existingConfig?.domain ?? ""); const [domain, setDomain] = useState(existingConfig?.domain ?? "");
@ -42,11 +82,10 @@ export function RankTrackingConfigModal({
existingConfig?.devices ?? "mobile", existingConfig?.devices ?? "mobile",
); );
const [locationCode, setLocationCode] = useState( const [locationCode, setLocationCode] = useState(
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE, existingConfig?.locationCode ?? initialMarket.locationCode,
); );
const [languageCode, setLanguageCode] = useState( const [languageCode, setLanguageCode] = useState(
existingConfig?.languageCode ?? existingConfig?.languageCode ?? initialMarket.languageCode,
getLanguageCode(existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE),
); );
const languageOptions = useMemo( const languageOptions = useMemo(
() => getLanguageOptions(locationCode), () => getLanguageOptions(locationCode),

View File

@ -9,7 +9,6 @@ import {
buildKeywordResearchRequest, buildKeywordResearchRequest,
keywordResearchQueryFn, keywordResearchQueryFn,
} from "@/client/features/keywords/hooks/useKeywordResearchData"; } from "@/client/features/keywords/hooks/useKeywordResearchData";
import { getLanguageCode } from "@/client/features/keywords/locations";
import { getBacklinksOverview } from "@/serverFunctions/backlinks"; import { getBacklinksOverview } from "@/serverFunctions/backlinks";
import { getDomainOverview } from "@/serverFunctions/domain"; import { getDomainOverview } from "@/serverFunctions/domain";
export type { SearchTab } from "./types"; export type { SearchTab } from "./types";
@ -187,7 +186,6 @@ function getSearchTabQueryConfig(
if (tab.input.type === "domain") { if (tab.input.type === "domain") {
const input = tab.input; const input = tab.input;
const trimmedDomain = input.domain.trim(); const trimmedDomain = input.domain.trim();
const languageCode = getLanguageCode(input.locationCode);
return { return {
queryKey: [ queryKey: [
@ -196,7 +194,6 @@ function getSearchTabQueryConfig(
trimmedDomain, trimmedDomain,
input.subdomains, input.subdomains,
input.locationCode, input.locationCode,
languageCode,
], ],
queryFn: () => queryFn: () =>
getDomainOverview({ getDomainOverview({
@ -205,7 +202,6 @@ function getSearchTabQueryConfig(
domain: trimmedDomain, domain: trimmedDomain,
includeSubdomains: input.subdomains, includeSubdomains: input.subdomains,
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode,
}, },
}), }),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,

View File

@ -14,13 +14,13 @@ export type DomainSearchTabInput = {
type: "domain"; type: "domain";
domain: string; domain: string;
subdomains: boolean; subdomains: boolean;
locationCode: number; locationCode?: number;
}; };
export type KeywordSearchTabInput = { export type KeywordSearchTabInput = {
type: "keyword"; type: "keyword";
keyword: string; keyword: string;
locationCode: number; locationCode?: number;
resultLimit: ResultLimit; resultLimit: ResultLimit;
mode: KeywordMode; mode: KeywordMode;
clickstream: boolean; clickstream: boolean;

View File

@ -9,7 +9,7 @@ type UseSearchTabNavigationArgs = {
navigateToInput: (input: SearchTabInput | null) => void; navigateToInput: (input: SearchTabInput | null) => void;
}; };
function tabInputKey(input: SearchTabInput | null) { export function tabInputKey(input: SearchTabInput | null) {
return input ? JSON.stringify(input) : ""; return input ? JSON.stringify(input) : "";
} }

View File

@ -8,8 +8,8 @@ import {
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE, DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
domainSearchSchema, domainSearchSchema,
} from "@/types/schemas/domain"; } from "@/types/schemas/domain";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { getDomainRouteState } from "@/client/features/domain/domainRouteState"; import { getDomainRouteState } from "@/client/features/domain/domainRouteState";
import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
const DEFAULT_DOMAIN_SEARCH = { const DEFAULT_DOMAIN_SEARCH = {
domain: "", domain: "",
@ -17,7 +17,6 @@ const DEFAULT_DOMAIN_SEARCH = {
sort: "traffic", sort: "traffic",
order: undefined, order: undefined,
tab: "keywords", tab: "keywords",
loc: DEFAULT_LOCATION_CODE,
page: 1, page: 1,
size: DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE, size: DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
include: "", include: "",
@ -52,7 +51,8 @@ function DomainOverviewRoute() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch(); const search = Route.useSearch();
const routeState = getDomainRouteState(search); const projectMarket = useProjectMarket(projectId);
const routeState = getDomainRouteState(search, projectMarket);
return ( return (
<DomainOverviewPage <DomainOverviewPage

View File

@ -1,5 +1,4 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage"; import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage";
import { import {
isResultLimit, isResultLimit,
@ -31,20 +30,17 @@ function KeywordResearchPageRoute() {
const search = Route.useSearch(); const search = Route.useSearch();
const { const {
q: keywordInput = "", q: keywordInput = "",
loc: rawLocationCode, loc: locationCode,
kLimit: resultLimit = 150, kLimit: resultLimit = 150,
mode: keywordMode = "auto", mode: keywordMode = "auto",
sort: sortField = "searchVolume", sort: sortField = "searchVolume",
order: sortDir = "desc", order: sortDir = "desc",
} = search; } = search;
const locationCode = rawLocationCode ?? DEFAULT_LOCATION_CODE;
return ( return (
<KeywordResearchPage <KeywordResearchPage
projectId={projectId} projectId={projectId}
keywordInput={keywordInput} keywordInput={keywordInput}
locationCode={locationCode} locationCode={locationCode}
hasExplicitLocationCode={search.loc != null}
resultLimit={isResultLimit(resultLimit) ? resultLimit : 150} resultLimit={isResultLimit(resultLimit) ? resultLimit : 150}
keywordMode={normalizeKeywordMode(keywordMode)} keywordMode={normalizeKeywordMode(keywordMode)}
clickstream={search.cs ?? false} clickstream={search.cs ?? false}

View File

@ -9,7 +9,7 @@ import {
} from "@/server/lib/r2-cache"; } from "@/server/lib/r2-cache";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository"; import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import type { ResearchKeywordsInput } from "@/types/schemas/keywords"; import type { ResolvedResearchKeywordsInput } from "@/types/schemas/keywords";
import { z } from "zod"; import { z } from "zod";
import { getKeywordDataProvider } from "@/shared/keyword-locations"; import { getKeywordDataProvider } from "@/shared/keyword-locations";
import { type EnrichedKeyword, normalizeKeyword } from "./helpers"; import { type EnrichedKeyword, normalizeKeyword } from "./helpers";
@ -93,7 +93,7 @@ const CACHE_VERSION = 3;
async function fetchRowsFromSource( async function fetchRowsFromSource(
source: KeywordSource, source: KeywordSource,
input: ResearchKeywordsInput, input: ResolvedResearchKeywordsInput,
seedKeyword: string, seedKeyword: string,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature, creditFeature?: CreditFeature,
@ -113,7 +113,7 @@ async function fetchRowsFromSource(
} }
async function fetchAutoRows( async function fetchAutoRows(
input: ResearchKeywordsInput, input: ResolvedResearchKeywordsInput,
seedKeyword: string, seedKeyword: string,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature, creditFeature?: CreditFeature,
@ -175,7 +175,7 @@ async function fetchAutoRows(
} }
async function fetchGoogleAdsRows( async function fetchGoogleAdsRows(
input: ResearchKeywordsInput, input: ResolvedResearchKeywordsInput,
seedKeyword: string, seedKeyword: string,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature, creditFeature?: CreditFeature,
@ -211,7 +211,7 @@ async function fetchGoogleAdsRows(
async function fetchManualRows( async function fetchManualRows(
mode: Exclude<KeywordMode, "auto">, mode: Exclude<KeywordMode, "auto">,
input: ResearchKeywordsInput, input: ResolvedResearchKeywordsInput,
seedKeyword: string, seedKeyword: string,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature, creditFeature?: CreditFeature,
@ -242,7 +242,7 @@ async function fetchManualRows(
} }
async function buildResearchCacheKey( async function buildResearchCacheKey(
input: ResearchKeywordsInput, input: ResolvedResearchKeywordsInput,
normalizedKeywords: string[], normalizedKeywords: string[],
mode: KeywordMode, mode: KeywordMode,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
@ -261,7 +261,10 @@ async function buildResearchCacheKey(
}); });
} }
function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) { function persistRows(
input: ResolvedResearchKeywordsInput,
rows: EnrichedKeyword[],
) {
void Promise.all( void Promise.all(
rows.map((row) => rows.map((row) =>
KeywordResearchRepository.upsertKeywordMetric({ KeywordResearchRepository.upsertKeywordMetric({
@ -283,7 +286,7 @@ function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
} }
export async function research( export async function research(
input: ResearchKeywordsInput, input: ResolvedResearchKeywordsInput,
billingCustomer: BillingCustomerContext, billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature, creditFeature?: CreditFeature,
): Promise<ResearchResult> { ): Promise<ResearchResult> {
@ -300,7 +303,7 @@ export async function research(
// Labs source modes and clickstream refinement don't exist for // Labs source modes and clickstream refinement don't exist for
// Google-Ads-served countries; collapse both so equivalent requests share // Google-Ads-served countries; collapse both so equivalent requests share
// one cache entry. // one cache entry.
const effectiveInput: ResearchKeywordsInput = const effectiveInput: ResolvedResearchKeywordsInput =
provider === "google_ads" provider === "google_ads"
? { ...input, mode: "auto", clickstream: false } ? { ...input, mode: "auto", clickstream: false }
: input; : input;

View File

@ -5,7 +5,7 @@ import type {
ExportSavedKeywordsInput, ExportSavedKeywordsInput,
GetSavedKeywordsInput, GetSavedKeywordsInput,
RemoveSavedKeywordsInput, RemoveSavedKeywordsInput,
SaveKeywordsInput, ResolvedSaveKeywordsInput,
UpdateSavedKeywordTagInput, UpdateSavedKeywordTagInput,
UpdateSavedKeywordTagsInput, UpdateSavedKeywordTagsInput,
} from "@/types/schemas/keywords"; } from "@/types/schemas/keywords";
@ -31,7 +31,7 @@ function parseMonthlySearches(payload: string | null): MonthlySearch[] {
return result.success ? result.data : []; return result.success ? result.data : [];
} }
export async function saveKeywords(input: SaveKeywordsInput) { export async function saveKeywords(input: ResolvedSaveKeywordsInput) {
const normalizedKeywords = [ const normalizedKeywords = [
...new Set( ...new Set(
input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0), input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0),

View File

@ -61,11 +61,13 @@ async function createProject(
organizationId: string, organizationId: string,
name: string, name: string,
domain?: string, domain?: string,
// Omitted keeps the column defaults.
market?: { locationCode: number; languageCode: string },
) { ) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const [row] = await db const [row] = await db
.insert(projects) .insert(projects)
.values({ id, organizationId, name, domain }) .values({ id, organizationId, name, domain, ...market })
.returning(); .returning();
return row; return row;
} }
@ -73,15 +75,48 @@ async function createProject(
async function updateProject( async function updateProject(
projectId: string, projectId: string,
organizationId: string, organizationId: string,
input: { name: string; domain?: string }, input: {
name: string;
domain?: string;
// Omitted leaves the market columns untouched.
market?: { locationCode: number; languageCode: string };
},
) { ) {
const [row] = await db const [row] = await db
.update(projects) .update(projects)
.set({ name: input.name, domain: input.domain ?? null }) .set({ name: input.name, domain: input.domain ?? null, ...input.market })
.where( .where(
and( and(
eq(projects.id, projectId), eq(projects.id, projectId),
eq(projects.organizationId, organizationId), eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
),
)
.returning();
if (!row) {
throw new AppError("NOT_FOUND");
}
return row;
}
// Writes only the market columns. Onboarding sets the project's market before
// the user has named the project or picked a domain, so it must not go through
// updateProject, whose `domain: input.domain ?? null` would clear the domain.
async function updateProjectMarket(
projectId: string,
organizationId: string,
market: { locationCode: number; languageCode: string },
) {
const [row] = await db
.update(projects)
.set(market)
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
), ),
) )
.returning(); .returning();
@ -162,6 +197,7 @@ export const ProjectRepository = {
getProjectById, getProjectById,
createProject, createProject,
updateProject, updateProject,
updateProjectMarket,
tryCreateDefaultProject, tryCreateDefaultProject,
archiveProject, archiveProject,
restoreProject, restoreProject,

View File

@ -6,6 +6,7 @@ import {
listProjects, listProjects,
listProjectsEnsuringOne, listProjectsEnsuringOne,
restoreProject, restoreProject,
setProjectMarket,
updateProject, updateProject,
} from "@/server/features/projects/services/projects"; } from "@/server/features/projects/services/projects";
@ -14,6 +15,7 @@ export const ProjectService = {
listProjectsEnsuringOne, listProjectsEnsuringOne,
createProject, createProject,
updateProject, updateProject,
setProjectMarket,
archiveProject, archiveProject,
restoreProject, restoreProject,
listArchivedProjects, listArchivedProjects,

View File

@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
archiveProject: vi.fn(), archiveProject: vi.fn(),
restoreProject: vi.fn(), restoreProject: vi.fn(),
countProjects: vi.fn(), countProjects: vi.fn(),
updateProjectMarket: vi.fn(),
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
listProjects: vi.fn(), listProjects: vi.fn(),
listArchivedProjects: vi.fn(), listArchivedProjects: vi.fn(),
@ -89,9 +90,40 @@ describe("project service", () => {
"org_1", "org_1",
"Acme", "Acme",
"acme.com", "acme.com",
undefined,
); );
}); });
it("derives the native language when only the location is given", async () => {
mocks.createProject.mockResolvedValue(namedProject);
const { createProject } = await import("./projects");
await createProject("org_1", {
name: "Acme",
domain: "acme.com",
locationCode: 2704,
});
expect(mocks.createProject).toHaveBeenCalledWith(
"org_1",
"Acme",
"acme.com",
{ locationCode: 2704, languageCode: "vi" },
);
});
it("rejects a language DataForSEO does not serve for the location", async () => {
const { createProject } = await import("./projects");
await expect(
createProject("org_1", {
name: "Acme",
locationCode: 2840,
languageCode: "vi",
}),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.createProject).not.toHaveBeenCalled();
});
it("maps the reserved Default conflict to a friendly CONFLICT", async () => { it("maps the reserved Default conflict to a friendly CONFLICT", async () => {
mocks.createProject.mockRejectedValue( mocks.createProject.mockRejectedValue(
new Error( new Error(
@ -107,6 +139,37 @@ describe("project service", () => {
}); });
describe("updateProject", () => { describe("updateProject", () => {
it("leaves the market columns untouched when neither half is given", async () => {
mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects");
await updateProject("org_1", { projectId: "project_acme", name: "Acme" });
expect(mocks.updateProject).toHaveBeenCalledWith(
"project_acme",
"org_1",
expect.objectContaining({ market: undefined }),
);
});
it("snaps the language on a location-only change without reading the stored row", async () => {
mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects");
await updateProject("org_1", {
projectId: "project_acme",
name: "Acme",
locationCode: 2276,
});
expect(mocks.getProjectForOrganization).not.toHaveBeenCalled();
expect(mocks.updateProject).toHaveBeenCalledWith(
"project_acme",
"org_1",
expect.objectContaining({
market: { locationCode: 2276, languageCode: "de" },
}),
);
});
it("returns the updated project", async () => { it("returns the updated project", async () => {
mocks.updateProject.mockResolvedValue(namedProject); mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects"); const { updateProject } = await import("./projects");
@ -145,6 +208,45 @@ describe("project service", () => {
}); });
}); });
describe("setProjectMarket", () => {
it("writes only the market columns, leaving name and domain untouched", async () => {
// Onboarding sets the market before the project is named or given a
// domain; going through updateProject would clear the domain.
mocks.updateProjectMarket.mockResolvedValue({
...namedProject,
locationCode: 2704,
languageCode: "vi",
});
const { setProjectMarket } = await import("./projects");
await setProjectMarket("org_1", {
projectId: "project_acme",
locationCode: 2704,
languageCode: "vi",
});
expect(mocks.updateProjectMarket).toHaveBeenCalledWith(
"project_acme",
"org_1",
{ locationCode: 2704, languageCode: "vi" },
);
expect(mocks.updateProject).not.toHaveBeenCalled();
});
it("rejects a language the location does not serve before any write", async () => {
const { setProjectMarket } = await import("./projects");
await expect(
setProjectMarket("org_1", {
projectId: "project_acme",
locationCode: 2840,
languageCode: "vi",
}),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.updateProjectMarket).not.toHaveBeenCalled();
});
});
describe("archiveProject", () => { describe("archiveProject", () => {
it("refuses to archive the org's only project", async () => { it("refuses to archive the org's only project", async () => {
mocks.countProjects.mockResolvedValue(1); mocks.countProjects.mockResolvedValue(1);

View File

@ -2,25 +2,50 @@ import type {
ArchiveProjectInput, ArchiveProjectInput,
CreateProjectInput, CreateProjectInput,
RestoreProjectInput, RestoreProjectInput,
SetProjectMarketInput,
UpdateProjectInput, UpdateProjectInput,
} from "@/types/schemas/projects"; } from "@/types/schemas/projects";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { assertLanguageForLocation } from "@/server/lib/market";
import { getLanguageCode } from "@/shared/keyword-locations";
function mapProject(project: { function mapProject(project: {
id: string; id: string;
name: string; name: string;
domain: string | null; domain: string | null;
locationCode: number;
languageCode: string;
createdAt: string; createdAt: string;
}) { }) {
return { return {
id: project.id, id: project.id,
name: project.name, name: project.name,
domain: project.domain, domain: project.domain,
// Default market for the project's data calls (MCP tools and the web UI
// fall back to these when a call omits locationCode/languageCode).
locationCode: project.locationCode,
languageCode: project.languageCode,
createdAt: project.createdAt, createdAt: project.createdAt,
}; };
} }
/**
* Resolves a market input into the columns to write. A location with no
* language snaps to that location's native language; the schemas forbid the
* reverse, so the pair is always resolvable without reading the stored row.
*/
function resolveMarketInput(input: {
locationCode?: number;
languageCode?: string;
}): { locationCode: number; languageCode: string } | undefined {
if (input.locationCode == null) return undefined;
const locationCode = input.locationCode;
const languageCode = input.languageCode ?? getLanguageCode(locationCode);
assertLanguageForLocation(locationCode, languageCode);
return { locationCode, languageCode };
}
// The projects table's only unique index guards the auto-created ("Default", // The projects table's only unique index guards the auto-created ("Default",
// null) singleton. A UNIQUE violation while writing exactly that name/domain // null) singleton. A UNIQUE violation while writing exactly that name/domain
// therefore means one already exists — gating on the input (not just the error // therefore means one already exists — gating on the input (not just the error
@ -67,6 +92,7 @@ export async function createProject(
organizationId, organizationId,
input.name, input.name,
input.domain, input.domain,
resolveMarketInput(input),
); );
return mapProject(row); return mapProject(row);
} catch (error) { } catch (error) {
@ -85,7 +111,11 @@ export async function updateProject(
const row = await ProjectRepository.updateProject( const row = await ProjectRepository.updateProject(
input.projectId, input.projectId,
organizationId, organizationId,
{ name: input.name, domain: input.domain }, {
name: input.name,
domain: input.domain,
market: resolveMarketInput(input),
},
); );
return mapProject(row); return mapProject(row);
} catch (error) { } catch (error) {
@ -96,6 +126,24 @@ export async function updateProject(
} }
} }
/**
* Sets a project's default market on its own, for surfaces that only ask for
* the market (onboarding). Writing just these two columns keeps the write from
* echoing a name/domain the caller never edited.
*/
export async function setProjectMarket(
organizationId: string,
input: SetProjectMarketInput,
) {
assertLanguageForLocation(input.locationCode, input.languageCode);
const row = await ProjectRepository.updateProjectMarket(
input.projectId,
organizationId,
{ locationCode: input.locationCode, languageCode: input.languageCode },
);
return mapProject(row);
}
export async function archiveProject( export async function archiveProject(
organizationId: string, organizationId: string,
input: ArchiveProjectInput, input: ArchiveProjectInput,

View File

@ -29,6 +29,7 @@ const archivedConfig = {
const baseInput = { const baseInput = {
projectId: "project_1", projectId: "project_1",
projectMarket: { locationCode: 2704, languageCode: "vi" },
domain: "acme.com", domain: "acme.com",
locationCode: 2840, locationCode: 2840,
languageCode: "es", languageCode: "es",
@ -154,4 +155,41 @@ describe("RankTrackingService.createConfig", () => {
}), }),
); );
}); });
it("uses the project's market when location and language are omitted", async () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
const { RankTrackingService } = await import("./RankTrackingService");
await RankTrackingService.createConfig({
projectId: "project_1",
projectMarket: { locationCode: 2704, languageCode: "vi" },
domain: "acme.com",
serpDepth: 40,
});
expect(mocks.createConfig).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2704, languageCode: "vi" }),
);
});
it("snaps the language when only location overrides the project market", async () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
const { RankTrackingService } = await import("./RankTrackingService");
await RankTrackingService.createConfig({
projectId: "project_1",
projectMarket: { locationCode: 2704, languageCode: "vi" },
domain: "acme.com",
locationCode: 2276,
serpDepth: 40,
});
expect(mocks.createConfig).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2276, languageCode: "de" }),
);
});
}); });

View File

@ -22,6 +22,7 @@ import {
MAX_KEYWORDS_PER_CONFIG, MAX_KEYWORDS_PER_CONFIG,
MAX_CONFIGS_PER_PROJECT, MAX_CONFIGS_PER_PROJECT,
} from "@/shared/rank-tracking"; } from "@/shared/rank-tracking";
import { resolveMarket } from "@/shared/keyword-locations";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Config // Config
@ -29,6 +30,7 @@ import {
async function createConfig(input: { async function createConfig(input: {
projectId: string; projectId: string;
projectMarket: { locationCode: number; languageCode: string };
domain: string; domain: string;
locationCode?: number; locationCode?: number;
languageCode?: string; languageCode?: string;
@ -39,7 +41,10 @@ async function createConfig(input: {
}) { }) {
const normalizedDomain = normalizeDomain(input.domain); const normalizedDomain = normalizeDomain(input.domain);
const locationCode = input.locationCode ?? 2840; const { locationCode, languageCode } = resolveMarket(
input,
input.projectMarket,
);
const scheduleInterval = input.scheduleInterval ?? "weekly"; const scheduleInterval = input.scheduleInterval ?? "weekly";
const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval) const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval)
? computeNextCheckAt(scheduleInterval) ? computeNextCheckAt(scheduleInterval)
@ -82,7 +87,7 @@ async function createConfig(input: {
if (existing) { if (existing) {
await RankTrackingRepository.updateConfig(existing.id, input.projectId, { await RankTrackingRepository.updateConfig(existing.id, input.projectId, {
isActive: true, isActive: true,
languageCode: input.languageCode ?? "en", languageCode,
devices: input.devices ?? "both", devices: input.devices ?? "both",
serpDepth: input.serpDepth, serpDepth: input.serpDepth,
scheduleInterval, scheduleInterval,
@ -102,7 +107,7 @@ async function createConfig(input: {
projectId: input.projectId, projectId: input.projectId,
domain: normalizedDomain, domain: normalizedDomain,
locationCode, locationCode,
languageCode: input.languageCode ?? "en", languageCode,
locationName, locationName,
devices: input.devices ?? "both", devices: input.devices ?? "both",
serpDepth: input.serpDepth, serpDepth: input.serpDepth,

43
src/server/lib/market.ts Normal file
View File

@ -0,0 +1,43 @@
import { AppError } from "@/server/lib/errors";
import {
DEFAULT_LOCATION_CODE,
getKeywordDataProvider,
getLanguageOptions,
isLanguageServedForLocation,
} from "@/shared/keyword-locations";
/**
* Guards Labs-backed tools (domain analytics) against locations we serve
* from Google Ads keyword data only.
*/
export function assertLabsLocationCode(locationCode: number | undefined) {
if (locationCode != null && getKeywordDataProvider(locationCode) !== "labs") {
throw new AppError(
"VALIDATION_ERROR",
"Domain analytics is not available for this country. Keyword research and rank tracking work; domain-level data is limited to DataForSEO Labs locations.",
);
}
}
/**
* Guards Labs-backed callers against a language DataForSEO doesn't serve for
* the chosen location. A mismatched pair (e.g. language_code="ru" for the
* United States) is otherwise rejected as an opaque *charged* "Invalid Field:
* 'language_code'." task failure, so validate the pair first (cost 0).
*/
export function assertLanguageForLocation(
locationCode: number | undefined,
languageCode: string | undefined,
) {
if (languageCode == null) return;
const resolvedLocation = locationCode ?? DEFAULT_LOCATION_CODE;
if (isLanguageServedForLocation(resolvedLocation, languageCode)) return;
throw new AppError(
"VALIDATION_ERROR",
`Language '${languageCode}' is not available for this location. Available: ${getLanguageOptions(
resolvedLocation,
)
.map((option) => option.code)
.join(", ")}.`,
);
}

View File

@ -46,6 +46,8 @@ describe("withMcpProjectAuth", () => {
mocks.getProjectForOrganization.mockResolvedValue({ mocks.getProjectForOrganization.mockResolvedValue({
id: "project_123", id: "project_123",
name: "Test", name: "Test",
locationCode: 2840,
languageCode: "en",
}); });
}); });
@ -64,7 +66,7 @@ describe("withMcpProjectAuth", () => {
); );
}); });
it("passes auth, baseUrl, and billing context to the wrapped handler", async () => { it("passes auth, baseUrl, billing, and project context to the wrapped handler", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth"); const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn().mockReturnValue("ok"); const handler = vi.fn().mockReturnValue("ok");
@ -90,6 +92,12 @@ describe("withMcpProjectAuth", () => {
organizationId: "org_123", organizationId: "org_123",
projectId: "project_123", projectId: "project_123",
}, },
project: {
id: "project_123",
name: "Test",
locationCode: 2840,
languageCode: "en",
},
}, },
); );
}); });

View File

@ -28,6 +28,9 @@ async function requireProjectAccess(extra: ToolExtra, projectId: string) {
auth, auth,
baseUrl, baseUrl,
billing: buildBillingCustomer(auth, projectId), billing: buildBillingCustomer(auth, projectId),
// The row is already fetched for the auth gate; exposing it lets tools
// fall back to the project's default market without another query.
project,
}; };
} }

View File

@ -1,13 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { AppError } from "@/server/lib/errors"; import { isSupportedLanguageCode } from "@/shared/keyword-locations";
import {
getKeywordDataProvider,
getLanguageOptions,
isSupportedLanguageCode,
} from "@/shared/keyword-locations";
export const DEFAULT_LOCATION_CODE = 2840; export const DEFAULT_LOCATION_CODE = 2840;
export const DEFAULT_LANGUAGE_CODE = "en";
export const projectIdSchema = z export const projectIdSchema = z
.string() .string()
@ -21,52 +15,15 @@ export const locationCodeSchema = z
.int() .int()
.positive() .positive()
.describe( .describe(
"DataForSEO location code. Defaults to 2840 (United States). See dataforseo.com/help-center/locations. Some countries (e.g. Iceland, 2352) are served from Google Ads data: keyword volume/CPC/trends work, but keyword difficulty, search intent, and domain analytics are unavailable.", "DataForSEO location code. Defaults to the project's default market (see list_projects; editable in project settings). See dataforseo.com/help-center/locations. Some countries (e.g. Iceland, 2352) are served from Google Ads data: keyword volume/CPC/trends work, but keyword difficulty, search intent, and domain analytics are unavailable.",
); );
/**
* Guards Labs-backed tools (domain analytics) against locations we serve
* from Google Ads keyword data only.
*/
export function assertLabsLocationCode(locationCode: number | undefined) {
if (locationCode != null && getKeywordDataProvider(locationCode) !== "labs") {
throw new AppError(
"VALIDATION_ERROR",
"Domain analytics is not available for this country. Keyword research and rank tracking work; domain-level data is limited to DataForSEO Labs locations.",
);
}
}
/**
* Guards Labs-backed tools against a language DataForSEO doesn't serve for the
* chosen location. A mismatched pair (e.g. language_code="ru" for the United
* States) is otherwise rejected as an opaque *charged* "Invalid Field:
* 'language_code'." task failure, so validate the pair first (cost 0). Only
* Labs locations have authoritative per-location language lists; Google Ads
* locations are left to the metering safety net.
*/
export function assertLanguageForLocation(
locationCode: number | undefined,
languageCode: string | undefined,
) {
if (languageCode == null) return;
const resolvedLocation = locationCode ?? DEFAULT_LOCATION_CODE;
if (getKeywordDataProvider(resolvedLocation) !== "labs") return;
const options = getLanguageOptions(resolvedLocation);
if (!options.some((option) => option.code === languageCode)) {
throw new AppError(
"VALIDATION_ERROR",
`Language '${languageCode}' is not available for this location. Available: ${options
.map((option) => option.code)
.join(", ")}.`,
);
}
}
export const languageCodeSchema = z export const languageCodeSchema = z
.string() .string()
.refine(isSupportedLanguageCode, { .refine(isSupportedLanguageCode, {
message: message:
"Unsupported language code. Use a supported code such as 'en', 'es', 'de', or 'fr'.", "Unsupported language code. Use a supported code such as 'en', 'es', 'de', or 'fr'.",
}) })
.describe("Language code (e.g. 'en', 'es', 'fr'). Defaults to 'en'."); .describe(
"Language code (e.g. 'en', 'es', 'vi'). Defaults to the project's default market language (see list_projects).",
);

View File

@ -62,7 +62,11 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => {
vi.resetModules(); vi.resetModules();
mocks.createDataforseoClient.mockReset(); mocks.createDataforseoClient.mockReset();
mocks.getProjectForOrganization.mockReset(); mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
}); });
it("serves Iceland from adsSearchVolume without KD/intent", async () => { it("serves Iceland from adsSearchVolume without KD/intent", async () => {

View File

@ -0,0 +1,114 @@
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";
// Market resolution for get_ranked_keywords: the explicit country selector and
// the project's default-market fallback (projects.locationCode/languageCode).
// find_serp_competitors resolves through the same resolveMarketSelector.
const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(),
getProjectForOrganization: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/server/lib/dataforseo", () => ({
createDataforseoClient: mocks.createDataforseoClient,
fetchKeywordMetricsForList: vi.fn(),
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
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,
};
function setProject(market: { locationCode: number; languageCode: string }) {
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
name: "Test",
domain: null,
createdAt: "2026-01-01T00:00:00.000Z",
...market,
});
}
async function runRankedKeywords(args: { market?: { country: "US" } }) {
const rankedKeywords = vi.fn().mockResolvedValue({
items: [],
totalCount: 0,
});
mocks.createDataforseoClient.mockReturnValue({
domain: { rankedKeywords },
});
const { getRankedKeywordsTool } = await import("./dataforseo-research-tools");
await getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "acmeexample.com", ...args },
toolExtra,
);
return rankedKeywords;
}
describe("market resolution for Labs tools", () => {
beforeEach(() => {
vi.resetModules();
mocks.createDataforseoClient.mockReset();
mocks.getProjectForOrganization.mockReset();
setProject({ locationCode: 2840, languageCode: "en" });
});
it("keeps the US when market is explicit even for a non-US project", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
const rankedKeywords = await runRankedKeywords({
market: { country: "US" },
});
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2840, languageCode: "en" }),
);
});
it("follows the project's default market when the market object is omitted", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
const rankedKeywords = await runRankedKeywords({});
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2704, languageCode: "vi" }),
);
});
it("falls back to the US when the project market is not Labs-served", async () => {
// Iceland (2352) is served from Google Ads data; the Labs-only market
// tools must not inherit it.
setProject({ locationCode: 2352, languageCode: "en" });
const rankedKeywords = await runRankedKeywords({});
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2840, languageCode: "en" }),
);
});
});

View File

@ -64,12 +64,18 @@ function textOf(result: {
return first?.type === "text" ? (first.text ?? "") : ""; return first?.type === "text" ? (first.text ?? "") : "";
} }
const usProjectRow = {
id: "project_1",
locationCode: 2840,
languageCode: "en",
};
describe("DataForSEO research MCP tools", () => { describe("DataForSEO research MCP tools", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
mocks.createDataforseoClient.mockReset(); mocks.createDataforseoClient.mockReset();
mocks.getProjectForOrganization.mockReset(); mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); mocks.getProjectForOrganization.mockResolvedValue(usProjectRow);
}); });
it("searches local businesses without running rankings or Q&A", async () => { it("searches local businesses without running rankings or Q&A", async () => {

View File

@ -17,10 +17,10 @@ import {
readPath, readPath,
type McpTableColumn, type McpTableColumn,
} from "@/server/mcp/table"; } from "@/server/mcp/table";
import { resolveLabsMarket, resolveMarket } from "@/shared/keyword-locations";
import { assertLanguageForLocation } from "@/server/lib/market";
import { import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE, DEFAULT_LOCATION_CODE,
assertLanguageForLocation,
languageCodeSchema, languageCodeSchema,
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
@ -46,10 +46,14 @@ const marketSchema = z
country: z country: z
.enum(["US", "USA", "United States", "United States of America"]) .enum(["US", "USA", "United States", "United States of America"])
.optional() .optional()
.describe("Country selector. Only the United States is supported."), .describe(
"Country selector. Only the United States can be selected explicitly.",
),
}) })
.optional() .optional()
.describe("Optional United States market object. Defaults to United States."); .describe(
"Optional market object. Omitted = the project's default market (United States unless the project overrides it).",
);
const nearSchema = z const nearSchema = z
.object({ .object({
@ -344,10 +348,20 @@ type GetGoogleBusinessQuestionsArgs = z.infer<
const QUESTIONS_ANSWERS_MIN_RADIUS = 200; const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999; const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
function resolveMarketLocationCode(_market: Market | undefined): number { /**
// The Zod enum on market.country already restricts values to United States * Resolves the market selector to a Labs location + language. An explicit
// variants, so no other country can reach this code path. * country wins; omitted inherits the project's default via resolveLabsMarket,
return DEFAULT_LOCATION_CODE; * which keeps these Labs-only tools off an Ads-served project market.
*/
function resolveMarketSelector(
market: Market | undefined,
project: { locationCode: number; languageCode: string },
): { locationCode: number; languageCode: string } {
if (market?.country != null) {
// The Zod enum already restricts explicit values to United States variants.
return { locationCode: DEFAULT_LOCATION_CODE, languageCode: "en" };
}
return resolveLabsMarket({}, project);
} }
function formatCoordinate(value: number): string { function formatCoordinate(value: number): string {
@ -594,10 +608,11 @@ export const getRankedKeywordsTool = {
handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => { handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => {
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const targetIsPage = /^https?:\/\//.test(args.target); const targetIsPage = /^https?:\/\//.test(args.target);
const market = resolveMarketSelector(args.market, context.project);
const keywords = await client.domain.rankedKeywords({ const keywords = await client.domain.rankedKeywords({
target: args.target, target: args.target,
locationCode: resolveMarketLocationCode(args.market), locationCode: market.locationCode,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: market.languageCode,
limit: args.limit ?? 50, limit: args.limit ?? 50,
offset: args.offset, offset: args.offset,
orderBy: sortOrderByRankedMode(args.sortBy), orderBy: sortOrderByRankedMode(args.sortBy),
@ -693,7 +708,7 @@ export const getLocalSerpResultsTool = {
const results = await client.serp.local({ const results = await client.serp.local({
keyword: args.keyword, keyword: args.keyword,
locationCoordinate: formatLocalSerpCoordinate(args.near), locationCoordinate: formatLocalSerpCoordinate(args.near),
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, languageCode: args.languageCode ?? context.project.languageCode,
searchType: args.searchType ?? "maps", searchType: args.searchType ?? "maps",
device: args.device ?? "desktop", device: args.device ?? "desktop",
depth: args.depth ?? 20, depth: args.depth ?? 20,
@ -736,7 +751,7 @@ export const getGoogleBusinessQuestionsTool = {
const questions = await client.business.questionsAnswers({ const questions = await client.business.questionsAnswers({
keyword: args.keyword, keyword: args.keyword,
locationCoordinate: formatQuestionsAnswersCoordinate(args.near), locationCoordinate: formatQuestionsAnswersCoordinate(args.near),
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, languageCode: args.languageCode ?? context.project.languageCode,
depth: args.depth ?? 20, depth: args.depth ?? 20,
}); });
@ -773,10 +788,11 @@ export const findSerpCompetitorsTool = {
handler: withMcpProjectAuth( handler: withMcpProjectAuth(
async (args: FindSerpCompetitorsArgs, context) => { async (args: FindSerpCompetitorsArgs, context) => {
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const market = resolveMarketSelector(args.market, context.project);
const competitors = await client.labs.serpCompetitors({ const competitors = await client.labs.serpCompetitors({
keywords: args.keywords, keywords: args.keywords,
locationCode: resolveMarketLocationCode(args.market), locationCode: market.locationCode,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: market.languageCode,
itemTypes: args.resultTypes ?? ["organic", "local_pack"], itemTypes: args.resultTypes ?? ["organic", "local_pack"],
includeSubdomains: args.includeSubdomains, includeSubdomains: args.includeSubdomains,
limit: args.limit ?? 50, limit: args.limit ?? 50,
@ -829,10 +845,11 @@ export const getKeywordMetricsTool = {
}, },
}, },
handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => { handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => {
assertLanguageForLocation(args.locationCode, args.languageCode); const { locationCode, languageCode } = resolveMarket(args, context.project);
// Assert against the RESOLVED pair: an explicit language with an omitted
// location must validate against the project's default location.
assertLanguageForLocation(locationCode, languageCode);
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
const metrics = await fetchKeywordMetricsForList(client, { const metrics = await fetchKeywordMetricsForList(client, {
keywords: args.keywords, keywords: args.keywords,
locationCode, locationCode,

View File

@ -7,16 +7,17 @@ import {
optionalMetaOutputSchema, optionalMetaOutputSchema,
} from "@/server/mcp/output-schemas"; } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveLabsMarket } from "@/shared/keyword-locations";
import { import {
formatMcpTable, formatMcpTable,
readPath, readPath,
type McpTableColumn, type McpTableColumn,
} from "@/server/mcp/table"; } from "@/server/mcp/table";
import { import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
assertLabsLocationCode, assertLabsLocationCode,
assertLanguageForLocation, assertLanguageForLocation,
} from "@/server/lib/market";
import {
languageCodeSchema, languageCodeSchema,
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
@ -59,13 +60,17 @@ export const getDomainKeywordSuggestionsTool = {
}, },
}, },
handler: withMcpProjectAuth(async (args: Args, context) => { handler: withMcpProjectAuth(async (args: Args, context) => {
assertLabsLocationCode(args.locationCode); const { locationCode, languageCode } = resolveLabsMarket(
assertLanguageForLocation(args.locationCode, args.languageCode); args,
context.project,
);
assertLabsLocationCode(locationCode);
assertLanguageForLocation(locationCode, languageCode);
const keywords = await DomainService.getSuggestedKeywords( const keywords = await DomainService.getSuggestedKeywords(
{ {
domain: args.domain, domain: args.domain,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, locationCode,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, languageCode,
organizationId: context.auth.organizationId, organizationId: context.auth.organizationId,
projectId: args.projectId, projectId: args.projectId,
}, },

View File

@ -4,11 +4,12 @@ import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context"; import { buildProjectMeta } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveLabsMarket } from "@/shared/keyword-locations";
import { import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
assertLabsLocationCode, assertLabsLocationCode,
assertLanguageForLocation, assertLanguageForLocation,
} from "@/server/lib/market";
import {
languageCodeSchema, languageCodeSchema,
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
@ -52,15 +53,19 @@ export const getDomainOverviewTool = {
}, },
}, },
handler: withMcpProjectAuth(async (args: Args, context) => { handler: withMcpProjectAuth(async (args: Args, context) => {
assertLabsLocationCode(args.locationCode); const { locationCode, languageCode } = resolveLabsMarket(
assertLanguageForLocation(args.locationCode, args.languageCode); args,
context.project,
);
assertLabsLocationCode(locationCode);
assertLanguageForLocation(locationCode, languageCode);
const result = await DomainService.getOverview( const result = await DomainService.getOverview(
{ {
projectId: args.projectId, projectId: args.projectId,
domain: args.domain, domain: args.domain,
includeSubdomains: args.includeSubdomains, includeSubdomains: args.includeSubdomains,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, locationCode,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, languageCode,
}, },
context.billing, context.billing,
); );

View File

@ -4,10 +4,9 @@ import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context"; import { buildProjectMeta } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveMarket } from "@/shared/keyword-locations";
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
import { import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema, languageCodeSchema,
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
@ -95,14 +94,12 @@ export const getSerpResultsTool = {
}, },
handler: withMcpProjectAuth(async (args: Args, context) => { handler: withMcpProjectAuth(async (args: Args, context) => {
const client = createDataforseoClient(context.billing); const client = createDataforseoClient(context.billing);
const results = await Promise.all( const results = await Promise.all(
args.queries.map(async (q) => { args.queries.map(async (q) => {
try { try {
const items = await client.serp.live({ const items = await client.serp.live({
keyword: q.keyword, keyword: q.keyword,
locationCode: q.locationCode ?? DEFAULT_LOCATION_CODE, ...resolveMarket(q, context.project),
languageCode: q.languageCode ?? DEFAULT_LANGUAGE_CODE,
}); });
// Trim noise — return only essentials per item. // Trim noise — return only essentials per item.
const trimmed = items.slice(0, 20).map((item) => ({ const trimmed = items.slice(0, 20).map((item) => ({

View File

@ -13,7 +13,7 @@ export const listProjectsTool = {
config: { config: {
title: "List projects", title: "List projects",
description: description:
"Lists all projects in the user's organization. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.", "Lists all projects in the user's organization. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain, locationCode, languageCode}; pass the `id` value as `projectId`. locationCode/languageCode are the project's default market — tools fall back to them when a call omits location/language args.",
inputSchema: {} as Record<string, never>, inputSchema: {} as Record<string, never>,
outputSchema: { outputSchema: {
projects: z.array( projects: z.array(
@ -22,6 +22,8 @@ export const listProjectsTool = {
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
domain: z.string().nullable().optional(), domain: z.string().nullable().optional(),
locationCode: z.number(),
languageCode: z.string(),
url: z.string(), url: z.string(),
}) })
.passthrough(), .passthrough(),
@ -41,7 +43,8 @@ export const listProjectsTool = {
projects.length === 0 projects.length === 0
? ["No projects yet. Create one in the dashboard."] ? ["No projects yet. Create one in the dashboard."]
: projects.map( : projects.map(
(p) => `- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""}`, (p) =>
`- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""} market:${p.locationCode}/${p.languageCode}`,
); );
return mcpResponse({ return mcpResponse({
text: `Projects (${projects.length}):\n${lines.join("\n")}`, text: `Projects (${projects.length}):\n${lines.join("\n")}`,
@ -54,6 +57,8 @@ export const listProjectsTool = {
id: p.id, id: p.id,
name: p.name, name: p.name,
domain: p.domain, domain: p.domain,
locationCode: p.locationCode,
languageCode: p.languageCode,
url: buildDashboardUrl(baseUrl, `/p/${p.id}`), url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
})), })),
}, },

View File

@ -99,7 +99,11 @@ const backlinkPage = {
beforeEach(() => { beforeEach(() => {
mocks.getProjectForOrganization.mockReset(); mocks.getProjectForOrganization.mockReset();
mocks.profileBacklinksPage.mockReset(); mocks.profileBacklinksPage.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_123" }); mocks.getProjectForOrganization.mockResolvedValue({
id: "project_123",
locationCode: 2840,
languageCode: "en",
});
}); });
describe("DataForSEO research tool output schemas", () => { describe("DataForSEO research tool output schemas", () => {

View File

@ -7,11 +7,10 @@ import {
optionalMetaOutputSchema, optionalMetaOutputSchema,
} from "@/server/mcp/output-schemas"; } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveMarket } from "@/shared/keyword-locations";
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
import { assertLanguageForLocation } from "@/server/lib/market";
import { import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
assertLanguageForLocation,
languageCodeSchema, languageCodeSchema,
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
@ -108,13 +107,17 @@ export const researchKeywordsTool = {
const results = await Promise.all( const results = await Promise.all(
args.seeds.map(async (item) => { args.seeds.map(async (item) => {
try { try {
assertLanguageForLocation(item.locationCode, item.languageCode); const { locationCode, languageCode } = resolveMarket(
item,
context.project,
);
assertLanguageForLocation(locationCode, languageCode);
const data = await KeywordResearchService.research( const data = await KeywordResearchService.research(
{ {
projectId: args.projectId, projectId: args.projectId,
keywords: [item.seed], keywords: [item.seed],
locationCode: item.locationCode ?? DEFAULT_LOCATION_CODE, locationCode,
languageCode: item.languageCode ?? DEFAULT_LANGUAGE_CODE, languageCode,
resultLimit: args.resultLimit ?? 150, resultLimit: args.resultLimit ?? 150,
mode: "auto", mode: "auto",
clickstream: args.includeClickstreamData ?? false, clickstream: args.includeClickstreamData ?? false,

View File

@ -4,9 +4,8 @@ import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context"; import { buildProjectMeta } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveMarket } from "@/shared/keyword-locations";
import { import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema, languageCodeSchema,
locationCodeSchema, locationCodeSchema,
projectIdSchema, projectIdSchema,
@ -66,8 +65,7 @@ export const saveKeywordsTool = {
throw new Error("Replacement tags are required when tagMode is replace."); throw new Error("Replacement tags are required when tagMode is replace.");
} }
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE; const { locationCode, languageCode } = resolveMarket(args, context.project);
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
await KeywordResearchService.saveKeywords({ await KeywordResearchService.saveKeywords({
projectId: args.projectId, projectId: args.projectId,

View File

@ -51,7 +51,11 @@ describe("saved keyword MCP tools", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
mocks.getProjectForOrganization.mockReset(); mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
mocks.getSavedKeywords.mockReset(); mocks.getSavedKeywords.mockReset();
mocks.saveKeywords.mockReset(); mocks.saveKeywords.mockReset();
}); });

View File

@ -76,7 +76,11 @@ const toolExtra: ToolExtra = {
describe("search console MCP tools", () => { describe("search console MCP tools", () => {
beforeEach(() => { beforeEach(() => {
mocks.getProjectForOrganization.mockReset(); mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
mocks.isHostedServerAuthMode.mockReset(); mocks.isHostedServerAuthMode.mockReset();
mocks.isHostedServerAuthMode.mockResolvedValue(true); mocks.isHostedServerAuthMode.mockResolvedValue(true);
mocks.hasSelfHostedGscConfig.mockReset(); mocks.hasSelfHostedGscConfig.mockReset();

View File

@ -90,7 +90,11 @@ describe("MCP tool text output (service-backed tools)", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
for (const mock of Object.values(mocks)) mock.mockReset(); for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
}); });
it("research_keywords renders every keyword row in the text table", async () => { it("research_keywords renders every keyword row in the text table", async () => {

View File

@ -7,6 +7,7 @@ import {
domainPagesPageRequestSchema, domainPagesPageRequestSchema,
} from "@/types/schemas/domain"; } from "@/types/schemas/domain";
import { DomainService } from "@/server/features/domain/services/DomainService"; import { DomainService } from "@/server/features/domain/services/DomainService";
import { resolveLabsMarket } from "@/shared/keyword-locations";
function shouldUseDomainE2eFixtures() { function shouldUseDomainE2eFixtures() {
return import.meta.env.VITE_E2E_DOMAIN_FIXTURES === "1"; return import.meta.env.VITE_E2E_DOMAIN_FIXTURES === "1";
@ -20,18 +21,17 @@ export const getDomainOverview = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(domainOverviewSchema) .validator(domainOverviewSchema)
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const input = {
...data,
...resolveLabsMarket(data, context.project),
projectId: context.projectId,
};
if (shouldUseDomainE2eFixtures()) { if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures(); const fixtures = await getDomainE2eFixtures();
return fixtures.getFixtureOverview(data.domain); return fixtures.getFixtureOverview(input.domain);
} }
return DomainService.getOverview( return DomainService.getOverview(input, context);
{
...data,
projectId: context.projectId,
},
context,
);
}); });
export const getDomainKeywordSuggestions = createServerFn({ method: "POST" }) export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
@ -41,6 +41,7 @@ export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
DomainService.getSuggestedKeywords( DomainService.getSuggestedKeywords(
{ {
...data, ...data,
...resolveLabsMarket(data, context.project),
organizationId: context.organizationId, organizationId: context.organizationId,
projectId: context.projectId, projectId: context.projectId,
}, },
@ -52,34 +53,32 @@ export const getDomainKeywordsPage = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(domainKeywordsPageRequestSchema) .validator(domainKeywordsPageRequestSchema)
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const input = {
...data,
...resolveLabsMarket(data, context.project),
projectId: context.projectId,
};
if (shouldUseDomainE2eFixtures()) { if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures(); const fixtures = await getDomainE2eFixtures();
return fixtures.getFixtureKeywordsPage(data); return fixtures.getFixtureKeywordsPage(input);
} }
return DomainService.getKeywordsPage( return DomainService.getKeywordsPage(input, context);
{
...data,
projectId: context.projectId,
},
context,
);
}); });
export const getDomainPagesPage = createServerFn({ method: "POST" }) export const getDomainPagesPage = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(domainPagesPageRequestSchema) .validator(domainPagesPageRequestSchema)
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const input = {
...data,
...resolveLabsMarket(data, context.project),
projectId: context.projectId,
};
if (shouldUseDomainE2eFixtures()) { if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures(); const fixtures = await getDomainE2eFixtures();
return fixtures.getFixturePagesPage(data); return fixtures.getFixturePagesPage(input);
} }
return DomainService.getPagesPage( return DomainService.getPagesPage(input, context);
{
...data,
projectId: context.projectId,
},
context,
);
}); });

View File

@ -13,6 +13,7 @@ import {
} from "@/types/schemas/keywords"; } from "@/types/schemas/keywords";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { requireProjectContext } from "@/serverFunctions/middleware"; import { requireProjectContext } from "@/serverFunctions/middleware";
import { resolveMarket } from "@/shared/keyword-locations";
function shouldUseKeywordE2eFixtures() { function shouldUseKeywordE2eFixtures() {
return import.meta.env.VITE_E2E_KEYWORD_FIXTURES === "1"; return import.meta.env.VITE_E2E_KEYWORD_FIXTURES === "1";
@ -26,18 +27,17 @@ export const researchKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(researchKeywordsSchema) .validator(researchKeywordsSchema)
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const input = {
...data,
...resolveMarket(data, context.project),
projectId: context.projectId,
};
if (shouldUseKeywordE2eFixtures()) { if (shouldUseKeywordE2eFixtures()) {
const fixtures = await getKeywordE2eFixtures(); const fixtures = await getKeywordE2eFixtures();
return fixtures.getKeywordResearchFixture(data); return fixtures.getKeywordResearchFixture(input);
} }
return KeywordResearchService.research( return KeywordResearchService.research(input, context);
{
...data,
projectId: context.projectId,
},
context,
);
}); });
export const saveKeywords = createServerFn({ method: "POST" }) export const saveKeywords = createServerFn({ method: "POST" })
@ -46,6 +46,7 @@ export const saveKeywords = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
return KeywordResearchService.saveKeywords({ return KeywordResearchService.saveKeywords({
...data, ...data,
...resolveMarket(data, context.project),
projectId: context.projectId, projectId: context.projectId,
}); });
}); });
@ -126,6 +127,7 @@ export const getSerpAnalysis = createServerFn({ method: "POST" })
KeywordResearchService.getSerpAnalysis( KeywordResearchService.getSerpAnalysis(
{ {
...data, ...data,
...resolveMarket(data, context.project),
projectId: context.projectId, projectId: context.projectId,
}, },
context, context,

View File

@ -8,6 +8,7 @@ import {
archiveProjectSchema, archiveProjectSchema,
createProjectSchema, createProjectSchema,
restoreProjectSchema, restoreProjectSchema,
setProjectMarketSchema,
updateProjectSchema, updateProjectSchema,
} from "@/types/schemas/projects"; } from "@/types/schemas/projects";
import { z } from "zod"; import { z } from "zod";
@ -34,6 +35,13 @@ export const updateProject = createServerFn({ method: "POST" })
ProjectService.updateProject(context.organizationId, data), ProjectService.updateProject(context.organizationId, data),
); );
export const setProjectMarket = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(setProjectMarketSchema)
.handler(async ({ data, context }) =>
ProjectService.setProjectMarket(context.organizationId, data),
);
export const archiveProject = createServerFn({ method: "POST" }) export const archiveProject = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(archiveProjectSchema) .validator(archiveProjectSchema)

View File

@ -77,6 +77,7 @@ export const createRankTrackingConfig = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
const result = await RankTrackingService.createConfig({ const result = await RankTrackingService.createConfig({
projectId: context.projectId, projectId: context.projectId,
projectMarket: context.project,
domain: data.domain, domain: data.domain,
locationCode: data.locationCode, locationCode: data.locationCode,
languageCode: data.languageCode, languageCode: data.languageCode,

View File

@ -9,6 +9,8 @@ import {
isLabsLocationCode, isLabsLocationCode,
isSupportedLanguageCode, isSupportedLanguageCode,
isSupportedLocationCode, isSupportedLocationCode,
resolveLabsMarket,
resolveMarket,
} from "./keyword-locations"; } from "./keyword-locations";
describe("keyword locations", () => { describe("keyword locations", () => {
@ -94,3 +96,76 @@ describe("formatLocationLabel", () => {
); );
}); });
}); });
describe("resolveMarket", () => {
const vietnamProject = { locationCode: 2704, languageCode: "vi" };
it("falls back to the project's pair when nothing is supplied", () => {
expect(resolveMarket({}, vietnamProject)).toEqual({
locationCode: 2704,
languageCode: "vi",
});
});
it("snaps the language to a location override instead of borrowing the project's", () => {
// A Vietnam project querying Germany must not send Vietnamese.
expect(resolveMarket({ locationCode: 2276 }, vietnamProject)).toEqual({
locationCode: 2276,
languageCode: "de",
});
});
it("keeps the project language when the override matches the project location", () => {
const spanishUs = { locationCode: 2840, languageCode: "es" };
expect(resolveMarket({ locationCode: 2840 }, spanishUs)).toEqual({
locationCode: 2840,
languageCode: "es",
});
});
it("applies an explicit language to the project's location", () => {
expect(resolveMarket({ languageCode: "en" }, vietnamProject)).toEqual({
locationCode: 2704,
languageCode: "en",
});
});
it("uses both overrides verbatim", () => {
expect(
resolveMarket({ locationCode: 2276, languageCode: "en" }, vietnamProject),
).toEqual({ locationCode: 2276, languageCode: "en" });
});
});
describe("resolveLabsMarket", () => {
it("inherits a Labs-served project market like resolveMarket does", () => {
expect(
resolveLabsMarket({}, { locationCode: 2704, languageCode: "vi" }),
).toEqual({ locationCode: 2704, languageCode: "vi" });
});
it("falls back to the US when the project market is Google-Ads-served", () => {
// Iceland has no Labs data. The caller never picked it, so a Labs-only
// tool must not fail on it.
expect(
resolveLabsMarket({}, { locationCode: 2352, languageCode: "en" }),
).toEqual({ locationCode: 2840, languageCode: "en" });
});
it("falls back to the US when the project pair is not served", () => {
// Concurrent half-updates can leave a location/language pair Labs rejects;
// sending it would spend credits on a task that always fails.
expect(
resolveLabsMarket({}, { locationCode: 2276, languageCode: "vi" }),
).toEqual({ locationCode: 2840, languageCode: "en" });
});
it("leaves an explicit location alone so the caller's assert can reject it", () => {
expect(
resolveLabsMarket(
{ locationCode: 2352 },
{ locationCode: 2704, languageCode: "vi" },
),
).toMatchObject({ locationCode: 2352 });
});
});

View File

@ -697,6 +697,66 @@ export function getLanguageCode(locationCode: number): string {
return LOCATION_LANGUAGE[locationCode] ?? "en"; return LOCATION_LANGUAGE[locationCode] ?? "en";
} }
/**
* Resolves a request's market against the project's default. The pair is
* resolved together: overriding only the location snaps the language to that
* location's default language, because the project's language was chosen for
* the project's own location and may not be valid or sensible for the
* override (e.g. a Vietnam project querying Germany must not default to
* Vietnamese).
*/
export function resolveMarket(
args: { locationCode?: number; languageCode?: string },
project: { locationCode: number; languageCode: string },
): { locationCode: number; languageCode: string } {
const locationCode = args.locationCode ?? project.locationCode;
const languageCode =
args.languageCode ??
(locationCode === project.locationCode
? project.languageCode
: getLanguageCode(locationCode));
return { locationCode, languageCode };
}
/**
* Whether DataForSEO serves this language for this location. Only Labs
* locations have authoritative per-location language lists; Google Ads
* locations are left to the metering safety net.
*/
export function isLanguageServedForLocation(
locationCode: number,
languageCode: string,
): boolean {
if (getKeywordDataProvider(locationCode) !== "labs") return true;
return getLanguageOptions(locationCode).some(
(option) => option.code === languageCode,
);
}
/**
* Resolves the market for a Labs-only tool. Same as resolveMarket, except a
* project default Labs cannot serve is replaced by the United States: the
* caller never chose that market, so rejecting the call would dead-end on a
* value it can't see and passing the pair through would spend credits on a
* task DataForSEO rejects. An explicit location is left alone, so a caller that
* names an unserved country still fails loudly on its own assert.
*/
export function resolveLabsMarket(
args: { locationCode?: number; languageCode?: string },
project: { locationCode: number; languageCode: string },
): { locationCode: number; languageCode: string } {
const projectIsServed =
getKeywordDataProvider(project.locationCode) === "labs" &&
isLanguageServedForLocation(project.locationCode, project.languageCode);
return resolveMarket(
args,
projectIsServed
? project
: { locationCode: DEFAULT_LOCATION_CODE, languageCode: "en" },
);
}
/** /**
* Language codes DataForSEO accepts the master LANGUAGE_OPTIONS list. Callers * Language codes DataForSEO accepts the master LANGUAGE_OPTIONS list. Callers
* (e.g. MCP tools) can pass an arbitrary `language_code`; an unsupported one is * (e.g. MCP tools) can pass an arbitrary `language_code`; an unsupported one is
@ -738,9 +798,9 @@ const MULTI_LANGUAGE_LOCATIONS: Record<number, readonly string[]> = {
}; };
/** /**
* Languages to offer for a location's rank-tracking config. Restricts the * Languages to offer for a location. Restricts the global LANGUAGE_OPTIONS
* global LANGUAGE_OPTIONS list to the languages DataForSEO supports for that * list to the languages DataForSEO supports for that country, so a picker
* country, so the picker isn't a wall of irrelevant options. * isn't a wall of irrelevant options.
*/ */
export function getLanguageOptions( export function getLanguageOptions(
locationCode: number, locationCode: number,

View File

@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import {
LABS_LOCATION_OPTIONS,
getLanguageCode,
isLabsLocationCode,
} from "./keyword-locations";
/**
* DataForSEO's own default language for each Labs location: the language with
* the most keyword records, which is what Labs uses when a call omits
* language_code. Snapshot of the free
* GET /v3/dataforseo_labs/locations_and_languages, taken 2026-07-16.
*
* LOCATION_LANGUAGE is hand-maintained, so it can drift from what DataForSEO
* actually serves a drifted entry sends the wrong-language data for a
* country without failing. Refresh this snapshot with:
*
* curl -s https://api.dataforseo.com/v3/dataforseo_labs/locations_and_languages \
* -H "Authorization: Basic $DATAFORSEO_API_KEY" \
* | jq -r '.tasks[0].result | sort_by(.location_code)[]
* | " \(.location_code): \"\(.available_languages | max_by(.keywords) | .language_code)\", // \(.location_name)"'
*
* Google-Ads-only countries are absent by design: Labs holds no data for them,
* so their language has no vendor answer to check against.
*/
const VENDOR_DEFAULT_LANGUAGE: Record<number, string> = {
2008: "sq", // Albania
2012: "fr", // Algeria
2024: "pt", // Angola
2031: "az", // Azerbaijan
2032: "es", // Argentina
2036: "en", // Australia
2040: "de", // Austria
2048: "ar", // Bahrain
2050: "bn", // Bangladesh
2051: "hy", // Armenia
2056: "nl", // Belgium
2068: "es", // Bolivia
2070: "bs", // Bosnia and Herzegovina
2076: "pt", // Brazil
2100: "bg", // Bulgaria
2104: "en", // Myanmar (Burma)
2116: "en", // Cambodia
2120: "fr", // Cameroon
2124: "en", // Canada
2144: "en", // Sri Lanka
2152: "es", // Chile
2158: "zh-TW", // Taiwan
2170: "es", // Colombia
2188: "es", // Costa Rica
2191: "hr", // Croatia
2196: "el", // Cyprus
2203: "cs", // Czechia
2208: "da", // Denmark
2218: "es", // Ecuador
2222: "es", // El Salvador
2233: "et", // Estonia
2246: "fi", // Finland
2250: "fr", // France
2276: "de", // Germany
2288: "en", // Ghana
2300: "el", // Greece
2320: "es", // Guatemala
2344: "zh-TW", // Hong Kong
2348: "hu", // Hungary
2356: "en", // India
2360: "id", // Indonesia
2372: "en", // Ireland
2376: "he", // Israel
2380: "it", // Italy
2384: "fr", // Cote d'Ivoire
2392: "ja", // Japan
2398: "ru", // Kazakhstan
2400: "ar", // Jordan
2404: "en", // Kenya
2410: "ko", // South Korea
2428: "lv", // Latvia
2440: "lt", // Lithuania
2458: "en", // Malaysia
2470: "en", // Malta
2484: "es", // Mexico
2492: "fr", // Monaco
2498: "ro", // Moldova
2504: "ar", // Morocco
2528: "nl", // Netherlands
2554: "en", // New Zealand
2558: "es", // Nicaragua
2566: "en", // Nigeria
2578: "nb", // Norway
2586: "en", // Pakistan
2591: "es", // Panama
2600: "es", // Paraguay
2604: "es", // Peru
2608: "en", // Philippines
2616: "pl", // Poland
2620: "pt", // Portugal
2642: "ro", // Romania
2682: "ar", // Saudi Arabia
2686: "fr", // Senegal
2688: "sr", // Serbia
2702: "en", // Singapore
2703: "sk", // Slovakia
2704: "vi", // Vietnam
2705: "sl", // Slovenia
2710: "en", // South Africa
2724: "es", // Spain
2752: "sv", // Sweden
2756: "de", // Switzerland
2764: "th", // Thailand
2784: "en", // United Arab Emirates
2788: "ar", // Tunisia
2792: "tr", // Turkiye
2804: "uk", // Ukraine
2807: "mk", // North Macedonia
2818: "ar", // Egypt
2826: "en", // United Kingdom
2840: "en", // United States
2854: "fr", // Burkina Faso
2858: "es", // Uruguay
2862: "es", // Venezuela
};
describe("getLanguageCode vs DataForSEO's Labs defaults", () => {
it("uses DataForSEO's default language for every Labs location", () => {
const drifted = Object.entries(VENDOR_DEFAULT_LANGUAGE)
.filter(([code, language]) => getLanguageCode(Number(code)) !== language)
.map(
([code, language]) =>
`${code}: ours=${getLanguageCode(Number(code))} vendor=${language}`,
);
expect(drifted).toEqual([]);
});
it("classifies every location DataForSEO serves from Labs as a Labs location", () => {
const misclassified = Object.entries(VENDOR_DEFAULT_LANGUAGE)
.filter(([code]) => !isLabsLocationCode(Number(code)))
.map(([code, language]) => `${code}: vendor serves ${language} via Labs`);
expect(misclassified).toEqual([]);
});
it("covers every Labs location we offer, so a new one can't skip the check", () => {
const unverified = LABS_LOCATION_OPTIONS.filter(
(option) => VENDOR_DEFAULT_LANGUAGE[option.code] == null,
).map((option) => `${option.code} ${option.label}`);
expect(unverified).toEqual([]);
});
});

View File

@ -59,8 +59,8 @@ export const domainOverviewSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
domain: z.string().min(1, "Domain is required").max(255), domain: z.string().min(1, "Domain is required").max(255),
includeSubdomains: z.boolean().default(true), includeSubdomains: z.boolean().default(true),
locationCode: z.number().int().positive().default(2840), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).default("en"), languageCode: z.string().min(2).max(8).optional(),
}); });
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@ -74,8 +74,8 @@ const domainTabs = ["keywords", "pages"] as const;
export const domainKeywordSuggestionsSchema = z.object({ export const domainKeywordSuggestionsSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
domain: domainField, domain: domainField,
locationCode: z.number().int().positive(), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8), languageCode: z.string().min(2).max(8).optional(),
}); });
export const DOMAIN_KEYWORDS_PAGE_SIZES = [50, 100, 200] as const; export const DOMAIN_KEYWORDS_PAGE_SIZES = [50, 100, 200] as const;
@ -119,8 +119,8 @@ export const domainKeywordsPageRequestSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
domain: z.string().min(1).max(255), domain: z.string().min(1).max(255),
includeSubdomains: z.boolean().default(true), includeSubdomains: z.boolean().default(true),
locationCode: z.number().int().positive().default(2840), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).default("en"), languageCode: z.string().min(2).max(8).optional(),
page: z.number().int().positive().default(1), page: z.number().int().positive().default(1),
pageSize: z pageSize: z
.number() .number()
@ -141,8 +141,8 @@ export const domainPagesPageRequestSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
domain: z.string().min(1).max(255), domain: z.string().min(1).max(255),
includeSubdomains: z.boolean().default(true), includeSubdomains: z.boolean().default(true),
locationCode: z.number().int().positive().default(2840), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).default("en"), languageCode: z.string().min(2).max(8).optional(),
page: z.number().int().positive().default(1), page: z.number().int().positive().default(1),
pageSize: z pageSize: z
.number() .number()

View File

@ -18,8 +18,8 @@ const sortDirs = ["asc", "desc"] as const;
export const researchKeywordsSchema = z.object({ export const researchKeywordsSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
keywords: z.array(z.string().min(1)).min(1).max(200), keywords: z.array(z.string().min(1)).min(1).max(200),
locationCode: z.number().int().positive().default(2840), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).default("en"), languageCode: z.string().min(2).max(8).optional(),
resultLimit: z resultLimit: z
.union([z.literal(150), z.literal(300), z.literal(500)]) .union([z.literal(150), z.literal(300), z.literal(500)])
.default(150), .default(150),
@ -35,8 +35,8 @@ export const saveKeywordsSchema = z
.object({ .object({
projectId: z.string().min(1), projectId: z.string().min(1),
keywords: z.array(z.string().min(1)).min(1).max(500), keywords: z.array(z.string().min(1)).min(1).max(500),
locationCode: z.number().int().positive().default(2840), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).default("en"), languageCode: z.string().min(2).max(8).optional(),
tags: z.array(savedKeywordTagSchema).max(20).optional(), tags: z.array(savedKeywordTagSchema).max(20).optional(),
tagMode: z.enum(["append", "replace"]).optional(), tagMode: z.enum(["append", "replace"]).optional(),
metrics: z metrics: z
@ -149,6 +149,17 @@ export const refreshSavedKeywordMetricsSchema = 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>;
type ResolvedMarket = { locationCode: number; languageCode: string };
export type ResolvedResearchKeywordsInput = Omit<
ResearchKeywordsInput,
keyof ResolvedMarket
> &
ResolvedMarket;
export type ResolvedSaveKeywordsInput = Omit<
SaveKeywordsInput,
keyof ResolvedMarket
> &
ResolvedMarket;
export type RemoveSavedKeywordsInput = z.infer< export type RemoveSavedKeywordsInput = z.infer<
typeof removeSavedKeywordsSchema typeof removeSavedKeywordsSchema
>; >;
@ -172,8 +183,8 @@ export type RefreshSavedKeywordMetricsInput = z.infer<
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),
locationCode: z.number().int().positive().default(2840), locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).default("en"), languageCode: z.string().min(2).max(8).optional(),
}); });
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { createProjectSchema, updateProjectSchema } from "./projects";
// The service derives a missing language from the location, so a language
// arriving on its own has nothing to validate against.
describe("project market fields", () => {
it("rejects a language with no location", () => {
expect(
updateProjectSchema.safeParse({
projectId: "project_1",
name: "Acme",
languageCode: "vi",
}).success,
).toBe(false);
expect(
createProjectSchema.safeParse({ name: "Acme", languageCode: "vi" })
.success,
).toBe(false);
});
it("accepts a location on its own, and a full pair", () => {
expect(
createProjectSchema.safeParse({ name: "Acme", locationCode: 2704 })
.success,
).toBe(true);
expect(
updateProjectSchema.safeParse({
projectId: "project_1",
name: "Acme",
locationCode: 2704,
languageCode: "vi",
}).success,
).toBe(true);
});
it("accepts a project with no market at all", () => {
expect(createProjectSchema.safeParse({ name: "Acme" }).success).toBe(true);
});
});

View File

@ -1,4 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import {
isSupportedLanguageCode,
isSupportedLocationCode,
} from "@/shared/keyword-locations";
const projectNameField = z const projectNameField = z
.string() .string()
@ -13,15 +17,63 @@ const projectDomainField = z
.transform((value) => value || undefined) .transform((value) => value || undefined)
.optional(); .optional();
export const createProjectSchema = z.object({ // Default market for the project's data calls. The location/language PAIR is
// validated in the service (an update may change one side and needs the
// stored row for the other).
const projectLocationCodeField = z
.number()
.int()
.refine(isSupportedLocationCode, "Unsupported DataForSEO location code")
.optional();
const projectLanguageCodeField = z
.string()
.refine(isSupportedLanguageCode, "Unsupported language code")
.optional();
// A language on its own has no location to validate against, and would force a
// read of the stored row to resolve. Callers set the market as a pair, or send
// a location alone and let the service derive its language.
const hasLocationForLanguage = (input: {
locationCode?: number;
languageCode?: string;
}) => input.locationCode != null || input.languageCode == null;
const marketPairMessage = {
message: "A language requires a location.",
path: ["languageCode"],
};
export const createProjectSchema = z
.object({
name: projectNameField, name: projectNameField,
domain: projectDomainField, domain: projectDomainField,
}); locationCode: projectLocationCodeField,
languageCode: projectLanguageCodeField,
})
.refine(hasLocationForLanguage, marketPairMessage);
export const updateProjectSchema = z.object({ export const updateProjectSchema = z
.object({
projectId: z.string().min(1), projectId: z.string().min(1),
name: projectNameField, name: projectNameField,
domain: projectDomainField, domain: projectDomainField,
locationCode: projectLocationCodeField,
languageCode: projectLanguageCodeField,
})
.refine(hasLocationForLanguage, marketPairMessage);
// Market-only update (onboarding). Both halves are required: the caller picks
// them together, so the service can validate the pair without a stored row.
export const setProjectMarketSchema = z.object({
projectId: z.string().min(1),
locationCode: z
.number()
.int()
.refine(isSupportedLocationCode, "Unsupported DataForSEO location code"),
languageCode: z
.string()
.refine(isSupportedLanguageCode, "Unsupported language code"),
}); });
export const archiveProjectSchema = z.object({ export const archiveProjectSchema = z.object({
@ -37,5 +89,6 @@ export const restoreProjectSchema = z.object({
export type CreateProjectInput = z.infer<typeof createProjectSchema>; export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>; export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
export type SetProjectMarketInput = z.infer<typeof setProjectMarketSchema>;
export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>; export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>; export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;