diff --git a/app/api/profile/generate-catalog-batch/route.ts b/app/api/profile/generate-catalog-batch/route.ts index 05a31b7..a42f6f9 100644 --- a/app/api/profile/generate-catalog-batch/route.ts +++ b/app/api/profile/generate-catalog-batch/route.ts @@ -72,7 +72,8 @@ export async function POST(request: NextRequest) { string, unknown >; - const before = summarizeToolReadiness(stored, ALL_TOOL_SLUGS); + const profileHash = calculateProfileDataHash(userProfile); + const before = summarizeToolReadiness(stored, ALL_TOOL_SLUGS, profileHash); const toolStatus = (stored.toolStatus as PersistedToolStatusMap | undefined) ?? {}; if (before.allReportsReady) { return NextResponse.json({ @@ -103,7 +104,6 @@ export async function POST(request: NextRequest) { }); } - const profileHash = calculateProfileDataHash(userProfile); const result = await generateAndPersistToolReports({ uid, profile: { ...userProfile, uid }, @@ -112,7 +112,7 @@ export async function POST(request: NextRequest) { skipVedicComprehensive: false, }); - const readiness = result.readiness ?? summarizeToolReadiness(stored, ALL_TOOL_SLUGS); + const readiness = result.readiness ?? summarizeToolReadiness(stored, ALL_TOOL_SLUGS, profileHash); const next = selectRunnableCatalogSlugs( readiness.pendingToolSlugs, result.toolStatus ?? toolStatus, diff --git a/app/api/profile/generate-mystical/route.ts b/app/api/profile/generate-mystical/route.ts index ea6177d..fbc8a10 100644 --- a/app/api/profile/generate-mystical/route.ts +++ b/app/api/profile/generate-mystical/route.ts @@ -295,7 +295,7 @@ export async function POST(request: NextRequest) { if (hashMatches) { const stored = await getDocument('comprehensiveMysticalProfiles', uid); const storedProfile = (stored || {}) as Record; - const readiness = summarizeToolReadiness(storedProfile, ALL_TOOL_SLUGS); + const readiness = summarizeToolReadiness(storedProfile, ALL_TOOL_SLUGS, effectiveHash); if (readiness.allReportsReady) { const auditId = await writeRegenDecisionTelemetry(uid, { event: 'mystical_regen_skipped_unchanged', @@ -503,7 +503,7 @@ export async function POST(request: NextRequest) { const storedAfterNatal = ((await getDocument('comprehensiveMysticalProfiles', uid)) || {}) as Record; const readiness = - natalReadiness ?? summarizeToolReadiness(storedAfterNatal, ALL_TOOL_SLUGS); + natalReadiness ?? summarizeToolReadiness(storedAfterNatal, ALL_TOOL_SLUGS, newHash); await setDocument('users', uid, { mysticalProfileGenerated: true, mysticalProfileGeneratedAt: Date.now(), @@ -613,7 +613,8 @@ export async function GET(request: NextRequest) { const generationJobStatus = typeof generationJob?.status === 'string' ? generationJob.status : null; const lockRuntime = getMysticalLockRuntimeStatus(lock, mysticalLockStaleMs()); const generated = Boolean(user?.mysticalProfileGenerated) || Boolean(profileDoc); - const readiness = summarizeToolReadiness(profile, ALL_TOOL_SLUGS); + const currentHash = typeof user?.profileDataHash === 'string' ? user.profileDataHash : undefined; + const readiness = summarizeToolReadiness(profile, ALL_TOOL_SLUGS, currentHash); const lastHeartbeatAt = typeof generationJob?.lastHeartbeatAt === 'number' ? generationJob.lastHeartbeatAt : null; const runningHeartbeatStale = generationJobStatus === 'running' && diff --git a/lib/onDemandToolReports.ts b/lib/onDemandToolReports.ts index 4c059cf..0521629 100644 --- a/lib/onDemandToolReports.ts +++ b/lib/onDemandToolReports.ts @@ -89,7 +89,7 @@ export async function persistOnDemandToolReports(params: { profilePatch.toolStatus = toolStatus; const mergedProfile = { ...existingProfile, ...profilePatch }; - const readiness = summarizeToolReadiness(mergedProfile, ALL_TOOL_SLUGS); + const readiness = summarizeToolReadiness(mergedProfile, ALL_TOOL_SLUGS, profileHash); await setDocument('comprehensiveMysticalProfiles', uid, profilePatch); await setDocument('users', uid, { mysticalProfileGenerated: true, diff --git a/lib/toolReportReadiness.ts b/lib/toolReportReadiness.ts index 08fd388..c93f07b 100644 --- a/lib/toolReportReadiness.ts +++ b/lib/toolReportReadiness.ts @@ -168,9 +168,27 @@ export function getCoreToolSlugsCore10(): string[] { return [...CORE10_TOOL_SLUGS]; } +/** + * When a current profile hash is supplied, reports keyed to a *different* + * generationIdempotencyKey are treated as pending so a birth-data regen + * refills the catalog instead of keeping the previous natal's readings. + * Reports with no key stay ready (legacy rows) unless Generate clears them. + */ +export function reportMatchesCurrentProfileHash( + report: unknown, + currentProfileHash: string | undefined, +): boolean { + if (!currentProfileHash) return true; + if (!report || typeof report !== 'object') return true; + const key = (report as Record).generationIdempotencyKey; + if (typeof key !== 'string' || key.length === 0) return true; + return key === currentProfileHash; +} + export function summarizeToolReadiness( profile: Record | null | undefined, toolSlugs: readonly string[] = ALL_TOOL_SLUGS, + currentProfileHash?: string, ): { readyToolsCount: number; pendingToolSlugs: string[]; allReportsReady: boolean } { if (!profile) { return { @@ -184,7 +202,7 @@ export function summarizeToolReadiness( const pendingToolSlugs: string[] = []; for (const slug of toolSlugs) { const report = profile[slug] ?? toolReports?.[slug]?.data; - if (isReadyToolReport(report, slug)) { + if (isReadyToolReport(report, slug) && reportMatchesCurrentProfileHash(report, currentProfileHash)) { readyToolsCount += 1; } else { pendingToolSlugs.push(slug); diff --git a/tests/integration/generate-catalog-batch.test.ts b/tests/integration/generate-catalog-batch.test.ts index 214c4bf..3354f60 100644 --- a/tests/integration/generate-catalog-batch.test.ts +++ b/tests/integration/generate-catalog-batch.test.ts @@ -145,6 +145,42 @@ describe('generate-catalog-batch API', () => { expect(data.allReportsReady).toBe(false); }); + it('generates hash-mismatched tools instead of treating the old catalog as complete', async () => { + mockGetDocument.mockImplementation((collection: string) => { + if (collection === 'users') return Promise.resolve({ uid, mysticalProfileGenerated: true }); + if (collection === 'comprehensiveMysticalProfiles') { + const stored = allToolsDisplayableProfile(); + stored.vedic = { + ...displayableReportForSlug('vedic'), + generationIdempotencyKey: 'hash-1', + }; + stored.western = { + ...displayableReportForSlug('western'), + generationIdempotencyKey: 'hash-1', + }; + for (const slug of ALL_TOOL_SLUGS) { + if (slug === 'vedic' || slug === 'western') continue; + stored[slug] = { + ...displayableReportForSlug(slug), + generationIdempotencyKey: 'old-hash', + }; + } + return Promise.resolve(stored); + } + return Promise.resolve({}); + }); + const res = await callBatch(); + const data = await res.json(); + expect(res.status).toBe(200); + expect(mockGenerateAndPersistToolReports).toHaveBeenCalledWith( + expect.objectContaining({ + toolSlugs: ['hellenistic', 'esotericAstrology'], + }), + ); + expect(data.generatedSlugs).toEqual(['hellenistic', 'esotericAstrology']); + expect(data.allReportsReady).toBe(false); + }); + it('skips exhausted failed slugs and generates the next runnable tools', async () => { mockGetDocument.mockImplementation((collection: string) => { if (collection === 'users') return Promise.resolve({ uid, mysticalProfileGenerated: true }); diff --git a/tests/integration/profile-generate.test.ts b/tests/integration/profile-generate.test.ts index 0ab5552..93917f3 100644 --- a/tests/integration/profile-generate.test.ts +++ b/tests/integration/profile-generate.test.ts @@ -400,6 +400,42 @@ describe('Profile generate-mystical API', () => { expect(data.pendingToolSlugs.length).toBeGreaterThan(0); expect(mockGenerateAndPersistToolReports).not.toHaveBeenCalled(); }); + + it('returns fill_catalog when hash matches but stored tools belong to a previous hash', async () => { + const profile = { ...baseProfile }; + const hash = calculateProfileDataHash(profile); + const staleCatalog = allToolsDisplayableProfile(); + for (const slug of ALL_TOOL_SLUGS) { + staleCatalog[slug] = { + ...(staleCatalog[slug] as Record), + generationIdempotencyKey: 'previous-hash', + }; + } + mockGetDocument.mockImplementation((collection: string) => { + if (collection === 'users') { + return Promise.resolve({ + ...profile, + mysticalProfileGenerated: true, + profileDataHash: hash, + }); + } + if (collection === 'generationLocks') return Promise.resolve(null); + if (collection === 'comprehensiveMysticalProfiles') { + return Promise.resolve(staleCatalog); + } + return Promise.resolve(undefined); + }); + + const res = await callGenerate(); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.alreadyGenerated).toBe(true); + expect(data.decision).toBe('fill_catalog'); + expect(data.decisionReason).toBe('unchanged_hash_catalog_incomplete'); + expect(data.allReportsReady).toBe(false); + expect(data.pendingToolSlugs).toContain('tarot'); + expect(mockGenerateAndPersistToolReports).not.toHaveBeenCalled(); + }); }); describe('Auth and validation', () => { @@ -740,6 +776,59 @@ describe('Profile generate-mystical API', () => { expect(mockTryResumeMysticalStageB).not.toHaveBeenCalled(); }); + it('does not mark the catalog complete when stored tools belong to a previous hash', async () => { + const currentHash = calculateProfileDataHash(baseProfile); + const staleCatalog = allToolsDisplayableProfile(); + staleCatalog.vedic = { + ...(staleCatalog.vedic as Record), + generationIdempotencyKey: currentHash, + }; + staleCatalog.western = { + ...(staleCatalog.western as Record), + generationIdempotencyKey: currentHash, + }; + for (const slug of ALL_TOOL_SLUGS) { + if (slug === 'vedic' || slug === 'western') continue; + staleCatalog[slug] = { + ...(staleCatalog[slug] as Record), + generationIdempotencyKey: 'previous-hash', + }; + } + mockGetDocument.mockImplementation((collection: string) => { + if (collection === 'users') { + return Promise.resolve({ + ...baseProfile, + mysticalProfileGenerated: true, + profileDataHash: currentHash, + allReportsReady: true, + pendingToolSlugs: [], + }); + } + if (collection === 'generationLocks') { + return Promise.resolve({ status: 'completed', phase: 'completed', updatedAt: Date.now() }); + } + if (collection === 'comprehensiveMysticalProfiles') { + return Promise.resolve(staleCatalog); + } + return Promise.resolve(undefined); + }); + + const res = await callGenerationStatus(); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.allReportsReady).toBe(false); + expect(data.completed).toBe(false); + expect(data.pendingToolSlugs).toContain('tarot'); + expect(data.pendingToolSlugs).not.toContain('vedic'); + expect(mockSetDocument).toHaveBeenCalledWith( + 'users', + uid, + expect.objectContaining({ + allReportsReady: false, + }), + ); + }); + it('reconciles user allReportsReady false when profile shows all tools ready', async () => { const allReadyProfile = allToolsDisplayableProfile(); mockGetDocument.mockImplementation((collection: string) => { diff --git a/tests/integration/report-readiness.test.ts b/tests/integration/report-readiness.test.ts index 2c181d6..8efed1c 100644 --- a/tests/integration/report-readiness.test.ts +++ b/tests/integration/report-readiness.test.ts @@ -91,4 +91,32 @@ describe('Report readiness contract', () => { expect(summary.pendingToolSlugs).not.toContain('nameAnalysis'); expect(summary.pendingToolSlugs).not.toContain('vastu'); }); + + it('treats generationIdempotencyKey mismatches as pending when a current hash is given', () => { + const profile = { + vedic: { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'hash-new' }, + western: { planets: [{ name: 'Moon' }], generationIdempotencyKey: 'hash-old' }, + tarot: { profile: { birthCard: { name: 'The Fool' } } }, + } as Record; + const summary = summarizeToolReadiness(profile, ALL_TOOL_SLUGS, 'hash-new'); + expect(summary.pendingToolSlugs).not.toContain('vedic'); + expect(summary.pendingToolSlugs).toContain('western'); + expect(summary.pendingToolSlugs).not.toContain('tarot'); + expect(summary.allReportsReady).toBe(false); + }); + + it('does not treat missing keys as stale when summarizing for a current hash', () => { + const slugs = ['dreamSymbols', 'ogham'] as const; + const keyed = { + dreamSymbols: { reading: 'ok', generationIdempotencyKey: 'hash-1' }, + ogham: { reading: 'ok', generationIdempotencyKey: 'hash-1' }, + }; + const unkeyed = { + dreamSymbols: { reading: 'ok' }, + ogham: { reading: 'ok' }, + }; + expect(summarizeToolReadiness(keyed, slugs, 'hash-1').allReportsReady).toBe(true); + expect(summarizeToolReadiness(unkeyed, slugs, 'hash-1').allReportsReady).toBe(true); + expect(summarizeToolReadiness(keyed, slugs, 'hash-2').allReportsReady).toBe(false); + }); });