diff --git a/src/server/features/rank-tracking/services/RankTrackingService.test.ts b/src/server/features/rank-tracking/services/RankTrackingService.test.ts index c7699f5..000b1e9 100644 --- a/src/server/features/rank-tracking/services/RankTrackingService.test.ts +++ b/src/server/features/rank-tracking/services/RankTrackingService.test.ts @@ -45,6 +45,7 @@ describe("RankTrackingService.createConfig", () => { it("reactivates an archived config instead of throwing, applying the new settings", async () => { mocks.getConfigByProjectDomainLocation.mockResolvedValue(archivedConfig); + mocks.getConfigsForProject.mockResolvedValue([]); mocks.updateConfig.mockResolvedValue(undefined); const { RankTrackingService } = await import("./RankTrackingService"); @@ -83,6 +84,25 @@ describe("RankTrackingService.createConfig", () => { expect(mocks.createConfig).not.toHaveBeenCalled(); }); + it("rejects reactivating an archived config when the project is at the active-config cap", async () => { + const { MAX_CONFIGS_PER_PROJECT } = await import("@/shared/rank-tracking"); + mocks.getConfigByProjectDomainLocation.mockResolvedValue(archivedConfig); + mocks.getConfigsForProject.mockResolvedValue( + Array.from({ length: MAX_CONFIGS_PER_PROJECT }, (_, i) => ({ + ...archivedConfig, + id: `config_${i}`, + isActive: true, + })), + ); + const { RankTrackingService } = await import("./RankTrackingService"); + + await expect( + RankTrackingService.createConfig(baseInput), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + expect(mocks.updateConfig).not.toHaveBeenCalled(); + expect(mocks.createConfig).not.toHaveBeenCalled(); + }); + it("creates a new config when none exists for the domain + location", async () => { mocks.getConfigByProjectDomainLocation.mockResolvedValue(null); mocks.getConfigsForProject.mockResolvedValue([]); diff --git a/src/server/features/rank-tracking/services/RankTrackingService.ts b/src/server/features/rank-tracking/services/RankTrackingService.ts index 3eb34f0..1956ca5 100644 --- a/src/server/features/rank-tracking/services/RankTrackingService.ts +++ b/src/server/features/rank-tracking/services/RankTrackingService.ts @@ -50,19 +50,31 @@ async function createConfig(input: { normalizedDomain, locationCode, ); - if (existing) { - // The (project, domain, location) row still exists when a domain is - // archived — archiving only flips isActive to false. So re-adding an - // archived domain reactivates that row (keeping its keyword/ranking - // history) with the freshly chosen settings, rather than colliding with - // the unique index. An already-active row is a genuine duplicate. - if (existing.isActive) { - throw new AppError( - "VALIDATION_ERROR", - "This domain + country combination is already being tracked", - ); - } + // The (project, domain, location) row still exists when a domain is + // archived — archiving only flips isActive to false. So re-adding an + // archived domain reactivates that row (keeping its keyword/ranking + // history) with the freshly chosen settings, rather than colliding with + // the unique index. An already-active row is a genuine duplicate. + if (existing?.isActive) { + throw new AppError( + "VALIDATION_ERROR", + "This domain + country combination is already being tracked", + ); + } + // Enforced for reactivations too, not just new rows — otherwise archiving + // and re-adding domains would push a project past the active-config cap. + const allConfigs = await RankTrackingRepository.getConfigsForProject( + input.projectId, + ); + if (allConfigs.length >= MAX_CONFIGS_PER_PROJECT) { + throw new AppError( + "VALIDATION_ERROR", + `Maximum ${MAX_CONFIGS_PER_PROJECT} tracked domains per project`, + ); + } + + if (existing) { await RankTrackingRepository.updateConfig(existing.id, input.projectId, { isActive: true, languageCode: input.languageCode ?? "en", @@ -78,16 +90,6 @@ async function createConfig(input: { return { configId: existing.id }; } - const allConfigs = await RankTrackingRepository.getConfigsForProject( - input.projectId, - ); - if (allConfigs.length >= MAX_CONFIGS_PER_PROJECT) { - throw new AppError( - "VALIDATION_ERROR", - `Maximum ${MAX_CONFIGS_PER_PROJECT} tracked domains per project`, - ); - } - const configId = crypto.randomUUID(); await RankTrackingRepository.createConfig({ diff --git a/src/shared/rank-tracking.ts b/src/shared/rank-tracking.ts index e0d7309..7fa12f1 100644 --- a/src/shared/rank-tracking.ts +++ b/src/shared/rank-tracking.ts @@ -37,7 +37,7 @@ export const SECONDS_PER_BATCH = 6; export const MAX_KEYWORDS_PER_CONFIG = 1000; /** Maximum configs (domain+location combos) per project */ -export const MAX_CONFIGS_PER_PROJECT = 100; +export const MAX_CONFIGS_PER_PROJECT = 500; // --------------------------------------------------------------------------- // Cost estimation