From eebe8f9036c6297870e99a6111c5877bb45403a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 11:09:10 +0000 Subject: [PATCH] fix(generation): do not advance profile hash without natal regen Catalog fill and on-demand tool persist calculated a new profileDataHash from edited birth fields and wrote it while Vedic/Western charts stayed on the previous hash, which hid the stale-report banner. Co-authored-by: Andy Oliver Rozario --- app/api/profile/ensure-tool-report/route.ts | 10 ++++ .../profile/generate-catalog-batch/route.ts | 11 ++++ lib/onDemandToolReports.ts | 27 +++++++-- lib/profileHashCommit.ts | 42 ++++++++++++++ tests/integration/ensure-tool-report.test.ts | 28 ++++++++- .../generate-catalog-batch.test.ts | 33 ++++++++++- tests/unit/profileHashCommit.test.ts | 57 +++++++++++++++++++ 7 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 lib/profileHashCommit.ts create mode 100644 tests/unit/profileHashCommit.test.ts diff --git a/app/api/profile/ensure-tool-report/route.ts b/app/api/profile/ensure-tool-report/route.ts index c11c2e48..a6742d9b 100644 --- a/app/api/profile/ensure-tool-report/route.ts +++ b/app/api/profile/ensure-tool-report/route.ts @@ -22,6 +22,7 @@ import { isOnDemandToolSlug, storedReportMatchesHash, } from '@/lib/onDemandToolReports'; +import { isCommittedProfileHash } from '@/lib/profileHashCommit'; import { hasToolReportExtraInputs, sanitizeToolReportExtraInputs } from '@/lib/toolReportExtraInputs'; export const dynamic = 'force-dynamic'; @@ -83,6 +84,15 @@ export async function POST(request: NextRequest) { } const profileHash = calculateProfileDataHash(userProfile); + if (!isCommittedProfileHash(userProfile.profileDataHash, profileHash)) { + return NextResponse.json( + { + error: 'Birth details changed. Click Generate to rebuild natal charts before opening this tool.', + code: 'profile_hash_changed', + }, + { status: 409 }, + ); + } const stored = ((await getDocument('comprehensiveMysticalProfiles', uid)) || {}) as Record; const existing = stored[toolSlug]; const forceRefresh = hasToolReportExtraInputs(extraInputs); diff --git a/app/api/profile/generate-catalog-batch/route.ts b/app/api/profile/generate-catalog-batch/route.ts index 05a31b75..720ee0a7 100644 --- a/app/api/profile/generate-catalog-batch/route.ts +++ b/app/api/profile/generate-catalog-batch/route.ts @@ -19,6 +19,7 @@ import { checkRateLimitWithOptionalFirestore } from '@/lib/rateLimitFirestore'; import { logServerError } from '@/lib/serverErrorLogging'; import { devLog } from '@/lib/devLogger'; import { generateAndPersistToolReports } from '@/lib/onDemandToolReports'; +import { isCommittedProfileHash } from '@/lib/profileHashCommit'; import type { PersistedToolStatusMap } from '@/lib/mysticalStageB'; export const dynamic = 'force-dynamic'; @@ -104,6 +105,16 @@ export async function POST(request: NextRequest) { } const profileHash = calculateProfileDataHash(userProfile); + if (!isCommittedProfileHash(userProfile.profileDataHash, profileHash)) { + return NextResponse.json( + { + error: + 'Birth details changed. Click Generate to rebuild natal charts before filling remaining reports.', + code: 'profile_hash_changed', + }, + { status: 409 }, + ); + } const result = await generateAndPersistToolReports({ uid, profile: { ...userProfile, uid }, diff --git a/lib/onDemandToolReports.ts b/lib/onDemandToolReports.ts index 4c059cfe..823181e1 100644 --- a/lib/onDemandToolReports.ts +++ b/lib/onDemandToolReports.ts @@ -14,8 +14,12 @@ import type { PersistedToolStatusMap } from '@/lib/mysticalStageB'; import { collapseDuplicateReportFields } from '@/lib/reportDedup'; import type { ToolReportExtraInputs } from '@/lib/toolReportExtraInputs'; import { clearCachedDivinationData } from '@/lib/universalDataAggregator'; +import { + NATAL_CHART_SLUGS, + natalReportsMatchProfileHash, +} from '@/lib/profileHashCommit'; -export const NATAL_CHART_SLUGS = ['vedic', 'western'] as const; +export { NATAL_CHART_SLUGS }; export type OnDemandToolSlug = (typeof ALL_TOOL_SLUGS)[number]; @@ -66,10 +70,20 @@ export async function persistOnDemandToolReports(params: { const existingProfile = ((await getDocument('comprehensiveMysticalProfiles', uid)) || {}) as Record; let toolStatus = (existingProfile.toolStatus as PersistedToolStatusMap | undefined) ?? {}; + const rewritingSlugs = Object.entries(toolReports) + .filter(([, entry]) => entry.status === 'success') + .map(([slug]) => slug); + const writeCommittedHash = natalReportsMatchProfileHash( + existingProfile, + profileHash, + rewritingSlugs, + ); const profilePatch: Record = { lastProgressAt: now, - profileDataHash: profileHash, }; + if (writeCommittedHash) { + profilePatch.profileDataHash = profileHash; + } const readySlugs: string[] = []; const failedSlugs: string[] = []; @@ -91,17 +105,20 @@ export async function persistOnDemandToolReports(params: { const mergedProfile = { ...existingProfile, ...profilePatch }; const readiness = summarizeToolReadiness(mergedProfile, ALL_TOOL_SLUGS); await setDocument('comprehensiveMysticalProfiles', uid, profilePatch); - await setDocument('users', uid, { + const userPatch: Record = { mysticalProfileGenerated: true, mysticalProfileGeneratedAt: now, - profileDataHash: profileHash, profileStatus: readiness.allReportsReady ? 'completed' : 'running', allReportsReady: readiness.allReportsReady, pendingToolSlugs: readiness.pendingToolSlugs, toolStatus, lastProgressAt: now, updatedAt: now, - }); + }; + if (writeCommittedHash) { + userPatch.profileDataHash = profileHash; + } + await setDocument('users', uid, userPatch); const lockPatch: Record = { status: readiness.allReportsReady ? 'completed' : 'running', phase: readiness.allReportsReady ? 'completed' : 'catalog', diff --git a/lib/profileHashCommit.ts b/lib/profileHashCommit.ts new file mode 100644 index 00000000..166075bb --- /dev/null +++ b/lib/profileHashCommit.ts @@ -0,0 +1,42 @@ +/** + * Catalog / on-demand persist may only advance profileDataHash when the + * committed hash still matches live birth fields. Otherwise natal charts stay + * on the previous hash while the stale banner disappears. + */ + +export const NATAL_CHART_SLUGS = ['vedic', 'western'] as const; + +export function isCommittedProfileHash( + committedHash: unknown, + liveHash: string, +): boolean { + if (typeof committedHash !== 'string' || committedHash.length === 0) { + return true; + } + return committedHash === liveHash; +} + +function reportGenerationKey(report: unknown): string | null { + if (!report || typeof report !== 'object') return null; + const key = (report as Record).generationIdempotencyKey; + if (typeof key !== 'string' || key.length === 0) return null; + return key; +} + +/** + * True when existing natal reports are either missing (legacy), have no key + * (legacy), match profileHash, or are being rewritten in this persist. + */ +export function natalReportsMatchProfileHash( + profile: Record, + profileHash: string, + rewritingSlugs: readonly string[] = [], +): boolean { + for (const slug of NATAL_CHART_SLUGS) { + if (rewritingSlugs.includes(slug)) continue; + const key = reportGenerationKey(profile[slug]); + if (key == null) continue; + if (key !== profileHash) return false; + } + return true; +} diff --git a/tests/integration/ensure-tool-report.test.ts b/tests/integration/ensure-tool-report.test.ts index 1fd0e059..48da1d5e 100644 --- a/tests/integration/ensure-tool-report.test.ts +++ b/tests/integration/ensure-tool-report.test.ts @@ -36,11 +36,16 @@ jest.mock('@/lib/onDemandToolReports', () => ({ })); jest.mock('@/lib/firebase', () => ({ - calculateProfileDataHash: () => 'hash-1', + calculateProfileDataHash: jest.fn(() => 'hash-1'), })); +import { calculateProfileDataHash } from '@/lib/firebase'; import { POST } from '@/app/api/profile/ensure-tool-report/route'; +const mockCalculateProfileDataHash = calculateProfileDataHash as jest.MockedFunction< + typeof calculateProfileDataHash +>; + describe('ensure-tool-report API', () => { const uid = 'user-1'; @@ -53,6 +58,7 @@ describe('ensure-tool-report API', () => { failedSlugs: [], toolReports: { tarot: { status: 'success', data: { cards: [{ name: 'The Fool' }] } } }, }); + mockCalculateProfileDataHash.mockReturnValue('hash-1'); }); async function callEnsure(body: Record): Promise { @@ -136,4 +142,24 @@ describe('ensure-tool-report API', () => { }), ); }); + + it('returns 409 and does not persist when birth details changed after the last Generate', async () => { + mockCalculateProfileDataHash.mockReturnValue('hash-2'); + mockGetDocument.mockImplementation((collection: string) => { + if (collection === 'users') { + return Promise.resolve({ + uid, + mysticalProfileGenerated: true, + profileDataHash: 'hash-1', + }); + } + if (collection === 'comprehensiveMysticalProfiles') return Promise.resolve({}); + return Promise.resolve({}); + }); + const res = await callEnsure({ toolSlug: 'tarot' }); + const data = await res.json(); + expect(res.status).toBe(409); + expect(data.code).toBe('profile_hash_changed'); + expect(mockGenerateAndPersistToolReports).not.toHaveBeenCalled(); + }); }); diff --git a/tests/integration/generate-catalog-batch.test.ts b/tests/integration/generate-catalog-batch.test.ts index 214c4bf9..36ade94f 100644 --- a/tests/integration/generate-catalog-batch.test.ts +++ b/tests/integration/generate-catalog-batch.test.ts @@ -62,11 +62,16 @@ jest.mock('@/lib/onDemandToolReports', () => ({ })); jest.mock('@/lib/firebase', () => ({ - calculateProfileDataHash: () => 'hash-1', + calculateProfileDataHash: jest.fn(() => 'hash-1'), })); +import { calculateProfileDataHash } from '@/lib/firebase'; import { POST } from '@/app/api/profile/generate-catalog-batch/route'; +const mockCalculateProfileDataHash = calculateProfileDataHash as jest.MockedFunction< + typeof calculateProfileDataHash +>; + describe('generate-catalog-batch API', () => { const uid = 'user-1'; @@ -79,6 +84,7 @@ describe('generate-catalog-batch API', () => { failedSlugs: [], toolReports: {}, }); + mockCalculateProfileDataHash.mockReturnValue('hash-1'); }); async function callBatch(): Promise { @@ -169,4 +175,29 @@ describe('generate-catalog-batch API', () => { ); expect(data.generatedSlugs).toEqual(['esotericAstrology', 'kabbalisticAstrology']); }); + + it('returns 409 and does not persist when birth details changed after the last Generate', async () => { + mockCalculateProfileDataHash.mockReturnValue('hash-2'); + mockGetDocument.mockImplementation((collection: string) => { + if (collection === 'users') { + return Promise.resolve({ + uid, + mysticalProfileGenerated: true, + profileDataHash: 'hash-1', + }); + } + if (collection === 'comprehensiveMysticalProfiles') { + return Promise.resolve({ + vedic: displayableReportForSlug('vedic'), + western: displayableReportForSlug('western'), + }); + } + return Promise.resolve({}); + }); + const res = await callBatch(); + const data = await res.json(); + expect(res.status).toBe(409); + expect(data.code).toBe('profile_hash_changed'); + expect(mockGenerateAndPersistToolReports).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/profileHashCommit.test.ts b/tests/unit/profileHashCommit.test.ts new file mode 100644 index 00000000..3989e22f --- /dev/null +++ b/tests/unit/profileHashCommit.test.ts @@ -0,0 +1,57 @@ +import { + isCommittedProfileHash, + natalReportsMatchProfileHash, +} from '@/lib/profileHashCommit'; + +describe('isCommittedProfileHash', () => { + it('allows persist when no committed hash exists yet', () => { + expect(isCommittedProfileHash(undefined, 'h2')).toBe(true); + expect(isCommittedProfileHash('', 'h2')).toBe(true); + expect(isCommittedProfileHash(null, 'h2')).toBe(true); + }); + + it('allows persist when live hash matches the committed hash', () => { + expect(isCommittedProfileHash('h1', 'h1')).toBe(true); + }); + + it('refuses persist after birth fields change without Generate', () => { + expect(isCommittedProfileHash('h1', 'h2')).toBe(false); + }); +}); + +describe('natalReportsMatchProfileHash', () => { + it('treats missing or legacy natal reports as compatible', () => { + expect(natalReportsMatchProfileHash({}, 'h2')).toBe(true); + expect( + natalReportsMatchProfileHash( + { vedic: { planets: [{ name: 'Sun' }] }, western: { planets: [{ name: 'Moon' }] } }, + 'h2', + ), + ).toBe(true); + }); + + it('rejects when stored natal keys belong to a previous hash', () => { + expect( + natalReportsMatchProfileHash( + { + vedic: { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'h1' }, + western: { planets: [{ name: 'Moon' }], generationIdempotencyKey: 'h1' }, + }, + 'h2', + ), + ).toBe(false); + }); + + it('allows a persist that is rewriting the mismatched natal slugs', () => { + expect( + natalReportsMatchProfileHash( + { + vedic: { planets: [{ name: 'Sun' }], generationIdempotencyKey: 'h1' }, + western: { planets: [{ name: 'Moon' }], generationIdempotencyKey: 'h1' }, + }, + 'h2', + ['vedic', 'western'], + ), + ).toBe(true); + }); +});