From 0a3c4366f574489ce1a66b5e7c212160451ea990 Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Wed, 14 Jan 2026 11:01:06 -0800 Subject: [PATCH 01/11] Update frontend for user-policy API v2 compatibility --- app/src/adapters/UserPolicyAdapter.ts | 16 ++- app/src/api/policyAssociation.ts | 93 ++++++++------- app/src/hooks/useUserPolicy.ts | 7 +- app/src/libs/queryKeys.ts | 2 + .../fixtures/adapters/userAssociationMocks.ts | 9 +- .../unit/adapters/UserPolicyAdapter.test.ts | 26 +++-- .../tests/unit/api/policyAssociation.test.ts | 110 +++++++++++++----- app/src/types/metadata/userPolicyMetadata.ts | 20 +++- 8 files changed, 181 insertions(+), 102 deletions(-) diff --git a/app/src/adapters/UserPolicyAdapter.ts b/app/src/adapters/UserPolicyAdapter.ts index ea9024fea..43ef1cb90 100644 --- a/app/src/adapters/UserPolicyAdapter.ts +++ b/app/src/adapters/UserPolicyAdapter.ts @@ -2,6 +2,7 @@ import { UserPolicy } from '@/types/ingredients/UserPolicy'; import { UserPolicyCreationMetadata, UserPolicyMetadata, + UserPolicyUpdateMetadata, } from '@/types/metadata/userPolicyMetadata'; /** @@ -11,7 +12,6 @@ export class UserPolicyAdapter { /** * Convert UserPolicy to API creation payload * Handles camelCase to snake_case conversion - * Note: API endpoint doesn't exist yet */ static toCreationPayload( userPolicy: Omit @@ -21,7 +21,16 @@ export class UserPolicyAdapter { policy_id: String(userPolicy.policyId), country_id: userPolicy.countryId, label: userPolicy.label, - updated_at: userPolicy.updatedAt || new Date().toISOString(), + }; + } + + /** + * Convert UserPolicy updates to API update payload + * Handles camelCase to snake_case conversion + */ + static toUpdatePayload(updates: Partial): UserPolicyUpdateMetadata { + return { + label: updates.label, }; } @@ -29,11 +38,10 @@ export class UserPolicyAdapter { * Convert API response to UserPolicy * Handles snake_case to camelCase conversion * Explicitly coerces IDs to strings to handle JSON.parse type mismatches - * Note: API endpoint doesn't exist yet */ static fromApiResponse(apiData: UserPolicyMetadata): UserPolicy { return { - id: String(apiData.policy_id), + id: String(apiData.id), userId: String(apiData.user_id), policyId: String(apiData.policy_id), countryId: apiData.country_id, diff --git a/app/src/api/policyAssociation.ts b/app/src/api/policyAssociation.ts index dc073aac0..5204d0ce5 100644 --- a/app/src/api/policyAssociation.ts +++ b/app/src/api/policyAssociation.ts @@ -1,31 +1,30 @@ import { UserPolicyAdapter } from '@/adapters/UserPolicyAdapter'; -import { UserPolicyCreationPayload } from '@/types/payloads'; +import { UserPolicyCreationMetadata } from '@/types/metadata/userPolicyMetadata'; import { UserPolicy } from '../types/ingredients/UserPolicy'; export interface UserPolicyStore { create: (policy: Omit) => Promise; findByUser: (userId: string, countryId?: string) => Promise; - findById: (userId: string, policyId: string) => Promise; + findById: (userPolicyId: string) => Promise; update: (userPolicyId: string, updates: Partial) => Promise; - // The below are not yet implemented, but keeping for future use - // delete(userPolicyId: string): Promise; + delete: (userPolicyId: string) => Promise; } export class ApiPolicyStore implements UserPolicyStore { - // TODO: Modify value to match to-be-created API endpoint structure - private readonly BASE_URL = '/api/user-policy-associations'; + private readonly BASE_URL = '/user-policies'; async create(policy: Omit): Promise { - const payload: UserPolicyCreationPayload = UserPolicyAdapter.toCreationPayload(policy); + const payload: UserPolicyCreationMetadata = UserPolicyAdapter.toCreationPayload(policy); - const response = await fetch(`${this.BASE_URL}`, { + const response = await fetch(`${this.BASE_URL}/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { - throw new Error('Failed to create policy association'); + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to create policy association'); } const apiResponse = await response.json(); @@ -33,22 +32,25 @@ export class ApiPolicyStore implements UserPolicyStore { } async findByUser(userId: string, countryId?: string): Promise { - const response = await fetch(`${this.BASE_URL}/user/${userId}`, { + const params = new URLSearchParams({ user_id: userId }); + if (countryId) { + params.append('country_id', countryId); + } + + const response = await fetch(`${this.BASE_URL}/?${params}`, { headers: { 'Content-Type': 'application/json' }, }); + if (!response.ok) { throw new Error('Failed to fetch user associations'); } const apiResponses = await response.json(); - - // Convert each API response to UserPolicy and filter by country if specified - const policies = apiResponses.map((apiData: any) => UserPolicyAdapter.fromApiResponse(apiData)); - return countryId ? policies.filter((p: UserPolicy) => p.countryId === countryId) : policies; + return apiResponses.map((apiData: any) => UserPolicyAdapter.fromApiResponse(apiData)); } - async findById(userId: string, policyId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userId}/${policyId}`, { + async findById(userPolicyId: string): Promise { + const response = await fetch(`${this.BASE_URL}/${userPolicyId}`, { headers: { 'Content-Type': 'application/json' }, }); @@ -64,34 +66,40 @@ export class ApiPolicyStore implements UserPolicyStore { return UserPolicyAdapter.fromApiResponse(apiData); } - async update(_userPolicyId: string, _updates: Partial): Promise { - // TODO: Implement when backend API endpoint is available - // Expected endpoint: PUT /api/user-policy-associations/:userPolicyId - // Expected payload: UserPolicyUpdatePayload (to be created) + async update(userPolicyId: string, updates: Partial): Promise { + const payload = UserPolicyAdapter.toUpdatePayload(updates); - console.warn( - '[ApiPolicyStore.update] API endpoint not yet implemented. ' + - 'This method will be activated when user authentication is added.' - ); + const response = await fetch(`${this.BASE_URL}/${userPolicyId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (response.status === 404) { + throw new Error('User-policy association not found'); + } - throw new Error( - 'Policy updates via API are not yet supported. ' + - 'Please ensure you are using localStorage mode.' - ); + if (!response.ok) { + throw new Error('Failed to update policy association'); + } + + const apiData = await response.json(); + return UserPolicyAdapter.fromApiResponse(apiData); } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, policyId: string): Promise { - const response = await fetch(`/api/user-policy-associations/${userId}/${policyId}`, { + async delete(userPolicyId: string): Promise { + const response = await fetch(`${this.BASE_URL}/${userPolicyId}`, { method: 'DELETE', }); + if (response.status === 404) { + throw new Error('User-policy association not found'); + } + if (!response.ok) { throw new Error('Failed to delete association'); } } - */ } export class LocalStoragePolicyStore implements UserPolicyStore { @@ -125,9 +133,9 @@ export class LocalStoragePolicyStore implements UserPolicyStore { return policies.filter((p) => p.userId === userId && (!countryId || p.countryId === countryId)); } - async findById(userId: string, policyId: string): Promise { + async findById(userPolicyId: string): Promise { const policies = this.getStoredPolicies(); - return policies.find((p) => p.userId === userId && p.policyId === policyId) || null; + return policies.find((p) => p.id === userPolicyId) || null; } private getStoredPolicies(): UserPolicy[] { @@ -182,14 +190,15 @@ export class LocalStoragePolicyStore implements UserPolicyStore { return updated; } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, policyId: string): Promise { + async delete(userPolicyId: string): Promise { const policies = this.getStoredPolicies(); - const filtered = policies.filter( - a => !(a.userId === userId && a.policyId === policyId) - ); + const index = policies.findIndex((p) => p.id === userPolicyId); + + if (index === -1) { + throw new Error(`UserPolicy with id ${userPolicyId} not found`); + } + + const filtered = policies.filter((p) => p.id !== userPolicyId); this.setStoredPolicies(filtered); } - */ } diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index 841b2ebf9..732890500 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -33,14 +33,15 @@ export const usePolicyAssociationsByUser = (userId: string) => { }); }; -export const usePolicyAssociation = (userId: string, policyId: string) => { +export const usePolicyAssociation = (userPolicyId: string) => { const store = useUserPolicyStore(); const isLoggedIn = false; // TODO: Replace with actual auth check in future const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; return useQuery({ - queryKey: policyAssociationKeys.specific(userId, policyId), - queryFn: () => store.findById(userId, policyId), + queryKey: policyAssociationKeys.byId(userPolicyId), + queryFn: () => store.findById(userPolicyId), + enabled: !!userPolicyId, ...config, }); }; diff --git a/app/src/libs/queryKeys.ts b/app/src/libs/queryKeys.ts index b86e7cd8a..f0cede00b 100644 --- a/app/src/libs/queryKeys.ts +++ b/app/src/libs/queryKeys.ts @@ -1,5 +1,7 @@ export const policyAssociationKeys = { all: ['policy-associations'] as const, + byId: (userPolicyId: string) => + [...policyAssociationKeys.all, 'id', userPolicyId] as const, byUser: (userId: string, countryId?: string) => countryId ? ([...policyAssociationKeys.all, 'user_id', userId, 'country', countryId] as const) diff --git a/app/src/tests/fixtures/adapters/userAssociationMocks.ts b/app/src/tests/fixtures/adapters/userAssociationMocks.ts index 689ec08b9..550db54e8 100644 --- a/app/src/tests/fixtures/adapters/userAssociationMocks.ts +++ b/app/src/tests/fixtures/adapters/userAssociationMocks.ts @@ -3,7 +3,6 @@ import { UserReport } from '@/types/ingredients/UserReport'; import { UserSimulation } from '@/types/ingredients/UserSimulation'; import { UserPolicyMetadata } from '@/types/metadata/userPolicyMetadata'; import { - UserPolicyCreationPayload, UserReportCreationPayload, UserSimulationCreationPayload, } from '@/types/payloads'; @@ -19,7 +18,7 @@ import { // UserPolicy fixtures export const mockUserPolicyUS: UserPolicy = { - id: TEST_POLICY_IDS.POLICY_789, // UserPolicyAdapter uses policyId as the id + id: 'user-policy-123', // Association ID from backend userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, countryId: TEST_COUNTRIES.US, @@ -31,7 +30,7 @@ export const mockUserPolicyUS: UserPolicy = { export const mockUserPolicyUK: UserPolicy = { ...mockUserPolicyUS, - id: TEST_POLICY_IDS.POLICY_ABC, + id: 'user-policy-456', // Association ID from backend policyId: TEST_POLICY_IDS.POLICY_ABC, countryId: TEST_COUNTRIES.UK, }; @@ -43,15 +42,15 @@ export const mockUserPolicyWithoutOptionalFields: Omit { expect(result).toEqual(mockUserPolicyCreationPayload); }); - test('given UserPolicy without updatedAt then generates timestamp', () => { + test('given UserPolicy without label then creates payload without label', () => { // Given const userPolicy: Omit = { userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, isCreated: true, }; @@ -50,9 +49,7 @@ describe('UserPolicyAdapter', () => { expect(result.user_id).toBe(TEST_USER_IDS.USER_123); expect(result.policy_id).toBe(TEST_POLICY_IDS.POLICY_789); expect(result.country_id).toBe(TEST_COUNTRIES.US); - expect(result.label).toBe(TEST_LABELS.MY_POLICY); - expect(result.updated_at).toBeDefined(); - expect(new Date(result.updated_at as string).toISOString()).toBe(result.updated_at); + expect(result.label).toBeUndefined(); }); test('given UserPolicy with numeric IDs then converts to strings', () => { @@ -116,35 +113,40 @@ describe('UserPolicyAdapter', () => { expect(result).toEqual(mockUserPolicyUS); }); - test('given API response without optional fields then creates UserPolicy with defaults', () => { + test('given API response with null label then creates UserPolicy with undefined label', () => { // Given const apiData = { + id: 'user-policy-456', policy_id: TEST_POLICY_IDS.POLICY_789, user_id: TEST_USER_IDS.USER_123, country_id: TEST_COUNTRIES.US, + label: null, + created_at: TEST_TIMESTAMPS.CREATED_AT, + updated_at: TEST_TIMESTAMPS.UPDATED_AT, }; // When const result = UserPolicyAdapter.fromApiResponse(apiData); // Then - expect(result.id).toBe(TEST_POLICY_IDS.POLICY_789); + expect(result.id).toBe('user-policy-456'); expect(result.userId).toBe(TEST_USER_IDS.USER_123); expect(result.policyId).toBe(TEST_POLICY_IDS.POLICY_789); expect(result.countryId).toBe(TEST_COUNTRIES.US); expect(result.label).toBeUndefined(); - expect(result.createdAt).toBeUndefined(); - expect(result.updatedAt).toBeUndefined(); + expect(result.createdAt).toBe(TEST_TIMESTAMPS.CREATED_AT); + expect(result.updatedAt).toBe(TEST_TIMESTAMPS.UPDATED_AT); expect(result.isCreated).toBe(true); }); - test('given API response with null label then converts to undefined', () => { + test('given API response with string id then preserves id', () => { // Given const apiData = { + id: 'custom-association-id', policy_id: TEST_POLICY_IDS.POLICY_789, user_id: TEST_USER_IDS.USER_123, country_id: TEST_COUNTRIES.US, - label: null, + label: TEST_LABELS.MY_POLICY, created_at: TEST_TIMESTAMPS.CREATED_AT, updated_at: TEST_TIMESTAMPS.UPDATED_AT, }; @@ -153,7 +155,7 @@ describe('UserPolicyAdapter', () => { const result = UserPolicyAdapter.fromApiResponse(apiData); // Then - expect(result.label).toBeUndefined(); + expect(result.id).toBe('custom-association-id'); expect(result.countryId).toBe(TEST_COUNTRIES.US); }); diff --git a/app/src/tests/unit/api/policyAssociation.test.ts b/app/src/tests/unit/api/policyAssociation.test.ts index 532526bca..97c705b21 100644 --- a/app/src/tests/unit/api/policyAssociation.test.ts +++ b/app/src/tests/unit/api/policyAssociation.test.ts @@ -17,7 +17,7 @@ describe('ApiPolicyStore', () => { }; const mockApiResponse = { - id: 'policy-456', + id: 'user-policy-abc123', user_id: 'user-123', policy_id: 'policy-456', country_id: 'us', @@ -48,7 +48,7 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations', + '/user-policies/', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -67,6 +67,7 @@ describe('ApiPolicyStore', () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 500, + json: async () => ({}), }); // When/Then @@ -89,7 +90,7 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations/user/user-123', + '/user-policies/?user_id=user-123', expect.objectContaining({ headers: { 'Content-Type': 'application/json' }, }) @@ -118,7 +119,7 @@ describe('ApiPolicyStore', () => { }); describe('findById', () => { - it('given valid IDs then fetches specific association', async () => { + it('given valid userPolicyId then fetches specific association', async () => { // Given (global.fetch as any).mockResolvedValue({ ok: true, @@ -127,11 +128,11 @@ describe('ApiPolicyStore', () => { }); // When - const result = await store.findById('user-123', 'policy-456'); + const result = await store.findById('user-policy-abc123'); // Then expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations/user-123/policy-456', + '/user-policies/user-policy-abc123', expect.objectContaining({ headers: { 'Content-Type': 'application/json' }, }) @@ -152,7 +153,7 @@ describe('ApiPolicyStore', () => { }); // When - const result = await store.findById('user-123', 'nonexistent'); + const result = await store.findById('nonexistent-id'); // Then expect(result).toBeNull(); @@ -166,37 +167,84 @@ describe('ApiPolicyStore', () => { }); // When/Then - await expect(store.findById('user-123', 'policy-456')).rejects.toThrow( + await expect(store.findById('user-policy-abc123')).rejects.toThrow( 'Failed to fetch association' ); }); }); describe('update', () => { - it('given update called then throws not supported error', async () => { - // Given & When & Then - await expect(store.update('sup-abc123', { label: 'Updated Label' })).rejects.toThrow( - 'Please ensure you are using localStorage mode' + it('given valid update then sends PATCH request', async () => { + // Given + const updatedResponse = { + ...mockApiResponse, + label: 'Updated Label', + updated_at: '2025-01-02T00:00:00Z', + }; + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => updatedResponse, + }); + + // When + const result = await store.update('user-policy-abc123', { label: 'Updated Label' }); + + // Then + expect(fetch).toHaveBeenCalledWith( + '/user-policies/user-policy-abc123', + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + }) + ); + expect(result.label).toBe('Updated Label'); + }); + + it('given API error then throws error', async () => { + // Given + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 500, + }); + + // When/Then + await expect(store.update('user-policy-abc123', { label: 'Updated Label' })).rejects.toThrow( + 'Failed to update policy association' ); }); + }); - it('given update called then logs warning', async () => { + describe('delete', () => { + it('given valid userPolicyId then sends DELETE request', async () => { // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + (global.fetch as any).mockResolvedValue({ + ok: true, + status: 204, + }); // When - try { - await store.update('sup-abc123', { label: 'Updated Label' }); - } catch { - // Expected to throw - } + await store.delete('user-policy-abc123'); // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('API endpoint not yet implemented') + expect(fetch).toHaveBeenCalledWith( + '/user-policies/user-policy-abc123', + expect.objectContaining({ + method: 'DELETE', + }) ); + }); + + it('given API error then throws error', async () => { + // Given + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 500, + }); - consoleWarnSpy.mockRestore(); + // When/Then + await expect(store.delete('user-policy-abc123')).rejects.toThrow( + 'Failed to delete association' + ); }); }); }); @@ -305,12 +353,12 @@ describe('LocalStoragePolicyStore', () => { }); describe('findById', () => { - it('given existing policy then returns it', async () => { + it('given existing policy then returns it by userPolicyId', async () => { // Given - await store.create(mockPolicyInput1); + const created = await store.create(mockPolicyInput1); // When - const result = await store.findById('user-123', 'policy-456'); + const result = await store.findById(created.id!); // Then expect(result).toMatchObject({ @@ -319,9 +367,9 @@ describe('LocalStoragePolicyStore', () => { }); }); - it('given nonexistent policy then returns null', async () => { + it('given nonexistent userPolicyId then returns null', async () => { // When - const result = await store.findById('user-123', 'nonexistent'); + const result = await store.findById('sup-nonexistent'); // Then expect(result).toBeNull(); @@ -373,21 +421,21 @@ describe('LocalStoragePolicyStore', () => { await store.update(created.id!, { label: 'Updated Label' }); // Then - const persisted = await store.findById(mockPolicyInput1.userId, mockPolicyInput1.policyId); + const persisted = await store.findById(created.id!); expect(persisted?.label).toBe('Updated Label'); }); it('given multiple policies then updates correct one by ID', async () => { // Given const created1 = await store.create(mockPolicyInput1); - await store.create(mockPolicyInput2); + const created2 = await store.create(mockPolicyInput2); // When await store.update(created1.id!, { label: 'Updated Label' }); // Then - const updated = await store.findById(mockPolicyInput1.userId, mockPolicyInput1.policyId); - const unchanged = await store.findById(mockPolicyInput2.userId, mockPolicyInput2.policyId); + const updated = await store.findById(created1.id!); + const unchanged = await store.findById(created2.id!); expect(updated?.label).toBe('Updated Label'); expect(unchanged?.label).toBe(mockPolicyInput2.label); }); diff --git a/app/src/types/metadata/userPolicyMetadata.ts b/app/src/types/metadata/userPolicyMetadata.ts index ec0d1a9c3..f24765ca0 100644 --- a/app/src/types/metadata/userPolicyMetadata.ts +++ b/app/src/types/metadata/userPolicyMetadata.ts @@ -3,25 +3,35 @@ import { countryIds } from '@/libs/countries'; /** * API response format for user policy associations * Uses snake_case to match API conventions + * Matches backend UserPolicyRead schema */ export interface UserPolicyMetadata { + id: string; user_id: string; policy_id: string; country_id: (typeof countryIds)[number]; - label?: string | null; - created_at?: string; - updated_at?: string; + label: string | null; + created_at: string; + updated_at: string; } /** * API creation payload format for user policy associations * Uses snake_case to match API conventions + * Matches backend UserPolicyCreate schema */ export interface UserPolicyCreationMetadata { user_id: string; policy_id: string; country_id: (typeof countryIds)[number]; label?: string | null; - created_at?: string; - updated_at?: string; +} + +/** + * API update payload format for user policy associations + * Uses snake_case to match API conventions + * Matches backend UserPolicyUpdate schema + */ +export interface UserPolicyUpdateMetadata { + label?: string | null; } From 86d87b37266ddcd6a01f602736f308f5d10b6d63 Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Thu, 29 Jan 2026 06:36:54 -0800 Subject: [PATCH 02/11] fix: Remove country_id from UserPolicy, add vite API proxy support --- app/src/adapters/UserPolicyAdapter.ts | 2 - app/src/api/policyAssociation.ts | 14 ++++--- app/src/hooks/useCreatePolicy.ts | 1 - app/src/hooks/useUserPolicy.ts | 18 ++++---- app/src/libs/queryKeys.ts | 9 ++-- app/src/pages/Policies.page.tsx | 7 +--- .../fixtures/adapters/userAssociationMocks.ts | 10 +---- .../tests/fixtures/api/associationFixtures.ts | 1 - .../fixtures/hooks/useUserPolicyMocks.ts | 2 - .../fixtures/hooks/useUserReportsMocks.ts | 2 - app/src/tests/fixtures/pages/policiesMocks.ts | 2 - .../pages/report-output/PolicySubPage.ts | 2 - .../report-output/SocietyWideReportOutput.ts | 1 - .../fixtures/utils/policyColumnHeaders.ts | 2 - .../unit/adapters/UserPolicyAdapter.test.ts | 42 ------------------- .../tests/unit/api/policyAssociation.test.ts | 9 +--- app/src/types/ingredients/UserPolicy.ts | 3 -- app/src/types/metadata/userPolicyMetadata.ts | 4 -- app/vite.config.mjs | 11 ++++- 19 files changed, 32 insertions(+), 110 deletions(-) diff --git a/app/src/adapters/UserPolicyAdapter.ts b/app/src/adapters/UserPolicyAdapter.ts index 43ef1cb90..77e8cdb9f 100644 --- a/app/src/adapters/UserPolicyAdapter.ts +++ b/app/src/adapters/UserPolicyAdapter.ts @@ -19,7 +19,6 @@ export class UserPolicyAdapter { return { user_id: String(userPolicy.userId), policy_id: String(userPolicy.policyId), - country_id: userPolicy.countryId, label: userPolicy.label, }; } @@ -44,7 +43,6 @@ export class UserPolicyAdapter { id: String(apiData.id), userId: String(apiData.user_id), policyId: String(apiData.policy_id), - countryId: apiData.country_id, label: apiData.label ?? undefined, createdAt: apiData.created_at, updatedAt: apiData.updated_at, diff --git a/app/src/api/policyAssociation.ts b/app/src/api/policyAssociation.ts index 5204d0ce5..001d21974 100644 --- a/app/src/api/policyAssociation.ts +++ b/app/src/api/policyAssociation.ts @@ -4,7 +4,7 @@ import { UserPolicy } from '../types/ingredients/UserPolicy'; export interface UserPolicyStore { create: (policy: Omit) => Promise; - findByUser: (userId: string, countryId?: string) => Promise; + findByUser: (userId: string, taxBenefitModelId?: string) => Promise; findById: (userPolicyId: string) => Promise; update: (userPolicyId: string, updates: Partial) => Promise; delete: (userPolicyId: string) => Promise; @@ -31,10 +31,10 @@ export class ApiPolicyStore implements UserPolicyStore { return UserPolicyAdapter.fromApiResponse(apiResponse); } - async findByUser(userId: string, countryId?: string): Promise { + async findByUser(userId: string, taxBenefitModelId?: string): Promise { const params = new URLSearchParams({ user_id: userId }); - if (countryId) { - params.append('country_id', countryId); + if (taxBenefitModelId) { + params.append('tax_benefit_model_id', taxBenefitModelId); } const response = await fetch(`${this.BASE_URL}/?${params}`, { @@ -128,9 +128,11 @@ export class LocalStoragePolicyStore implements UserPolicyStore { return newPolicy; } - async findByUser(userId: string, countryId?: string): Promise { + async findByUser(userId: string, _taxBenefitModelId?: string): Promise { const policies = this.getStoredPolicies(); - return policies.filter((p) => p.userId === userId && (!countryId || p.countryId === countryId)); + // LocalStorage doesn't have tax_benefit_model context - return all user's policies + // The underlying Policy contains tax_benefit_model_id, which would require fetching policy data + return policies.filter((p) => p.userId === userId); } async findById(userPolicyId: string): Promise { diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index bedcd682e..8a449c711 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -24,7 +24,6 @@ export function useCreatePolicy(policyLabel?: string) { await createAssociation.mutateAsync({ userId, policyId: data.result.policy_id, // This is from the API response structure; may be modified in API v2 - countryId, label: policyLabel, isCreated: true, }); diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index 732890500..947b32518 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -54,11 +54,9 @@ export const useCreatePolicyAssociation = () => { mutationFn: (userPolicy: Omit) => store.create(userPolicy), onSuccess: (newAssociation) => { // Invalidate and refetch related queries + // Note: country/model filtering now happens via Policy.tax_benefit_model_id, not UserPolicy queryClient.invalidateQueries({ - queryKey: policyAssociationKeys.byUser( - newAssociation.userId.toString(), - newAssociation.countryId - ), + queryKey: policyAssociationKeys.byUser(newAssociation.userId.toString()), }); queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byPolicy(newAssociation.policyId.toString()), @@ -91,11 +89,9 @@ export const useUpdatePolicyAssociation = () => { onSuccess: (updatedAssociation) => { // Invalidate all related queries to trigger refetch + // Note: country/model filtering now happens via Policy.tax_benefit_model_id, not UserPolicy queryClient.invalidateQueries({ - queryKey: policyAssociationKeys.byUser( - updatedAssociation.userId, - updatedAssociation.countryId - ), + queryKey: policyAssociationKeys.byUser(updatedAssociation.userId), }); queryClient.invalidateQueries({ @@ -118,10 +114,10 @@ export const useDeleteAssociation = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ userId, policyId }: { userId: string; policyId: string; countryId?: string }) => + mutationFn: ({ userId, policyId }: { userId: string; policyId: string }) => store.delete(userId, policyId), - onSuccess: (_, { userId, policyId, countryId }) => { - queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byUser(userId, countryId) }); + onSuccess: (_, { userId, policyId }) => { + queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byUser(userId) }); queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byPolicy(policyId) }); queryClient.setQueryData( diff --git a/app/src/libs/queryKeys.ts b/app/src/libs/queryKeys.ts index f0cede00b..799bab8fc 100644 --- a/app/src/libs/queryKeys.ts +++ b/app/src/libs/queryKeys.ts @@ -1,10 +1,9 @@ export const policyAssociationKeys = { all: ['policy-associations'] as const, - byId: (userPolicyId: string) => - [...policyAssociationKeys.all, 'id', userPolicyId] as const, - byUser: (userId: string, countryId?: string) => - countryId - ? ([...policyAssociationKeys.all, 'user_id', userId, 'country', countryId] as const) + byId: (userPolicyId: string) => [...policyAssociationKeys.all, 'id', userPolicyId] as const, + byUser: (userId: string, taxBenefitModelId?: string) => + taxBenefitModelId + ? ([...policyAssociationKeys.all, 'user_id', userId, 'model', taxBenefitModelId] as const) : ([...policyAssociationKeys.all, 'user_id', userId] as const), byPolicy: (policyId: string) => [...policyAssociationKeys.all, 'policy_id', policyId] as const, specific: (userId: string, policyId: string) => diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx index 4e37fda92..81ef71cb4 100644 --- a/app/src/pages/Policies.page.tsx +++ b/app/src/pages/Policies.page.tsx @@ -116,12 +116,7 @@ export default function PoliciesPage() { } as TextValue, dateCreated: { text: item.association.createdAt - ? formatDate( - item.association.createdAt, - 'short-month-day-year', - item.association.countryId, - true - ) + ? formatDate(item.association.createdAt, 'short-month-day-year', countryId, true) : '', } as TextValue, provisions: { diff --git a/app/src/tests/fixtures/adapters/userAssociationMocks.ts b/app/src/tests/fixtures/adapters/userAssociationMocks.ts index 550db54e8..b68c1ca24 100644 --- a/app/src/tests/fixtures/adapters/userAssociationMocks.ts +++ b/app/src/tests/fixtures/adapters/userAssociationMocks.ts @@ -2,10 +2,7 @@ import { UserPolicy } from '@/types/ingredients/UserPolicy'; import { UserReport } from '@/types/ingredients/UserReport'; import { UserSimulation } from '@/types/ingredients/UserSimulation'; import { UserPolicyMetadata } from '@/types/metadata/userPolicyMetadata'; -import { - UserReportCreationPayload, - UserSimulationCreationPayload, -} from '@/types/payloads'; +import { UserReportCreationPayload, UserSimulationCreationPayload } from '@/types/payloads'; import { TEST_COUNTRIES, TEST_LABELS, @@ -21,7 +18,6 @@ export const mockUserPolicyUS: UserPolicy = { id: 'user-policy-123', // Association ID from backend userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, label: TEST_LABELS.MY_POLICY, createdAt: TEST_TIMESTAMPS.CREATED_AT, updatedAt: TEST_TIMESTAMPS.UPDATED_AT, @@ -32,20 +28,17 @@ export const mockUserPolicyUK: UserPolicy = { ...mockUserPolicyUS, id: 'user-policy-456', // Association ID from backend policyId: TEST_POLICY_IDS.POLICY_ABC, - countryId: TEST_COUNTRIES.UK, }; export const mockUserPolicyWithoutOptionalFields: Omit = { userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, isCreated: true, }; export const mockUserPolicyCreationPayload = { user_id: TEST_USER_IDS.USER_123, policy_id: TEST_POLICY_IDS.POLICY_789, - country_id: TEST_COUNTRIES.US, label: TEST_LABELS.MY_POLICY, }; @@ -53,7 +46,6 @@ export const mockUserPolicyApiResponse: UserPolicyMetadata = { id: 'user-policy-123', policy_id: TEST_POLICY_IDS.POLICY_789, user_id: TEST_USER_IDS.USER_123, - country_id: TEST_COUNTRIES.US, label: TEST_LABELS.MY_POLICY, created_at: TEST_TIMESTAMPS.CREATED_AT, updated_at: TEST_TIMESTAMPS.UPDATED_AT, diff --git a/app/src/tests/fixtures/api/associationFixtures.ts b/app/src/tests/fixtures/api/associationFixtures.ts index b8d83722f..09af20a63 100644 --- a/app/src/tests/fixtures/api/associationFixtures.ts +++ b/app/src/tests/fixtures/api/associationFixtures.ts @@ -78,7 +78,6 @@ export const createMockPolicyAssociation = (overrides?: Partial): Us id: TEST_IDS.USER_POLICY_ID, userId: TEST_IDS.USER_ID, policyId: TEST_IDS.POLICY_ID, - countryId: TEST_COUNTRIES.US, label: TEST_LABELS.POLICY, createdAt: TEST_TIMESTAMPS.CREATED_AT, isCreated: true, diff --git a/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts b/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts index 85062c918..2f9100062 100644 --- a/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts @@ -18,7 +18,6 @@ export const mockUserPolicyAssociation1: UserPolicy = { id: 'assoc-1', userId: TEST_USER_ID, policyId: TEST_POLICY_ID_1, - countryId: TEST_COUNTRY_ID, label: 'Test Policy 1', createdAt: '2024-01-15T10:00:00Z', isCreated: true, @@ -28,7 +27,6 @@ export const mockUserPolicyAssociation2: UserPolicy = { id: 'assoc-2', userId: TEST_USER_ID, policyId: TEST_POLICY_ID_2, - countryId: TEST_COUNTRY_ID, label: 'Test Policy 2', createdAt: '2024-02-20T14:30:00Z', isCreated: true, diff --git a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts index cbe5516f3..b8de53625 100644 --- a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts @@ -106,7 +106,6 @@ export const mockUserPolicies: UserPolicy[] = [ policyId: TEST_POLICY_ID_1, label: 'My Policy 1', createdAt: '2025-01-01T09:00:00Z', - countryId: 'us', }, { id: 'user-pol-2', @@ -114,7 +113,6 @@ export const mockUserPolicies: UserPolicy[] = [ policyId: TEST_POLICY_ID_2, label: 'My Policy 2', createdAt: '2025-01-02T09:00:00Z', - countryId: 'us', }, ]; diff --git a/app/src/tests/fixtures/pages/policiesMocks.ts b/app/src/tests/fixtures/pages/policiesMocks.ts index db81f5555..4519ed63e 100644 --- a/app/src/tests/fixtures/pages/policiesMocks.ts +++ b/app/src/tests/fixtures/pages/policiesMocks.ts @@ -31,7 +31,6 @@ export const mockPolicyData: UserPolicyWithAssociation[] = [ policyId: '101', label: 'Test Policy 1', createdAt: '2024-01-15T10:00:00Z', - countryId: 'us', }, policy: { id: '101', @@ -62,7 +61,6 @@ export const mockPolicyData: UserPolicyWithAssociation[] = [ policyId: '102', label: 'Test Policy 2', createdAt: '2024-02-20T14:30:00Z', - countryId: 'us', }, policy: { id: '102', diff --git a/app/src/tests/fixtures/pages/report-output/PolicySubPage.ts b/app/src/tests/fixtures/pages/report-output/PolicySubPage.ts index 3746215cc..c88f782a2 100644 --- a/app/src/tests/fixtures/pages/report-output/PolicySubPage.ts +++ b/app/src/tests/fixtures/pages/report-output/PolicySubPage.ts @@ -153,7 +153,6 @@ export const mockUserBaselinePolicy: UserPolicy = { id: 'user-pol-baseline-123', userId: TEST_USER_ID, policyId: TEST_POLICY_IDS.BASELINE, - countryId: 'us', label: 'My Baseline Policy', createdAt: '2025-01-15T10:00:00Z', }; @@ -162,7 +161,6 @@ export const mockUserReformPolicy: UserPolicy = { id: 'user-pol-reform-456', userId: TEST_USER_ID, policyId: TEST_POLICY_IDS.REFORM, - countryId: 'us', label: 'My Reform Policy', createdAt: '2025-01-15T11:00:00Z', }; diff --git a/app/src/tests/fixtures/pages/report-output/SocietyWideReportOutput.ts b/app/src/tests/fixtures/pages/report-output/SocietyWideReportOutput.ts index b11873f94..3a96167ed 100644 --- a/app/src/tests/fixtures/pages/report-output/SocietyWideReportOutput.ts +++ b/app/src/tests/fixtures/pages/report-output/SocietyWideReportOutput.ts @@ -72,7 +72,6 @@ export const MOCK_USER_POLICY: UserPolicy = { userId: 'user-123', policyId: 'policy-1', label: 'My Policy', - countryId: 'us', createdAt: '2024-01-01T00:00:00Z', }; diff --git a/app/src/tests/fixtures/utils/policyColumnHeaders.ts b/app/src/tests/fixtures/utils/policyColumnHeaders.ts index 5c28c8805..31fe8a7a4 100644 --- a/app/src/tests/fixtures/utils/policyColumnHeaders.ts +++ b/app/src/tests/fixtures/utils/policyColumnHeaders.ts @@ -23,7 +23,6 @@ export const MOCK_USER_POLICY_BASELINE: UserPolicy = { userId: 'user-123', policyId: 'policy-1', label: 'My Baseline', - countryId: 'us', createdAt: '2024-01-01T00:00:00Z', }; @@ -32,7 +31,6 @@ export const MOCK_USER_POLICY_REFORM: UserPolicy = { userId: 'user-123', policyId: 'policy-2', label: 'My Reform', - countryId: 'us', createdAt: '2024-01-01T00:00:00Z', }; diff --git a/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts b/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts index 05c6cc6db..619768258 100644 --- a/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts +++ b/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts @@ -5,7 +5,6 @@ import { mockUserPolicyCreationPayload, mockUserPolicyUS, mockUserPolicyWithoutOptionalFields, - TEST_COUNTRIES, TEST_LABELS, TEST_POLICY_IDS, TEST_TIMESTAMPS, @@ -20,7 +19,6 @@ describe('UserPolicyAdapter', () => { const userPolicy: Omit = { userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, label: TEST_LABELS.MY_POLICY, updatedAt: TEST_TIMESTAMPS.UPDATED_AT, isCreated: true, @@ -38,7 +36,6 @@ describe('UserPolicyAdapter', () => { const userPolicy: Omit = { userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, isCreated: true, }; @@ -48,7 +45,6 @@ describe('UserPolicyAdapter', () => { // Then expect(result.user_id).toBe(TEST_USER_IDS.USER_123); expect(result.policy_id).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.country_id).toBe(TEST_COUNTRIES.US); expect(result.label).toBeUndefined(); }); @@ -57,7 +53,6 @@ describe('UserPolicyAdapter', () => { const userPolicy: Omit = { userId: 123 as any, policyId: 456 as any, - countryId: TEST_COUNTRIES.US, label: TEST_LABELS.MY_POLICY, isCreated: true, }; @@ -68,7 +63,6 @@ describe('UserPolicyAdapter', () => { // Then expect(result.user_id).toBe('123'); expect(result.policy_id).toBe('456'); - expect(result.country_id).toBe(TEST_COUNTRIES.US); }); test('given UserPolicy without label then includes undefined label', () => { @@ -80,24 +74,6 @@ describe('UserPolicyAdapter', () => { // Then expect(result.label).toBeUndefined(); - expect(result.country_id).toBe(TEST_COUNTRIES.US); - }); - - test('given UserPolicy with UK country then preserves country ID', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_ABC, - countryId: TEST_COUNTRIES.UK, - label: TEST_LABELS.MY_POLICY, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.country_id).toBe(TEST_COUNTRIES.UK); }); }); @@ -119,7 +95,6 @@ describe('UserPolicyAdapter', () => { id: 'user-policy-456', policy_id: TEST_POLICY_IDS.POLICY_789, user_id: TEST_USER_IDS.USER_123, - country_id: TEST_COUNTRIES.US, label: null, created_at: TEST_TIMESTAMPS.CREATED_AT, updated_at: TEST_TIMESTAMPS.UPDATED_AT, @@ -132,7 +107,6 @@ describe('UserPolicyAdapter', () => { expect(result.id).toBe('user-policy-456'); expect(result.userId).toBe(TEST_USER_IDS.USER_123); expect(result.policyId).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.countryId).toBe(TEST_COUNTRIES.US); expect(result.label).toBeUndefined(); expect(result.createdAt).toBe(TEST_TIMESTAMPS.CREATED_AT); expect(result.updatedAt).toBe(TEST_TIMESTAMPS.UPDATED_AT); @@ -145,7 +119,6 @@ describe('UserPolicyAdapter', () => { id: 'custom-association-id', policy_id: TEST_POLICY_IDS.POLICY_789, user_id: TEST_USER_IDS.USER_123, - country_id: TEST_COUNTRIES.US, label: TEST_LABELS.MY_POLICY, created_at: TEST_TIMESTAMPS.CREATED_AT, updated_at: TEST_TIMESTAMPS.UPDATED_AT, @@ -156,21 +129,6 @@ describe('UserPolicyAdapter', () => { // Then expect(result.id).toBe('custom-association-id'); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - }); - - test('given API response with UK country then preserves country ID', () => { - // Given - const apiData = { - ...mockUserPolicyApiResponse, - country_id: TEST_COUNTRIES.UK, - }; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result.countryId).toBe(TEST_COUNTRIES.UK); }); }); }); diff --git a/app/src/tests/unit/api/policyAssociation.test.ts b/app/src/tests/unit/api/policyAssociation.test.ts index 97c705b21..80b83325c 100644 --- a/app/src/tests/unit/api/policyAssociation.test.ts +++ b/app/src/tests/unit/api/policyAssociation.test.ts @@ -11,7 +11,6 @@ describe('ApiPolicyStore', () => { const mockPolicyInput: Omit = { userId: 'user-123', policyId: 'policy-456', - countryId: 'us', label: 'Test Policy', isCreated: true, }; @@ -20,7 +19,6 @@ describe('ApiPolicyStore', () => { id: 'user-policy-abc123', user_id: 'user-123', policy_id: 'policy-456', - country_id: 'us', label: 'Test Policy', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', @@ -57,7 +55,6 @@ describe('ApiPolicyStore', () => { expect(result).toMatchObject({ userId: 'user-123', policyId: 'policy-456', - countryId: 'us', label: 'Test Policy', }); }); @@ -99,7 +96,6 @@ describe('ApiPolicyStore', () => { expect(result[0]).toMatchObject({ userId: 'user-123', policyId: 'policy-456', - countryId: 'us', label: 'Test Policy', }); }); @@ -140,7 +136,6 @@ describe('ApiPolicyStore', () => { expect(result).toMatchObject({ userId: 'user-123', policyId: 'policy-456', - countryId: 'us', label: 'Test Policy', }); }); @@ -256,7 +251,6 @@ describe('LocalStoragePolicyStore', () => { const mockPolicyInput1: Omit = { userId: 'user-123', policyId: 'policy-456', - countryId: 'us', label: 'Test Policy 1', isCreated: true, }; @@ -264,7 +258,6 @@ describe('LocalStoragePolicyStore', () => { const mockPolicyInput2: Omit = { userId: 'user-123', policyId: 'policy-789', - countryId: 'us', label: 'Test Policy 2', isCreated: true, }; @@ -449,7 +442,7 @@ describe('LocalStoragePolicyStore', () => { // Then expect(result.label).toBe('Updated Label'); - expect(result.countryId).toBe(mockPolicyInput1.countryId); // unchanged + expect(result.policyId).toBe(mockPolicyInput1.policyId); // unchanged }); }); diff --git a/app/src/types/ingredients/UserPolicy.ts b/app/src/types/ingredients/UserPolicy.ts index a3ef74192..91052f90b 100644 --- a/app/src/types/ingredients/UserPolicy.ts +++ b/app/src/types/ingredients/UserPolicy.ts @@ -1,5 +1,3 @@ -import { countryIds } from '@/libs/countries'; - /** * UserPolicy type containing mutable user-specific data */ @@ -7,7 +5,6 @@ export interface UserPolicy { id?: string; userId: string; policyId: string; - countryId: (typeof countryIds)[number]; label?: string; createdAt?: string; updatedAt?: string; diff --git a/app/src/types/metadata/userPolicyMetadata.ts b/app/src/types/metadata/userPolicyMetadata.ts index f24765ca0..c7c0af011 100644 --- a/app/src/types/metadata/userPolicyMetadata.ts +++ b/app/src/types/metadata/userPolicyMetadata.ts @@ -1,5 +1,3 @@ -import { countryIds } from '@/libs/countries'; - /** * API response format for user policy associations * Uses snake_case to match API conventions @@ -9,7 +7,6 @@ export interface UserPolicyMetadata { id: string; user_id: string; policy_id: string; - country_id: (typeof countryIds)[number]; label: string | null; created_at: string; updated_at: string; @@ -23,7 +20,6 @@ export interface UserPolicyMetadata { export interface UserPolicyCreationMetadata { user_id: string; policy_id: string; - country_id: (typeof countryIds)[number]; label?: string | null; } diff --git a/app/vite.config.mjs b/app/vite.config.mjs index 74a38345d..de948c683 100644 --- a/app/vite.config.mjs +++ b/app/vite.config.mjs @@ -50,7 +50,9 @@ function spaFallbackPlugin() { const isStaticAsset = (url) => url.includes('.'); const isViteInternal = (url) => url.startsWith('/@'); const isNodeModule = (url) => url.startsWith('/node_modules'); - const isSpaRoute = (url) => !isStaticAsset(url) && !isViteInternal(url) && !isNodeModule(url); + const isApiRoute = (url) => url.startsWith('/user-policies'); + const isSpaRoute = (url) => + !isStaticAsset(url) && !isViteInternal(url) && !isNodeModule(url) && !isApiRoute(url); const middleware = (req, res, next) => { if (req.url && isSpaRoute(req.url)) { @@ -88,6 +90,13 @@ export default defineConfig({ // Use discovered ports in dev, defaults otherwise port: appMode === 'calculator' ? (calculatorPort ?? 3001) : (websitePort ?? 3000), strictPort: true, + // Proxy API v2 endpoints to local backend during development + proxy: { + '/user-policies': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, }, define: viteDefines, // Use separate cache directories for website and calculator to avoid conflicts From a74b83d1598c963c51d2d9565180eb886dab55a3 Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Thu, 12 Feb 2026 20:58:33 +0530 Subject: [PATCH 03/11] feat: Add client-generated UUID for anonymous users --- app/src/hooks/useCreatePolicy.ts | 47 ++++- app/src/hooks/useUserId.ts | 24 +++ app/src/libs/userIdentity.ts | 69 +++++++ app/src/pages/Policies.page.tsx | 4 +- .../views/policy/PolicyExistingView.tsx | 4 +- app/src/tests/unit/libs/userIdentity.test.ts | 175 ++++++++++++++++++ 6 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 app/src/hooks/useUserId.ts create mode 100644 app/src/libs/userIdentity.ts create mode 100644 app/src/tests/unit/libs/userIdentity.test.ts diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index 8a449c711..e2f7c92d1 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -1,29 +1,55 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createPolicy } from '@/api/policy'; -import { MOCK_USER_ID } from '@/constants'; +import { useSelector } from 'react-redux'; +import { PolicyAdapter } from '@/adapters'; +import { createPolicy, V2PolicyCreatePayload } from '@/api/policy'; import { policyKeys } from '@/libs/queryKeys'; -import { PolicyCreationPayload } from '@/types/payloads'; +import { RootState } from '@/store'; +import { Policy } from '@/types/ingredients/Policy'; import { useCurrentCountry } from './useCurrentCountry'; +import { useTaxBenefitModelId } from './useTaxBenefitModel'; +import { useUserId } from './useUserId'; import { useCreatePolicyAssociation } from './useUserPolicy'; +interface CreatePolicyInput { + policy: Policy; + name?: string; + description?: string; +} + export function useCreatePolicy(policyLabel?: string) { const queryClient = useQueryClient(); const countryId = useCurrentCountry(); - // const user = MOCK_USER_ID; // TODO: Replace with actual user context or auth hook in future + const { taxBenefitModelId, isLoading: isModelLoading } = useTaxBenefitModelId(countryId); + const parametersMetadata = useSelector((state: RootState) => state.metadata.parameters); const createAssociation = useCreatePolicyAssociation(); + const userId = useUserId(); const mutation = useMutation({ - mutationFn: (data: PolicyCreationPayload) => createPolicy(countryId, data), + mutationFn: async (input: CreatePolicyInput): Promise<{ id: string }> => { + if (!taxBenefitModelId) { + throw new Error('Tax benefit model ID not available'); + } + + // Convert policy to v2 payload using adapter + const payload: V2PolicyCreatePayload = PolicyAdapter.toV2CreationPayload( + input.policy, + parametersMetadata, + taxBenefitModelId, + input.name || policyLabel, + input.description + ); + + const response = await createPolicy(payload); + return { id: response.id }; + }, onSuccess: async (data) => { try { queryClient.invalidateQueries({ queryKey: policyKeys.all }); - // Create association with current user (or anonymous for session storage) - const userId = MOCK_USER_ID; // TODO: Replace with actual user ID retrieval logic and add conditional logic to access user ID - + // Create association with current user (localStorage-persisted UUID) await createAssociation.mutateAsync({ userId, - policyId: data.result.policy_id, // This is from the API response structure; may be modified in API v2 + policyId: data.id, label: policyLabel, isCreated: true, }); @@ -35,7 +61,8 @@ export function useCreatePolicy(policyLabel?: string) { return { createPolicy: mutation.mutateAsync, - isPending: mutation.isPending, + isPending: mutation.isPending || isModelLoading, error: mutation.error, + isModelReady: !!taxBenefitModelId, }; } diff --git a/app/src/hooks/useUserId.ts b/app/src/hooks/useUserId.ts new file mode 100644 index 000000000..2e222eb47 --- /dev/null +++ b/app/src/hooks/useUserId.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; + +import { getUserId } from '@/libs/userIdentity'; + +/** + * React hook that provides the current user's persistent ID. + * + * The ID is stable across renders and sessions - it's stored in localStorage + * and only generated once per browser. + * + * @returns The user's unique identifier + * + * @example + * ```tsx + * function MyComponent() { + * const userId = useUserId(); + * const { data } = useUserHouseholds(userId); + * // ... + * } + * ``` + */ +export function useUserId(): string { + return useMemo(() => getUserId(), []); +} diff --git a/app/src/libs/userIdentity.ts b/app/src/libs/userIdentity.ts new file mode 100644 index 000000000..bf6a8fb82 --- /dev/null +++ b/app/src/libs/userIdentity.ts @@ -0,0 +1,69 @@ +/** + * User Identity Module + * + * Manages persistent anonymous user IDs stored in localStorage. + * This ID is used to associate user-created records (households, policies, + * simulations, reports, geographies) with the user across sessions. + */ + +const USER_ID_STORAGE_KEY = 'policyengine_user_id'; +const MIGRATION_COMPLETE_KEY = 'policyengine_migration_v2_complete'; + +/** + * Gets the current user's ID, creating one if it doesn't exist. + * The ID is a UUID stored in localStorage for persistence across sessions. + * + * @returns The user's unique identifier + */ +export function getUserId(): string { + let userId = localStorage.getItem(USER_ID_STORAGE_KEY); + if (!userId) { + userId = crypto.randomUUID(); + localStorage.setItem(USER_ID_STORAGE_KEY, userId); + } + return userId; +} + +/** + * Clears the user's ID from localStorage. + * This will cause a new ID to be generated on the next call to getUserId(). + * + * Use with caution - this will effectively create a "new user" who won't + * have access to their previously created records. + */ +export function clearUserId(): void { + localStorage.removeItem(USER_ID_STORAGE_KEY); +} + +/** + * Checks if the v2 migration has been completed. + * + * @returns true if migration is complete, false otherwise + */ +export function isMigrationComplete(): boolean { + return localStorage.getItem(MIGRATION_COMPLETE_KEY) === 'true'; +} + +/** + * Marks the v2 migration as complete. + * This prevents the migration from running again on future page loads. + */ +export function markMigrationComplete(): void { + localStorage.setItem(MIGRATION_COMPLETE_KEY, 'true'); +} + +/** + * Clears the migration complete flag. + * This will cause the migration to run again on the next page load. + * + * Use for testing or if migration needs to be re-run. + */ +export function clearMigrationFlag(): void { + localStorage.removeItem(MIGRATION_COMPLETE_KEY); +} + +// Export storage keys for testing purposes +export const STORAGE_KEYS = { + USER_ID: USER_ID_STORAGE_KEY, + MIGRATION_COMPLETE: MIGRATION_COMPLETE_KEY, +} as const; diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx index 81ef71cb4..5d80e070d 100644 --- a/app/src/pages/Policies.page.tsx +++ b/app/src/pages/Policies.page.tsx @@ -5,14 +5,14 @@ import { useDisclosure } from '@mantine/hooks'; import { ColumnConfig, IngredientRecord, TextValue } from '@/components/columns'; import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; import IngredientReadView from '@/components/IngredientReadView'; -import { MOCK_USER_ID } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useUserId } from '@/hooks/useUserId'; import { useUpdatePolicyAssociation, useUserPolicies } from '@/hooks/useUserPolicy'; import { countPolicyModifications } from '@/utils/countParameterChanges'; import { formatDate } from '@/utils/dateUtils'; export default function PoliciesPage() { - const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic + const userId = useUserId(); const { data, isLoading, isError, error } = useUserPolicies(userId); const navigate = useNavigate(); const countryId = useCurrentCountry(); diff --git a/app/src/pathways/report/views/policy/PolicyExistingView.tsx b/app/src/pathways/report/views/policy/PolicyExistingView.tsx index 34b42de82..06325d02d 100644 --- a/app/src/pathways/report/views/policy/PolicyExistingView.tsx +++ b/app/src/pathways/report/views/policy/PolicyExistingView.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; import { Text } from '@mantine/core'; import PathwayView from '@/components/common/PathwayView'; -import { MOCK_USER_ID } from '@/constants'; +import { useUserId } from '@/hooks/useUserId'; import { isPolicyWithAssociation, UserPolicyWithAssociation, @@ -26,7 +26,7 @@ export default function PolicyExistingView({ onBack, onCancel, }: PolicyExistingViewProps) { - const userId = MOCK_USER_ID.toString(); + const userId = useUserId(); const { data, isLoading, isError, error } = useUserPolicies(userId); const [localPolicy, setLocalPolicy] = useState(null); diff --git a/app/src/tests/unit/libs/userIdentity.test.ts b/app/src/tests/unit/libs/userIdentity.test.ts new file mode 100644 index 000000000..7e1dc4535 --- /dev/null +++ b/app/src/tests/unit/libs/userIdentity.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + clearMigrationFlag, + clearUserId, + getUserId, + isMigrationComplete, + markMigrationComplete, + STORAGE_KEYS, +} from '@/libs/userIdentity'; + +describe('userIdentity', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + + // Mock crypto.randomUUID + vi.spyOn(crypto, 'randomUUID').mockReturnValue('test-uuid-1234-5678-90ab-cdef12345678'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + describe('getUserId', () => { + test('given no existing user ID then generates and stores new UUID', () => { + // When + const userId = getUserId(); + + // Then + expect(userId).toBe('test-uuid-1234-5678-90ab-cdef12345678'); + expect(localStorage.getItem(STORAGE_KEYS.USER_ID)).toBe( + 'test-uuid-1234-5678-90ab-cdef12345678' + ); + expect(crypto.randomUUID).toHaveBeenCalled(); + }); + + test('given existing user ID then returns stored ID without generating new one', () => { + // Given + localStorage.setItem(STORAGE_KEYS.USER_ID, 'existing-user-id-12345'); + + // When + const userId = getUserId(); + + // Then + expect(userId).toBe('existing-user-id-12345'); + expect(crypto.randomUUID).not.toHaveBeenCalled(); + }); + + test('given multiple calls then returns same user ID', () => { + // When + const userId1 = getUserId(); + const userId2 = getUserId(); + const userId3 = getUserId(); + + // Then + expect(userId1).toBe(userId2); + expect(userId2).toBe(userId3); + expect(crypto.randomUUID).toHaveBeenCalledTimes(1); + }); + }); + + describe('clearUserId', () => { + test('given user ID exists then removes it from localStorage', () => { + // Given + localStorage.setItem(STORAGE_KEYS.USER_ID, 'existing-user-id'); + + // When + clearUserId(); + + // Then + expect(localStorage.getItem(STORAGE_KEYS.USER_ID)).toBeNull(); + }); + + test('given user ID cleared then next getUserId generates new ID', () => { + // Given + localStorage.setItem(STORAGE_KEYS.USER_ID, 'old-user-id'); + + // When + clearUserId(); + const newUserId = getUserId(); + + // Then + expect(newUserId).toBe('test-uuid-1234-5678-90ab-cdef12345678'); + expect(newUserId).not.toBe('old-user-id'); + }); + }); + + describe('isMigrationComplete', () => { + test('given no migration flag then returns false', () => { + // When + const result = isMigrationComplete(); + + // Then + expect(result).toBe(false); + }); + + test('given migration flag set to true then returns true', () => { + // Given + localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'true'); + + // When + const result = isMigrationComplete(); + + // Then + expect(result).toBe(true); + }); + + test('given migration flag set to other value then returns false', () => { + // Given + localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'false'); + + // When + const result = isMigrationComplete(); + + // Then + expect(result).toBe(false); + }); + }); + + describe('markMigrationComplete', () => { + test('given migration not complete then sets flag to true', () => { + // When + markMigrationComplete(); + + // Then + expect(localStorage.getItem(STORAGE_KEYS.MIGRATION_COMPLETE)).toBe('true'); + }); + + test('given migration already complete then flag remains true', () => { + // Given + localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'true'); + + // When + markMigrationComplete(); + + // Then + expect(localStorage.getItem(STORAGE_KEYS.MIGRATION_COMPLETE)).toBe('true'); + }); + }); + + describe('clearMigrationFlag', () => { + test('given migration flag exists then removes it', () => { + // Given + localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'true'); + + // When + clearMigrationFlag(); + + // Then + expect(localStorage.getItem(STORAGE_KEYS.MIGRATION_COMPLETE)).toBeNull(); + }); + + test('given migration flag cleared then isMigrationComplete returns false', () => { + // Given + localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'true'); + + // When + clearMigrationFlag(); + const result = isMigrationComplete(); + + // Then + expect(result).toBe(false); + }); + }); + + describe('STORAGE_KEYS', () => { + test('given STORAGE_KEYS then contains expected key names', () => { + // Then + expect(STORAGE_KEYS.USER_ID).toBe('policyengine_user_id'); + expect(STORAGE_KEYS.MIGRATION_COMPLETE).toBe('policyengine_migration_v2_complete'); + }); + }); +}); From e957272b0b81e82895c205d188cee722d05f8ae9 Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Thu, 12 Feb 2026 21:00:58 +0530 Subject: [PATCH 04/11] refactor: Use hardcoded isLoggedIn=false like Anthony's approach --- app/src/hooks/useUserPolicy.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index 947b32518..52c61d81b 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -3,6 +3,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea import { PolicyAdapter } from '@/adapters'; import { fetchPolicyById } from '@/api/policy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useTaxBenefitModelId } from '@/hooks/useTaxBenefitModel'; import { Policy } from '@/types/ingredients/Policy'; import { ApiPolicyStore, LocalStoragePolicyStore } from '../api/policyAssociation'; import { queryConfig } from '../libs/queryConfig'; @@ -22,13 +23,15 @@ export const useUserPolicyStore = () => { export const usePolicyAssociationsByUser = (userId: string) => { const store = useUserPolicyStore(); const countryId = useCurrentCountry(); + const { taxBenefitModelId, isLoading: modelLoading } = useTaxBenefitModelId(countryId); const isLoggedIn = false; // TODO: Replace with actual auth check in future // TODO: Should we determine user ID from auth context here? Or pass as arg? const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; return useQuery({ - queryKey: policyAssociationKeys.byUser(userId, countryId), - queryFn: () => store.findByUser(userId, countryId), + queryKey: policyAssociationKeys.byUser(userId, taxBenefitModelId ?? undefined), + queryFn: () => store.findByUser(userId, taxBenefitModelId ?? undefined), + enabled: !modelLoading && !!taxBenefitModelId, ...config, }); }; @@ -174,8 +177,8 @@ export const useUserPolicies = (userId: string) => { queryKey: policyKeys.byId(policyId.toString()), queryFn: async () => { try { - const metadata = await fetchPolicyById(country, policyId.toString()); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(policyId.toString()); + return PolicyAdapter.fromV2Response(response); } catch (error) { // Add context to help debug which policy failed const message = From c45a49f069d18c222fa066d40a366afea31b3780 Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Thu, 12 Feb 2026 21:26:56 +0530 Subject: [PATCH 05/11] feat: Migrate policy CRUD to v2 API endpoints --- app/src/adapters/PolicyAdapter.ts | 108 +++++++++++++++++- app/src/api/policy.ts | 80 ++++++++++--- app/src/api/v2/taxBenefitModels.ts | 3 +- app/src/constants.ts | 3 +- app/src/hooks/usePolicy.ts | 2 +- app/src/hooks/useSaveSharedReport.ts | 1 - app/src/hooks/useTaxBenefitModel.ts | 25 ++++ app/src/hooks/useUserReports.ts | 8 +- app/src/hooks/useUserSimulations.ts | 8 +- .../hooks/utils/useFetchReportIngredients.ts | 4 +- .../report/views/policy/PolicySubmitView.tsx | 24 ++-- app/src/tests/fixtures/api/policyMocks.ts | 35 ++++++ .../hooks/useFetchReportIngredientsMocks.ts | 6 +- .../hooks/useSaveSharedReportMocks.ts | 6 +- .../hooks/useSharedReportDataMocks.tsx | 4 +- .../fixtures/hooks/useUserReportsMocks.ts | 26 +++++ .../tests/fixtures/utils/shareUtilsMocks.ts | 7 +- app/src/tests/unit/api/policy.test.ts | 47 ++++---- .../tests/unit/hooks/useUserReports.test.tsx | 16 +-- 19 files changed, 329 insertions(+), 84 deletions(-) create mode 100644 app/src/hooks/useTaxBenefitModel.ts diff --git a/app/src/adapters/PolicyAdapter.ts b/app/src/adapters/PolicyAdapter.ts index 951274da2..d4eeb68b5 100644 --- a/app/src/adapters/PolicyAdapter.ts +++ b/app/src/adapters/PolicyAdapter.ts @@ -1,4 +1,6 @@ +import { V2PolicyCreatePayload, V2PolicyParameterValue, V2PolicyResponse } from '@/api/policy'; import { Policy } from '@/types/ingredients/Policy'; +import { ParameterMetadata } from '@/types/metadata'; import { PolicyMetadata } from '@/types/metadata/policyMetadata'; import { PolicyCreationPayload } from '@/types/payloads'; import { convertParametersToPolicyJson, convertPolicyJsonToParameters } from './conversionHelpers'; @@ -8,6 +10,7 @@ import { convertParametersToPolicyJson, convertPolicyJsonToParameters } from './ */ export class PolicyAdapter { /** + * @deprecated Use fromV2Response for v2 API responses * Converts PolicyMetadata from API GET response to Policy type * Handles snake_case to camelCase conversion */ @@ -21,12 +24,113 @@ export class PolicyAdapter { } /** - * Converts Policy to format for API POST request - * Note: API expects snake_case, but we handle that at the API layer + * @deprecated Use toV2CreationPayload for v2 API + * Converts Policy to format for API POST request (v1 format) */ static toCreationPayload(policy: Policy): PolicyCreationPayload { return { data: convertParametersToPolicyJson(policy.parameters || []), }; } + + /** + * Converts V2 API response to Policy type + */ + static fromV2Response(response: V2PolicyResponse): Policy { + return { + id: response.id, + // Note: V2 response doesn't include parameter values directly + // They would need to be fetched separately if needed + parameters: [], + }; + } + + /** + * Converts Policy to V2 API creation payload + * + * @param policy - Policy with parameters (names and values) + * @param parametersMetadata - Metadata record for name→ID lookup + * @param taxBenefitModelId - UUID of the tax benefit model + * @param name - Optional policy name (defaults to "Unnamed policy") + * @param description - Optional policy description + */ + static toV2CreationPayload( + policy: Policy, + parametersMetadata: Record, + taxBenefitModelId: string, + name?: string, + description?: string + ): V2PolicyCreatePayload { + const parameterValues: V2PolicyParameterValue[] = []; + + for (const param of policy.parameters || []) { + const parameterId = PolicyAdapter.getParameterIdByName(param.name, parametersMetadata); + + if (!parameterId) { + console.warn(`Parameter ID not found for: ${param.name}`); + continue; + } + + // Convert each value interval to a V2 parameter value + for (const interval of param.values) { + const startDate = PolicyAdapter.toISOTimestamp(interval.startDate); + // Skip if start_date would be null (shouldn't happen in practice) + if (!startDate) { + console.warn(`Invalid start date for parameter: ${param.name}`); + continue; + } + + parameterValues.push({ + parameter_id: parameterId, + value_json: interval.value, + start_date: startDate, + end_date: PolicyAdapter.toISOTimestamp(interval.endDate), + }); + } + } + + return { + name: name || 'Unnamed policy', + description, + tax_benefit_model_id: taxBenefitModelId, + parameter_values: parameterValues, + }; + } + + /** + * Look up parameter ID by name from metadata + */ + private static getParameterIdByName( + paramName: string, + parametersMetadata: Record + ): string | null { + // First try direct lookup by parameter path + const param = parametersMetadata[paramName]; + if (param?.id) { + return param.id; + } + + // Also check by the 'parameter' field which might be the path + for (const metadata of Object.values(parametersMetadata)) { + if (metadata.parameter === paramName && metadata.id) { + return metadata.id; + } + } + + return null; + } + + /** + * Convert date string (YYYY-MM-DD) to ISO timestamp (YYYY-MM-DDTHH:MM:SSZ) + * Returns null for "forever" dates (9999-12-31 or 2100-12-31) + */ + private static toISOTimestamp(dateStr: string): string | null { + // Treat far-future dates as "indefinite" (null in v2 API) + if (dateStr === '9999-12-31' || dateStr === '2100-12-31') { + return null; + } + + // Convert YYYY-MM-DD to ISO timestamp at midnight UTC + return `${dateStr}T00:00:00Z`; + } } diff --git a/app/src/api/policy.ts b/app/src/api/policy.ts index 70d03d711..9504ccef0 100644 --- a/app/src/api/policy.ts +++ b/app/src/api/policy.ts @@ -1,9 +1,42 @@ -import { BASE_URL } from '@/constants'; -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; -import { PolicyCreationPayload } from '@/types/payloads'; +import { API_V2_BASE_URL } from '@/api/v2/taxBenefitModels'; -export async function fetchPolicyById(country: string, policyId: string): Promise { - const url = `${BASE_URL}/${country}/policy/${policyId}`; +/** + * V2 Policy parameter value - represents a single parameter change + */ +export interface V2PolicyParameterValue { + parameter_id: string; // UUID of the parameter + value_json: number | string | boolean | Record; + start_date: string; // ISO timestamp (e.g., "2025-01-01T00:00:00Z") + end_date: string | null; // ISO timestamp or null for indefinite +} + +/** + * V2 Policy creation payload + */ +export interface V2PolicyCreatePayload { + name: string; + description?: string; + tax_benefit_model_id: string; // UUID of the tax benefit model + parameter_values: V2PolicyParameterValue[]; +} + +/** + * V2 Policy response from API + */ +export interface V2PolicyResponse { + id: string; + name: string; + description: string | null; + tax_benefit_model_id: string; + created_at: string; + updated_at: string; +} + +/** + * Fetch a policy by ID from v2 API + */ +export async function fetchPolicyById(policyId: string): Promise { + const url = `${API_V2_BASE_URL}/policies/${policyId}`; const res = await fetch(url, { method: 'GET', @@ -17,25 +50,42 @@ export async function fetchPolicyById(country: string, policyId: string): Promis throw new Error(`Failed to fetch policy ${policyId}`); } - const json = await res.json(); - - return json.result; + return res.json(); } -export async function createPolicy( - countryId: string, - data: PolicyCreationPayload -): Promise<{ result: { policy_id: string } }> { - const url = `${BASE_URL}/${countryId}/policy`; +/** + * Create a new policy via v2 API + */ +export async function createPolicy(payload: V2PolicyCreatePayload): Promise { + const url = `${API_V2_BASE_URL}/policies/`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), + body: JSON.stringify(payload), }); if (!res.ok) { - throw new Error('Failed to create policy'); + const errorText = await res.text(); + throw new Error(`Failed to create policy: ${res.status} ${errorText}`); + } + + return res.json(); +} + +/** + * List all policies, optionally filtered by tax benefit model + */ +export async function listPolicies(taxBenefitModelId?: string): Promise { + let url = `${API_V2_BASE_URL}/policies/`; + if (taxBenefitModelId) { + url += `?tax_benefit_model_id=${taxBenefitModelId}`; + } + + const res = await fetch(url); + + if (!res.ok) { + throw new Error('Failed to list policies'); } return res.json(); diff --git a/app/src/api/v2/taxBenefitModels.ts b/app/src/api/v2/taxBenefitModels.ts index 562802580..c84c18418 100644 --- a/app/src/api/v2/taxBenefitModels.ts +++ b/app/src/api/v2/taxBenefitModels.ts @@ -1,4 +1,5 @@ -export const API_V2_BASE_URL = 'https://v2.api.policyengine.org'; +export const API_V2_BASE_URL = + import.meta.env.VITE_API_V2_URL || 'https://v2.api.policyengine.org'; /** * Map country IDs to their API model names. diff --git a/app/src/constants.ts b/app/src/constants.ts index f062f858e..fb00f5c58 100644 --- a/app/src/constants.ts +++ b/app/src/constants.ts @@ -41,6 +41,7 @@ export function getParamDefinitionDate(year?: string): string { /** * Mock user ID used for anonymous/unauthenticated users + * In development with VITE_DEV_USER_ID set, uses that UUID for API testing * TODO: Replace with actual user ID from auth context when authentication is implemented */ -export const MOCK_USER_ID = 'anonymous'; +export const MOCK_USER_ID = import.meta.env.VITE_DEV_USER_ID || 'anonymous'; diff --git a/app/src/hooks/usePolicy.ts b/app/src/hooks/usePolicy.ts index 4243194f1..24efd0d4a 100644 --- a/app/src/hooks/usePolicy.ts +++ b/app/src/hooks/usePolicy.ts @@ -10,6 +10,6 @@ export function usePolicy(country?: string, policyId = '88713') { // hardcoded a default value until user policies integrated return useQuery({ queryKey: ['policy', resolvedCountry, policyId], - queryFn: () => fetchPolicyById(resolvedCountry, policyId), + queryFn: () => fetchPolicyById(policyId), }); } diff --git a/app/src/hooks/useSaveSharedReport.ts b/app/src/hooks/useSaveSharedReport.ts index 8557637f3..cc13210ed 100644 --- a/app/src/hooks/useSaveSharedReport.ts +++ b/app/src/hooks/useSaveSharedReport.ts @@ -84,7 +84,6 @@ export function useSaveSharedReport() { createPolicyAssociation.mutateAsync({ userId, policyId: policy.policyId, - countryId: policy.countryId as CountryId, label: policy.label ?? undefined, }) ); diff --git a/app/src/hooks/useTaxBenefitModel.ts b/app/src/hooks/useTaxBenefitModel.ts new file mode 100644 index 000000000..c7187683a --- /dev/null +++ b/app/src/hooks/useTaxBenefitModel.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchTaxBenefitModels, getModelName, TaxBenefitModel } from '@/api/v2/taxBenefitModels'; + +/** + * Hook to get the TaxBenefitModel ID for a country + */ +export function useTaxBenefitModelId(countryId: string) { + const modelName = getModelName(countryId); + + const query = useQuery({ + queryKey: ['taxBenefitModels'], + queryFn: fetchTaxBenefitModels, + staleTime: 1000 * 60 * 60, // 1 hour - models don't change often + select: (models: TaxBenefitModel[]) => { + const model = models.find((m) => m.name === modelName); + return model?.id ?? null; + }, + }); + + return { + taxBenefitModelId: query.data ?? null, + isLoading: query.isLoading, + error: query.error, + }; +} diff --git a/app/src/hooks/useUserReports.ts b/app/src/hooks/useUserReports.ts index 0d7c9a7fa..74e48f804 100644 --- a/app/src/hooks/useUserReports.ts +++ b/app/src/hooks/useUserReports.ts @@ -163,8 +163,8 @@ export const useUserReports = (userId: string) => { const policyResults = useParallelQueries(policyIds, { queryKey: policyKeys.byId, queryFn: async (id) => { - const metadata = await fetchPolicyById(country, id); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(id); + return PolicyAdapter.fromV2Response(response); }, enabled: policyIds.length > 0, staleTime: 5 * 60 * 1000, @@ -414,8 +414,8 @@ export const useUserReportById = (userReportId: string, options?: { enabled?: bo const policyResults = useParallelQueries(policyIds, { queryKey: policyKeys.byId, queryFn: async (id) => { - const metadata = await fetchPolicyById(country, id); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(id); + return PolicyAdapter.fromV2Response(response); }, enabled: isEnabled && policyIds.length > 0, staleTime: 5 * 60 * 1000, diff --git a/app/src/hooks/useUserSimulations.ts b/app/src/hooks/useUserSimulations.ts index 8a90dbd11..92ba00e63 100644 --- a/app/src/hooks/useUserSimulations.ts +++ b/app/src/hooks/useUserSimulations.ts @@ -122,8 +122,8 @@ export const useUserSimulations = (userId: string) => { const policyResults = useParallelQueries(policyIds, { queryKey: policyKeys.byId, queryFn: async (id) => { - const metadata = await fetchPolicyById(country, id); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(id); + return PolicyAdapter.fromV2Response(response); }, enabled: policyIds.length > 0, staleTime: 5 * 60 * 1000, @@ -283,8 +283,8 @@ export const useUserSimulationById = (userId: string, simulationId: string) => { const { data: policy } = useQuery({ queryKey: policyKeys.byId(finalSimulation?.policyId?.toString() ?? ''), queryFn: async () => { - const metadata = await fetchPolicyById(country, finalSimulation!.policyId!.toString()); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(finalSimulation!.policyId!.toString()); + return PolicyAdapter.fromV2Response(response); }, enabled: !!finalSimulation?.policyId, staleTime: 5 * 60 * 1000, diff --git a/app/src/hooks/utils/useFetchReportIngredients.ts b/app/src/hooks/utils/useFetchReportIngredients.ts index 3991161d4..9f5636e4e 100644 --- a/app/src/hooks/utils/useFetchReportIngredients.ts +++ b/app/src/hooks/utils/useFetchReportIngredients.ts @@ -232,8 +232,8 @@ export function useFetchReportIngredients( const policyResults = useParallelQueries(isEnabled ? policyIds : [], { queryKey: policyKeys.byId, queryFn: async (id) => { - const metadata = await fetchPolicyById(country, id); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(id); + return PolicyAdapter.fromV2Response(response); }, enabled: isEnabled && policyIds.length > 0, staleTime: 5 * 60 * 1000, diff --git a/app/src/pathways/report/views/policy/PolicySubmitView.tsx b/app/src/pathways/report/views/policy/PolicySubmitView.tsx index fe13f4bd7..6a97396c1 100644 --- a/app/src/pathways/report/views/policy/PolicySubmitView.tsx +++ b/app/src/pathways/report/views/policy/PolicySubmitView.tsx @@ -4,7 +4,6 @@ * Props-based instead of Redux-based */ -import { PolicyAdapter } from '@/adapters'; import IngredientSubmissionView, { DateIntervalValue, TextListItem, @@ -14,7 +13,6 @@ import { useCreatePolicy } from '@/hooks/useCreatePolicy'; import { countryIds } from '@/libs/countries'; import { Policy } from '@/types/ingredients/Policy'; import { PolicyStateProps } from '@/types/pathwayState'; -import { PolicyCreationPayload } from '@/types/payloads'; import { formatDate } from '@/utils/dateUtils'; interface PolicySubmitViewProps { @@ -32,7 +30,7 @@ export default function PolicySubmitView({ onBack, onCancel, }: PolicySubmitViewProps) { - const { createPolicy, isPending } = useCreatePolicy(policy?.label || undefined); + const { createPolicy, isPending, isModelReady } = useCreatePolicy(policy?.label || undefined); // Convert state to Policy type structure const policyData: Partial = { @@ -45,14 +43,20 @@ export default function PolicySubmitView({ return; } - const serializedPolicyCreationPayload: PolicyCreationPayload = PolicyAdapter.toCreationPayload( - policyData as Policy + if (!isModelReady) { + console.error('Tax benefit model not loaded yet'); + return; + } + + // Pass policy directly - hook handles conversion to v2 format + createPolicy( + { policy: policyData as Policy, name: policy.label || undefined }, + { + onSuccess: (data) => { + onSubmitSuccess(data.id); + }, + } ); - createPolicy(serializedPolicyCreationPayload, { - onSuccess: (data) => { - onSubmitSuccess(data.result.policy_id); - }, - }); } // Helper function to format date range string (UTC timezone-agnostic) diff --git a/app/src/tests/fixtures/api/policyMocks.ts b/app/src/tests/fixtures/api/policyMocks.ts index 00b595744..0aaedfb6a 100644 --- a/app/src/tests/fixtures/api/policyMocks.ts +++ b/app/src/tests/fixtures/api/policyMocks.ts @@ -1,4 +1,5 @@ import { vi } from 'vitest'; +import { V2PolicyCreatePayload, V2PolicyResponse } from '@/api/policy'; export const TEST_POLICY_IDS = { POLICY_123: 'policy-123', @@ -11,6 +12,34 @@ export const TEST_COUNTRIES = { UK: 'uk', } as const; +export const TEST_TAX_BENEFIT_MODEL_ID = 'test-tbm-id-123'; + +/** + * V2 Policy response mock + */ +export const mockV2PolicyResponse = (overrides?: Partial): V2PolicyResponse => ({ + id: TEST_POLICY_IDS.POLICY_123, + name: 'Test Policy', + description: null, + tax_benefit_model_id: TEST_TAX_BENEFIT_MODEL_ID, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + ...overrides, +}); + +/** + * V2 Policy creation payload mock + */ +export const mockV2PolicyPayload = (overrides?: Partial): V2PolicyCreatePayload => ({ + name: 'New Policy', + tax_benefit_model_id: TEST_TAX_BENEFIT_MODEL_ID, + parameter_values: [], + ...overrides, +}); + +/** + * @deprecated Use mockV2PolicyResponse for v2 API + */ export const mockPolicyData = (overrides?: any) => ({ result: { id: TEST_POLICY_IDS.POLICY_123, @@ -20,12 +49,18 @@ export const mockPolicyData = (overrides?: any) => ({ }, }); +/** + * @deprecated Use mockV2PolicyPayload for v2 API + */ export const mockPolicyPayload = (overrides?: any) => ({ data: { param1: 100, param2: 200 }, label: 'New Policy', ...overrides, }); +/** + * @deprecated Use mockV2PolicyResponse for v2 API + */ export const mockPolicyCreateResponse = (policyId = TEST_POLICY_IDS.POLICY_456) => ({ result: { policy_id: policyId }, }); diff --git a/app/src/tests/fixtures/hooks/useFetchReportIngredientsMocks.ts b/app/src/tests/fixtures/hooks/useFetchReportIngredientsMocks.ts index 9a9668643..7b45e94e9 100644 --- a/app/src/tests/fixtures/hooks/useFetchReportIngredientsMocks.ts +++ b/app/src/tests/fixtures/hooks/useFetchReportIngredientsMocks.ts @@ -64,8 +64,8 @@ export const SOCIETY_WIDE_INPUT: ReportIngredientsInput = { { simulationId: TEST_IDS.SIMULATIONS.REFORM, countryId: TEST_COUNTRIES.US, label: 'Reform' }, ], userPolicies: [ - { policyId: TEST_IDS.POLICIES.CURRENT_LAW, countryId: TEST_COUNTRIES.US, label: 'Current Law' }, - { policyId: TEST_IDS.POLICIES.REFORM, countryId: TEST_COUNTRIES.US, label: 'My Reform' }, + { policyId: TEST_IDS.POLICIES.CURRENT_LAW, label: 'Current Law' }, + { policyId: TEST_IDS.POLICIES.REFORM, label: 'My Reform' }, ], userHouseholds: [], userGeographies: [ @@ -92,7 +92,7 @@ export const HOUSEHOLD_INPUT: ReportIngredientsInput = { userSimulations: [ { simulationId: 'sim-hh-1', countryId: TEST_COUNTRIES.UK, label: 'Household Sim' }, ], - userPolicies: [{ policyId: 'policy-hh-1', countryId: TEST_COUNTRIES.UK, label: 'HH Policy' }], + userPolicies: [{ policyId: 'policy-hh-1', label: 'HH Policy' }], userHouseholds: [ { type: 'household', diff --git a/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts b/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts index 789949083..15ef2a74d 100644 --- a/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts +++ b/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts @@ -44,7 +44,7 @@ export const MOCK_SAVE_SHARE_DATA: ReportIngredientsInput = { userSimulations: [ { simulationId: TEST_IDS.SIMULATION, countryId: TEST_COUNTRIES.US, label: 'Baseline' }, ], - userPolicies: [{ policyId: TEST_IDS.POLICY, countryId: TEST_COUNTRIES.US, label: 'My Policy' }], + userPolicies: [{ policyId: TEST_IDS.POLICY, label: 'My Policy' }], userHouseholds: [], userGeographies: [ { @@ -60,8 +60,8 @@ export const MOCK_SAVE_SHARE_DATA: ReportIngredientsInput = { export const MOCK_SHARE_DATA_WITH_CURRENT_LAW: ReportIngredientsInput = { ...MOCK_SAVE_SHARE_DATA, userPolicies: [ - { policyId: TEST_IDS.CURRENT_LAW_POLICY, countryId: TEST_COUNTRIES.US, label: 'Current Law' }, - { policyId: TEST_IDS.POLICY, countryId: TEST_COUNTRIES.US, label: 'My Policy' }, + { policyId: TEST_IDS.CURRENT_LAW_POLICY, label: 'Current Law' }, + { policyId: TEST_IDS.POLICY, label: 'My Policy' }, ], }; diff --git a/app/src/tests/fixtures/hooks/useSharedReportDataMocks.tsx b/app/src/tests/fixtures/hooks/useSharedReportDataMocks.tsx index 038f88b36..2c0b27b3a 100644 --- a/app/src/tests/fixtures/hooks/useSharedReportDataMocks.tsx +++ b/app/src/tests/fixtures/hooks/useSharedReportDataMocks.tsx @@ -18,7 +18,7 @@ export const MOCK_SHARE_DATA: ReportIngredientsInput = { label: 'Test Report', }, userSimulations: [{ simulationId: 'sim-1', countryId: 'us', label: 'Baseline Sim' }], - userPolicies: [{ policyId: 'policy-1', countryId: 'us', label: 'Test Policy' }], + userPolicies: [{ policyId: 'policy-1', label: 'Test Policy' }], userHouseholds: [], userGeographies: [ { @@ -39,7 +39,7 @@ export const MOCK_HOUSEHOLD_SHARE_DATA: ReportIngredientsInput = { label: 'Household Report', }, userSimulations: [{ simulationId: 'sim-2', countryId: 'uk', label: 'HH Sim' }], - userPolicies: [{ policyId: 'policy-2', countryId: 'uk', label: 'HH Policy' }], + userPolicies: [{ policyId: 'policy-2', label: 'HH Policy' }], userHouseholds: [ { type: 'household', householdId: 'hh-1', countryId: 'uk', label: 'My Household' }, ], diff --git a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts index b8de53625..47ecafd5d 100644 --- a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts @@ -1,4 +1,5 @@ import { vi } from 'vitest'; +import { V2PolicyResponse } from '@/api/policy'; import { Household } from '@/types/ingredients/Household'; import { Policy } from '@/types/ingredients/Policy'; import { Simulation } from '@/types/ingredients/Simulation'; @@ -147,6 +148,9 @@ export const mockSimulationMetadata2: SimulationMetadata = { policy_id: TEST_POLICY_ID_2, // policy-789 }; +/** + * @deprecated Use mockV2PolicyResponse1 for v2 API + */ export const mockPolicyMetadata1: PolicyMetadata = { id: TEST_POLICY_ID_1, country_id: TEST_COUNTRIES.US, @@ -156,6 +160,9 @@ export const mockPolicyMetadata1: PolicyMetadata = { label: 'Test Policy 1', }; +/** + * @deprecated Use mockV2PolicyResponse2 for v2 API + */ export const mockPolicyMetadata2: PolicyMetadata = { id: TEST_POLICY_ID_2, country_id: TEST_COUNTRIES.US, @@ -165,6 +172,25 @@ export const mockPolicyMetadata2: PolicyMetadata = { label: 'Test Policy 2', }; +// V2 API Policy Responses +export const mockV2PolicyResponse1: V2PolicyResponse = { + id: TEST_POLICY_ID_1, + name: 'Test Policy 1', + description: null, + tax_benefit_model_id: 'test-tbm-id', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', +}; + +export const mockV2PolicyResponse2: V2PolicyResponse = { + id: TEST_POLICY_ID_2, + name: 'Test Policy 2', + description: null, + tax_benefit_model_id: 'test-tbm-id', + created_at: '2025-01-02T00:00:00Z', + updated_at: '2025-01-02T00:00:00Z', +}; + export const mockHouseholdMetadata: HouseholdMetadata = { id: TEST_HOUSEHOLD_ID, country_id: TEST_COUNTRIES.US, diff --git a/app/src/tests/fixtures/utils/shareUtilsMocks.ts b/app/src/tests/fixtures/utils/shareUtilsMocks.ts index 573e1ab7d..3ef23932a 100644 --- a/app/src/tests/fixtures/utils/shareUtilsMocks.ts +++ b/app/src/tests/fixtures/utils/shareUtilsMocks.ts @@ -48,8 +48,8 @@ export const VALID_SHARE_DATA: ReportIngredientsInput = { { simulationId: 'sim-2', countryId: TEST_COUNTRIES.US, label: 'Reform' }, ], userPolicies: [ - { policyId: 'policy-1', countryId: TEST_COUNTRIES.US, label: 'Current Law' }, - { policyId: 'policy-2', countryId: TEST_COUNTRIES.US, label: 'My Policy' }, + { policyId: 'policy-1', label: 'Current Law' }, + { policyId: 'policy-2', label: 'My Policy' }, ], userHouseholds: [], userGeographies: [ @@ -77,7 +77,7 @@ export const VALID_HOUSEHOLD_SHARE_DATA: ReportIngredientsInput = { userSimulations: [ { simulationId: 'sim-3', countryId: TEST_COUNTRIES.UK, label: 'My Simulation' }, ], - userPolicies: [{ policyId: 'policy-3', countryId: TEST_COUNTRIES.UK, label: 'My Policy' }], + userPolicies: [{ policyId: 'policy-3', label: 'My Policy' }], userHouseholds: [ { type: 'household', @@ -114,7 +114,6 @@ export const MOCK_USER_POLICIES: UserPolicy[] = [ { userId: 'anonymous', policyId: 'policy-1', - countryId: TEST_COUNTRIES.US, label: 'Policy Label', }, ]; diff --git a/app/src/tests/unit/api/policy.test.ts b/app/src/tests/unit/api/policy.test.ts index aa432caa9..daea7407a 100644 --- a/app/src/tests/unit/api/policy.test.ts +++ b/app/src/tests/unit/api/policy.test.ts @@ -1,35 +1,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPolicy, fetchPolicyById } from '@/api/policy'; -import { BASE_URL } from '@/constants'; +import { API_V2_BASE_URL } from '@/api/v2/taxBenefitModels'; import { mockErrorFetchResponse, - mockPolicyCreateResponse, - mockPolicyData, - mockPolicyPayload, mockSuccessFetchResponse, - TEST_COUNTRIES, + mockV2PolicyPayload, + mockV2PolicyResponse, TEST_POLICY_IDS, } from '@/tests/fixtures/api/policyMocks'; // Mock fetch global.fetch = vi.fn(); -describe('policy API', () => { +describe('policy API (v2)', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('fetchPolicyById', () => { - it('given valid policy ID then fetches policy metadata', async () => { + it('given valid policy ID then fetches policy from v2 API', async () => { // Given - (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(mockPolicyData())); + const mockResponse = mockV2PolicyResponse(); + (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(mockResponse)); // When - const result = await fetchPolicyById(TEST_COUNTRIES.US, TEST_POLICY_IDS.POLICY_123); + const result = await fetchPolicyById(TEST_POLICY_IDS.POLICY_123); // Then expect(fetch).toHaveBeenCalledWith( - `${BASE_URL}/${TEST_COUNTRIES.US}/policy/${TEST_POLICY_IDS.POLICY_123}`, + `${API_V2_BASE_URL}/policies/${TEST_POLICY_IDS.POLICY_123}`, expect.objectContaining({ method: 'GET', headers: { @@ -38,7 +37,7 @@ describe('policy API', () => { }, }) ); - expect(result).toEqual(mockPolicyData().result); + expect(result).toEqual(mockResponse); }); it('given fetch error then throws error', async () => { @@ -46,26 +45,26 @@ describe('policy API', () => { (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(404)); // When/Then - await expect(fetchPolicyById(TEST_COUNTRIES.US, TEST_POLICY_IDS.NONEXISTENT)).rejects.toThrow( + await expect(fetchPolicyById(TEST_POLICY_IDS.NONEXISTENT)).rejects.toThrow( `Failed to fetch policy ${TEST_POLICY_IDS.NONEXISTENT}` ); }); }); describe('createPolicy', () => { - it('given valid policy data then creates policy', async () => { + it('given valid v2 payload then creates policy', async () => { // Given - const payload = mockPolicyPayload(); - const response = mockPolicyCreateResponse(); + const payload = mockV2PolicyPayload(); + const response = mockV2PolicyResponse({ id: TEST_POLICY_IDS.POLICY_456 }); (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(response)); // When - const result = await createPolicy(TEST_COUNTRIES.US, payload); + const result = await createPolicy(payload); // Then expect(fetch).toHaveBeenCalledWith( - `${BASE_URL}/${TEST_COUNTRIES.US}/policy`, + `${API_V2_BASE_URL}/policies/`, expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -75,16 +74,18 @@ describe('policy API', () => { expect(result).toEqual(response); }); - it('given API error then throws error', async () => { + it('given API error then throws error with status', async () => { // Given - const payload = mockPolicyPayload(); + const payload = mockV2PolicyPayload(); - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(500)); + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('Internal Server Error'), + }); // When/Then - await expect(createPolicy(TEST_COUNTRIES.US, payload)).rejects.toThrow( - 'Failed to create policy' - ); + await expect(createPolicy(payload)).rejects.toThrow('Failed to create policy: 500'); }); }); }); diff --git a/app/src/tests/unit/hooks/useUserReports.test.tsx b/app/src/tests/unit/hooks/useUserReports.test.tsx index 9dd924c35..9a1cb98f9 100644 --- a/app/src/tests/unit/hooks/useUserReports.test.tsx +++ b/app/src/tests/unit/hooks/useUserReports.test.tsx @@ -34,14 +34,14 @@ import { mockHouseholdMetadata, mockMetadataInitialState, mockPolicy1, - mockPolicyMetadata1, - mockPolicyMetadata2, mockSimulation1, mockSimulationMetadata1, mockSimulationMetadata2, mockUserHouseholds, mockUserPolicies, mockUserSimulations, + mockV2PolicyResponse1, + mockV2PolicyResponse2, TEST_HOUSEHOLD_ID, TEST_POLICY_ID_1, TEST_POLICY_ID_2, @@ -154,12 +154,12 @@ describe('useUserReports', () => { return Promise.reject(new Error(ERROR_MESSAGES.SIMULATION_NOT_FOUND(id))); }); - vi.spyOn(policyApi, 'fetchPolicyById').mockImplementation((_country, id) => { + vi.spyOn(policyApi, 'fetchPolicyById').mockImplementation((id: string) => { if (id === TEST_POLICY_ID_1) { - return Promise.resolve(mockPolicyMetadata1); + return Promise.resolve(mockV2PolicyResponse1); } if (id === TEST_POLICY_ID_2) { - return Promise.resolve(mockPolicyMetadata2); + return Promise.resolve(mockV2PolicyResponse2); } return Promise.reject(new Error(ERROR_MESSAGES.POLICY_NOT_FOUND(id))); }); @@ -582,12 +582,12 @@ describe('useUserReportById', () => { } return Promise.reject(new Error(ERROR_MESSAGES.SIMULATION_NOT_FOUND(id))); }); - vi.spyOn(policyApi, 'fetchPolicyById').mockImplementation((_country, id) => { + vi.spyOn(policyApi, 'fetchPolicyById').mockImplementation((id: string) => { if (id === TEST_POLICY_ID_1) { - return Promise.resolve(mockPolicyMetadata1); + return Promise.resolve(mockV2PolicyResponse1); } if (id === TEST_POLICY_ID_2) { - return Promise.resolve(mockPolicyMetadata2); + return Promise.resolve(mockV2PolicyResponse2); } return Promise.reject(new Error(ERROR_MESSAGES.POLICY_NOT_FOUND(id))); }); From 02e9479adcd72f1476d46da60860ce691663279f Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Thu, 12 Feb 2026 21:54:20 +0530 Subject: [PATCH 06/11] revert: Remove unnecessary VITE_DEV_USER_ID env var --- app/src/constants.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/constants.ts b/app/src/constants.ts index fb00f5c58..f062f858e 100644 --- a/app/src/constants.ts +++ b/app/src/constants.ts @@ -41,7 +41,6 @@ export function getParamDefinitionDate(year?: string): string { /** * Mock user ID used for anonymous/unauthenticated users - * In development with VITE_DEV_USER_ID set, uses that UUID for API testing * TODO: Replace with actual user ID from auth context when authentication is implemented */ -export const MOCK_USER_ID = import.meta.env.VITE_DEV_USER_ID || 'anonymous'; +export const MOCK_USER_ID = 'anonymous'; From fe3d2a7d72d3439a4d53ad31d7a4458d62ed726f Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Fri, 13 Feb 2026 18:25:47 +0530 Subject: [PATCH 07/11] fix: Filter policies by taxBenefitModelId for country separation --- app/src/adapters/PolicyAdapter.ts | 1 + app/src/hooks/useUserPolicy.ts | 16 ++++++++++++---- app/src/types/ingredients/Policy.ts | 1 + 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/adapters/PolicyAdapter.ts b/app/src/adapters/PolicyAdapter.ts index d4eeb68b5..7697111ea 100644 --- a/app/src/adapters/PolicyAdapter.ts +++ b/app/src/adapters/PolicyAdapter.ts @@ -39,6 +39,7 @@ export class PolicyAdapter { static fromV2Response(response: V2PolicyResponse): Policy { return { id: response.id, + taxBenefitModelId: response.tax_benefit_model_id, // Note: V2 response doesn't include parameter values directly // They would need to be fetched separately if needed parameters: [], diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index 52c61d81b..623e55150 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -158,9 +158,10 @@ export function isPolicyWithAssociation(obj: unknown): obj is UserPolicyWithAsso } export const useUserPolicies = (userId: string) => { - const country = useCurrentCountry(); + const countryId = useCurrentCountry(); + const { taxBenefitModelId, isLoading: modelLoading } = useTaxBenefitModelId(countryId); - // First, get the associations (filtered by current country) + // First, get the associations (localStorage doesn't filter, so we filter after fetching policies) const { data: associations, isLoading: associationsLoading, @@ -195,12 +196,12 @@ export const useUserPolicies = (userId: string) => { }); // Combine the results - const isLoading = associationsLoading || policyQueries.some((q) => q.isLoading); + const isLoading = modelLoading || associationsLoading || policyQueries.some((q) => q.isLoading); const error = associationsError || policyQueries.find((q) => q.error)?.error; const isError = !!error; // Simple index-based mapping since queries are in same order as associations - const policiesWithAssociations: UserPolicyWithAssociation[] | undefined = associations?.map( + const allPoliciesWithAssociations: UserPolicyWithAssociation[] | undefined = associations?.map( (association, index) => ({ association, policy: policyQueries[index]?.data, @@ -210,6 +211,13 @@ export const useUserPolicies = (userId: string) => { }) ); + // Filter by current country's tax benefit model + // TODO: Remove this filter when isLoggedIn = true - the API backend handles filtering, + // but localStorage mode requires post-fetch filtering since UserPolicy doesn't store model ID + const policiesWithAssociations = allPoliciesWithAssociations?.filter( + (item) => item.policy?.taxBenefitModelId === taxBenefitModelId + ); + return { data: policiesWithAssociations, isLoading, diff --git a/app/src/types/ingredients/Policy.ts b/app/src/types/ingredients/Policy.ts index 08e9a2950..93fbd2b48 100644 --- a/app/src/types/ingredients/Policy.ts +++ b/app/src/types/ingredients/Policy.ts @@ -7,6 +7,7 @@ import { Parameter } from '@/types/subIngredients/parameter'; export interface Policy { id?: string; countryId?: (typeof countryIds)[number]; + taxBenefitModelId?: string; // UUID of the tax benefit model (v2 API) apiVersion?: string; parameters?: Parameter[]; label?: string | null; From 3359b32432c5cbdfc8a9840cc4a4a6036dbb7775 Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Mon, 16 Feb 2026 23:26:05 +0530 Subject: [PATCH 08/11] feat: Use country_id for user-policy filtering --- app/src/adapters/UserPolicyAdapter.ts | 3 + app/src/api/policyAssociation.ts | 19 ++--- app/src/hooks/useUserPolicy.ts | 25 ++----- .../tests/unit/api/policyAssociation.test.ts | 71 ++++++++++++++++--- app/src/types/ingredients/UserPolicy.ts | 3 + app/src/types/metadata/userPolicyMetadata.ts | 2 + app/vite.config.mjs | 11 +-- 7 files changed, 88 insertions(+), 46 deletions(-) diff --git a/app/src/adapters/UserPolicyAdapter.ts b/app/src/adapters/UserPolicyAdapter.ts index 77e8cdb9f..ac73d1b5a 100644 --- a/app/src/adapters/UserPolicyAdapter.ts +++ b/app/src/adapters/UserPolicyAdapter.ts @@ -1,3 +1,4 @@ +import { CountryId } from '@/libs/countries'; import { UserPolicy } from '@/types/ingredients/UserPolicy'; import { UserPolicyCreationMetadata, @@ -19,6 +20,7 @@ export class UserPolicyAdapter { return { user_id: String(userPolicy.userId), policy_id: String(userPolicy.policyId), + country_id: userPolicy.countryId, label: userPolicy.label, }; } @@ -43,6 +45,7 @@ export class UserPolicyAdapter { id: String(apiData.id), userId: String(apiData.user_id), policyId: String(apiData.policy_id), + countryId: apiData.country_id as CountryId, label: apiData.label ?? undefined, createdAt: apiData.created_at, updatedAt: apiData.updated_at, diff --git a/app/src/api/policyAssociation.ts b/app/src/api/policyAssociation.ts index 001d21974..9d2ee952c 100644 --- a/app/src/api/policyAssociation.ts +++ b/app/src/api/policyAssociation.ts @@ -1,17 +1,18 @@ import { UserPolicyAdapter } from '@/adapters/UserPolicyAdapter'; +import { API_V2_BASE_URL } from '@/api/v2/taxBenefitModels'; import { UserPolicyCreationMetadata } from '@/types/metadata/userPolicyMetadata'; import { UserPolicy } from '../types/ingredients/UserPolicy'; export interface UserPolicyStore { create: (policy: Omit) => Promise; - findByUser: (userId: string, taxBenefitModelId?: string) => Promise; + findByUser: (userId: string, countryId?: string) => Promise; findById: (userPolicyId: string) => Promise; update: (userPolicyId: string, updates: Partial) => Promise; delete: (userPolicyId: string) => Promise; } export class ApiPolicyStore implements UserPolicyStore { - private readonly BASE_URL = '/user-policies'; + private readonly BASE_URL = `${API_V2_BASE_URL}/user-policies`; async create(policy: Omit): Promise { const payload: UserPolicyCreationMetadata = UserPolicyAdapter.toCreationPayload(policy); @@ -31,10 +32,10 @@ export class ApiPolicyStore implements UserPolicyStore { return UserPolicyAdapter.fromApiResponse(apiResponse); } - async findByUser(userId: string, taxBenefitModelId?: string): Promise { + async findByUser(userId: string, countryId?: string): Promise { const params = new URLSearchParams({ user_id: userId }); - if (taxBenefitModelId) { - params.append('tax_benefit_model_id', taxBenefitModelId); + if (countryId) { + params.append('country_id', countryId); } const response = await fetch(`${this.BASE_URL}/?${params}`, { @@ -128,11 +129,11 @@ export class LocalStoragePolicyStore implements UserPolicyStore { return newPolicy; } - async findByUser(userId: string, _taxBenefitModelId?: string): Promise { + async findByUser(userId: string, countryId?: string): Promise { const policies = this.getStoredPolicies(); - // LocalStorage doesn't have tax_benefit_model context - return all user's policies - // The underlying Policy contains tax_benefit_model_id, which would require fetching policy data - return policies.filter((p) => p.userId === userId); + return policies.filter( + (p) => p.userId === userId && (!countryId || p.countryId === countryId) + ); } async findById(userPolicyId: string): Promise { diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index 623e55150..a7cc19923 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -3,7 +3,6 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea import { PolicyAdapter } from '@/adapters'; import { fetchPolicyById } from '@/api/policy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { useTaxBenefitModelId } from '@/hooks/useTaxBenefitModel'; import { Policy } from '@/types/ingredients/Policy'; import { ApiPolicyStore, LocalStoragePolicyStore } from '../api/policyAssociation'; import { queryConfig } from '../libs/queryConfig'; @@ -23,15 +22,14 @@ export const useUserPolicyStore = () => { export const usePolicyAssociationsByUser = (userId: string) => { const store = useUserPolicyStore(); const countryId = useCurrentCountry(); - const { taxBenefitModelId, isLoading: modelLoading } = useTaxBenefitModelId(countryId); const isLoggedIn = false; // TODO: Replace with actual auth check in future // TODO: Should we determine user ID from auth context here? Or pass as arg? const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; return useQuery({ - queryKey: policyAssociationKeys.byUser(userId, taxBenefitModelId ?? undefined), - queryFn: () => store.findByUser(userId, taxBenefitModelId ?? undefined), - enabled: !modelLoading && !!taxBenefitModelId, + queryKey: policyAssociationKeys.byUser(userId, countryId), + queryFn: () => store.findByUser(userId, countryId), + enabled: !!countryId, ...config, }); }; @@ -158,10 +156,7 @@ export function isPolicyWithAssociation(obj: unknown): obj is UserPolicyWithAsso } export const useUserPolicies = (userId: string) => { - const countryId = useCurrentCountry(); - const { taxBenefitModelId, isLoading: modelLoading } = useTaxBenefitModelId(countryId); - - // First, get the associations (localStorage doesn't filter, so we filter after fetching policies) + // First, get the associations (now filtered by countryId in both API and localStorage) const { data: associations, isLoading: associationsLoading, @@ -196,12 +191,13 @@ export const useUserPolicies = (userId: string) => { }); // Combine the results - const isLoading = modelLoading || associationsLoading || policyQueries.some((q) => q.isLoading); + const isLoading = associationsLoading || policyQueries.some((q) => q.isLoading); const error = associationsError || policyQueries.find((q) => q.error)?.error; const isError = !!error; // Simple index-based mapping since queries are in same order as associations - const allPoliciesWithAssociations: UserPolicyWithAssociation[] | undefined = associations?.map( + // No post-fetch filter needed - both API and localStorage now filter by countryId + const policiesWithAssociations: UserPolicyWithAssociation[] | undefined = associations?.map( (association, index) => ({ association, policy: policyQueries[index]?.data, @@ -211,13 +207,6 @@ export const useUserPolicies = (userId: string) => { }) ); - // Filter by current country's tax benefit model - // TODO: Remove this filter when isLoggedIn = true - the API backend handles filtering, - // but localStorage mode requires post-fetch filtering since UserPolicy doesn't store model ID - const policiesWithAssociations = allPoliciesWithAssociations?.filter( - (item) => item.policy?.taxBenefitModelId === taxBenefitModelId - ); - return { data: policiesWithAssociations, isLoading, diff --git a/app/src/tests/unit/api/policyAssociation.test.ts b/app/src/tests/unit/api/policyAssociation.test.ts index 80b83325c..e4a58b78d 100644 --- a/app/src/tests/unit/api/policyAssociation.test.ts +++ b/app/src/tests/unit/api/policyAssociation.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiPolicyStore, LocalStoragePolicyStore } from '@/api/policyAssociation'; +import { API_V2_BASE_URL } from '@/api/v2/taxBenefitModels'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; // Mock fetch @@ -11,6 +12,7 @@ describe('ApiPolicyStore', () => { const mockPolicyInput: Omit = { userId: 'user-123', policyId: 'policy-456', + countryId: 'us', label: 'Test Policy', isCreated: true, }; @@ -19,6 +21,7 @@ describe('ApiPolicyStore', () => { id: 'user-policy-abc123', user_id: 'user-123', policy_id: 'policy-456', + country_id: 'us', label: 'Test Policy', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', @@ -46,7 +49,7 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/user-policies/', + `${API_V2_BASE_URL}/user-policies/`, expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -87,7 +90,7 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/user-policies/?user_id=user-123', + `${API_V2_BASE_URL}/user-policies/?user_id=user-123`, expect.objectContaining({ headers: { 'Content-Type': 'application/json' }, }) @@ -96,10 +99,31 @@ describe('ApiPolicyStore', () => { expect(result[0]).toMatchObject({ userId: 'user-123', policyId: 'policy-456', + countryId: 'us', label: 'Test Policy', }); }); + it('given valid user ID and country ID then fetches filtered associations', async () => { + // Given + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => [mockApiResponse], + }); + + // When + const result = await store.findByUser('user-123', 'us'); + + // Then + expect(fetch).toHaveBeenCalledWith( + `${API_V2_BASE_URL}/user-policies/?user_id=user-123&country_id=us`, + expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + }) + ); + expect(result).toHaveLength(1); + }); + it('given API error then throws error', async () => { // Given (global.fetch as any).mockResolvedValue({ @@ -128,7 +152,7 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/user-policies/user-policy-abc123', + `${API_V2_BASE_URL}/user-policies/user-policy-abc123`, expect.objectContaining({ headers: { 'Content-Type': 'application/json' }, }) @@ -186,7 +210,7 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/user-policies/user-policy-abc123', + `${API_V2_BASE_URL}/user-policies/user-policy-abc123`, expect.objectContaining({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -222,7 +246,7 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/user-policies/user-policy-abc123', + `${API_V2_BASE_URL}/user-policies/user-policy-abc123`, expect.objectContaining({ method: 'DELETE', }) @@ -251,6 +275,7 @@ describe('LocalStoragePolicyStore', () => { const mockPolicyInput1: Omit = { userId: 'user-123', policyId: 'policy-456', + countryId: 'us', label: 'Test Policy 1', isCreated: true, }; @@ -258,10 +283,19 @@ describe('LocalStoragePolicyStore', () => { const mockPolicyInput2: Omit = { userId: 'user-123', policyId: 'policy-789', + countryId: 'us', label: 'Test Policy 2', isCreated: true, }; + const mockPolicyInputUK: Omit = { + userId: 'user-123', + policyId: 'policy-uk-001', + countryId: 'uk', + label: 'UK Policy', + isCreated: true, + }; + beforeEach(() => { // Mock localStorage mockLocalStorage = {}; @@ -293,6 +327,7 @@ describe('LocalStoragePolicyStore', () => { expect(result).toMatchObject({ userId: 'user-123', policyId: 'policy-456', + countryId: 'us', label: 'Test Policy 1', }); expect(result.id).toBeDefined(); @@ -313,6 +348,7 @@ describe('LocalStoragePolicyStore', () => { expect(second).toMatchObject({ userId: 'user-123', policyId: 'policy-456', + countryId: 'us', label: 'Test Policy 1', }); expect(second.id).toBeDefined(); @@ -322,18 +358,35 @@ describe('LocalStoragePolicyStore', () => { }); describe('findByUser', () => { - it('given user with policies then returns all user policies', async () => { + it('given user with policies then returns all user policies when no country filter', async () => { // Given await store.create(mockPolicyInput1); await store.create(mockPolicyInput2); + await store.create(mockPolicyInputUK); // When const result = await store.findByUser('user-123'); // Then - expect(result).toHaveLength(2); - expect(result[0].policyId).toBe('policy-456'); - expect(result[1].policyId).toBe('policy-789'); + expect(result).toHaveLength(3); + }); + + it('given user with policies then filters by country when country is provided', async () => { + // Given + await store.create(mockPolicyInput1); + await store.create(mockPolicyInput2); + await store.create(mockPolicyInputUK); + + // When + const usResult = await store.findByUser('user-123', 'us'); + const ukResult = await store.findByUser('user-123', 'uk'); + + // Then + expect(usResult).toHaveLength(2); + expect(usResult[0].countryId).toBe('us'); + expect(usResult[1].countryId).toBe('us'); + expect(ukResult).toHaveLength(1); + expect(ukResult[0].countryId).toBe('uk'); }); it('given user with no policies then returns empty array', async () => { diff --git a/app/src/types/ingredients/UserPolicy.ts b/app/src/types/ingredients/UserPolicy.ts index 91052f90b..5e60bcfbc 100644 --- a/app/src/types/ingredients/UserPolicy.ts +++ b/app/src/types/ingredients/UserPolicy.ts @@ -1,3 +1,5 @@ +import { CountryId } from '@/libs/countries'; + /** * UserPolicy type containing mutable user-specific data */ @@ -5,6 +7,7 @@ export interface UserPolicy { id?: string; userId: string; policyId: string; + countryId: CountryId; label?: string; createdAt?: string; updatedAt?: string; diff --git a/app/src/types/metadata/userPolicyMetadata.ts b/app/src/types/metadata/userPolicyMetadata.ts index c7c0af011..6d396a5fa 100644 --- a/app/src/types/metadata/userPolicyMetadata.ts +++ b/app/src/types/metadata/userPolicyMetadata.ts @@ -7,6 +7,7 @@ export interface UserPolicyMetadata { id: string; user_id: string; policy_id: string; + country_id: string; label: string | null; created_at: string; updated_at: string; @@ -20,6 +21,7 @@ export interface UserPolicyMetadata { export interface UserPolicyCreationMetadata { user_id: string; policy_id: string; + country_id: string; label?: string | null; } diff --git a/app/vite.config.mjs b/app/vite.config.mjs index de948c683..74a38345d 100644 --- a/app/vite.config.mjs +++ b/app/vite.config.mjs @@ -50,9 +50,7 @@ function spaFallbackPlugin() { const isStaticAsset = (url) => url.includes('.'); const isViteInternal = (url) => url.startsWith('/@'); const isNodeModule = (url) => url.startsWith('/node_modules'); - const isApiRoute = (url) => url.startsWith('/user-policies'); - const isSpaRoute = (url) => - !isStaticAsset(url) && !isViteInternal(url) && !isNodeModule(url) && !isApiRoute(url); + const isSpaRoute = (url) => !isStaticAsset(url) && !isViteInternal(url) && !isNodeModule(url); const middleware = (req, res, next) => { if (req.url && isSpaRoute(req.url)) { @@ -90,13 +88,6 @@ export default defineConfig({ // Use discovered ports in dev, defaults otherwise port: appMode === 'calculator' ? (calculatorPort ?? 3001) : (websitePort ?? 3000), strictPort: true, - // Proxy API v2 endpoints to local backend during development - proxy: { - '/user-policies': { - target: 'http://localhost:8000', - changeOrigin: true, - }, - }, }, define: viteDefines, // Use separate cache directories for website and calculator to avoid conflicts From 845190be3850389b10cc495a19723c99c1c4e78a Mon Sep 17 00:00:00 2001 From: SakshiKekre Date: Tue, 17 Feb 2026 03:18:48 +0530 Subject: [PATCH 09/11] fix: Add missing countryId to createAssociation call --- app/src/hooks/useCreatePolicy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index e2f7c92d1..fd6401dc4 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -50,6 +50,7 @@ export function useCreatePolicy(policyLabel?: string) { await createAssociation.mutateAsync({ userId, policyId: data.id, + countryId, label: policyLabel, isCreated: true, }); From 46b21a43619243d6d354f7bec8777768fb1e5226 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 17 Feb 2026 20:40:25 +0100 Subject: [PATCH 10/11] refactor: Remove migration library code from userIdentity Remove isMigrationComplete, markMigrationComplete, clearMigrationFlag, and MIGRATION_COMPLETE storage key. Migration functionality will be built separately once all ingredient APIs are properly set up. Co-Authored-By: Claude Opus 4.6 --- app/src/libs/userIdentity.ts | 29 ------- app/src/tests/unit/libs/userIdentity.test.ts | 89 +------------------- 2 files changed, 1 insertion(+), 117 deletions(-) diff --git a/app/src/libs/userIdentity.ts b/app/src/libs/userIdentity.ts index bf6a8fb82..88e5f476a 100644 --- a/app/src/libs/userIdentity.ts +++ b/app/src/libs/userIdentity.ts @@ -7,7 +7,6 @@ */ const USER_ID_STORAGE_KEY = 'policyengine_user_id'; -const MIGRATION_COMPLETE_KEY = 'policyengine_migration_v2_complete'; /** * Gets the current user's ID, creating one if it doesn't exist. @@ -35,35 +34,7 @@ export function clearUserId(): void { localStorage.removeItem(USER_ID_STORAGE_KEY); } -/** - * Checks if the v2 migration has been completed. - * - * @returns true if migration is complete, false otherwise - */ -export function isMigrationComplete(): boolean { - return localStorage.getItem(MIGRATION_COMPLETE_KEY) === 'true'; -} - -/** - * Marks the v2 migration as complete. - * This prevents the migration from running again on future page loads. - */ -export function markMigrationComplete(): void { - localStorage.setItem(MIGRATION_COMPLETE_KEY, 'true'); -} - -/** - * Clears the migration complete flag. - * This will cause the migration to run again on the next page load. - * - * Use for testing or if migration needs to be re-run. - */ -export function clearMigrationFlag(): void { - localStorage.removeItem(MIGRATION_COMPLETE_KEY); -} - // Export storage keys for testing purposes export const STORAGE_KEYS = { USER_ID: USER_ID_STORAGE_KEY, - MIGRATION_COMPLETE: MIGRATION_COMPLETE_KEY, } as const; diff --git a/app/src/tests/unit/libs/userIdentity.test.ts b/app/src/tests/unit/libs/userIdentity.test.ts index 7e1dc4535..68f1abcba 100644 --- a/app/src/tests/unit/libs/userIdentity.test.ts +++ b/app/src/tests/unit/libs/userIdentity.test.ts @@ -1,13 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import { - clearMigrationFlag, - clearUserId, - getUserId, - isMigrationComplete, - markMigrationComplete, - STORAGE_KEYS, -} from '@/libs/userIdentity'; +import { clearUserId, getUserId, STORAGE_KEYS } from '@/libs/userIdentity'; describe('userIdentity', () => { beforeEach(() => { @@ -87,89 +79,10 @@ describe('userIdentity', () => { }); }); - describe('isMigrationComplete', () => { - test('given no migration flag then returns false', () => { - // When - const result = isMigrationComplete(); - - // Then - expect(result).toBe(false); - }); - - test('given migration flag set to true then returns true', () => { - // Given - localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'true'); - - // When - const result = isMigrationComplete(); - - // Then - expect(result).toBe(true); - }); - - test('given migration flag set to other value then returns false', () => { - // Given - localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'false'); - - // When - const result = isMigrationComplete(); - - // Then - expect(result).toBe(false); - }); - }); - - describe('markMigrationComplete', () => { - test('given migration not complete then sets flag to true', () => { - // When - markMigrationComplete(); - - // Then - expect(localStorage.getItem(STORAGE_KEYS.MIGRATION_COMPLETE)).toBe('true'); - }); - - test('given migration already complete then flag remains true', () => { - // Given - localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'true'); - - // When - markMigrationComplete(); - - // Then - expect(localStorage.getItem(STORAGE_KEYS.MIGRATION_COMPLETE)).toBe('true'); - }); - }); - - describe('clearMigrationFlag', () => { - test('given migration flag exists then removes it', () => { - // Given - localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'true'); - - // When - clearMigrationFlag(); - - // Then - expect(localStorage.getItem(STORAGE_KEYS.MIGRATION_COMPLETE)).toBeNull(); - }); - - test('given migration flag cleared then isMigrationComplete returns false', () => { - // Given - localStorage.setItem(STORAGE_KEYS.MIGRATION_COMPLETE, 'true'); - - // When - clearMigrationFlag(); - const result = isMigrationComplete(); - - // Then - expect(result).toBe(false); - }); - }); - describe('STORAGE_KEYS', () => { test('given STORAGE_KEYS then contains expected key names', () => { // Then expect(STORAGE_KEYS.USER_ID).toBe('policyengine_user_id'); - expect(STORAGE_KEYS.MIGRATION_COMPLETE).toBe('policyengine_migration_v2_complete'); }); }); }); From d1778d846351b3405ca1d1fd5cbb302ada487b35 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 17 Feb 2026 22:10:07 +0100 Subject: [PATCH 11/11] refactor: Migrate policy associations to v2 API patterns Create api/v2/userPolicyAssociations.ts with types, conversion functions, and CRUD operations mirroring the household v2 module. Refactor ApiPolicyStore to delegate to v2 functions instead of inline fetch calls. Add userId param to update/delete for backend ownership verification. Remove deprecated v1 code: PolicyAdapter.fromMetadata/toCreationPayload, UserPolicyAdapter, PolicyCreationPayload, UserPolicyCreationPayload, userPolicyMetadata types, and dead countParameterChanges function. Replace toCreationPayload usage in report pages with direct convertParametersToPolicyJson calls. Fix queryKeys bug (model -> country label), add useDeletePolicyAssociation hook, and update test fixtures with countryId. Co-Authored-By: Claude Opus 4.6 --- app/src/adapters/PolicyAdapter.ts | 27 -- app/src/adapters/UserPolicyAdapter.ts | 55 ----- app/src/adapters/index.ts | 1 - app/src/api/policyAssociation.ts | 110 ++------- app/src/api/v2/userPolicyAssociations.ts | 232 ++++++++++++++++++ app/src/hooks/useUserPolicy.ts | 45 ++-- app/src/libs/queryKeys.ts | 6 +- .../EarningsVariationSubPage.tsx | 10 +- .../MarginalTaxRatesSubPage.tsx | 10 +- .../fixtures/adapters/PolicyAdapterMocks.ts | 33 +-- .../fixtures/adapters/userAssociationMocks.ts | 13 +- .../fixtures/hooks/useUserPolicyMocks.ts | 17 +- .../fixtures/hooks/useUserReportsMocks.ts | 30 +-- .../utils/countParameterChangesMocks.ts | 76 ------ .../tests/unit/adapters/PolicyAdapter.test.ts | 153 ++++++------ .../unit/adapters/UserPolicyAdapter.test.ts | 134 ---------- .../tests/unit/api/policyAssociation.test.ts | 46 ++-- .../unit/utils/countParameterChanges.test.ts | 104 ++++---- app/src/types/metadata/policyMetadata.ts | 11 - app/src/types/metadata/userPolicyMetadata.ts | 35 --- .../types/payloads/PolicyCreationPayload.ts | 9 - .../payloads/UserPolicyCreationPayload.ts | 15 -- app/src/types/payloads/index.ts | 2 - app/src/utils/countParameterChanges.ts | 28 +-- 24 files changed, 463 insertions(+), 739 deletions(-) delete mode 100644 app/src/adapters/UserPolicyAdapter.ts create mode 100644 app/src/api/v2/userPolicyAssociations.ts delete mode 100644 app/src/tests/fixtures/utils/countParameterChangesMocks.ts delete mode 100644 app/src/tests/unit/adapters/UserPolicyAdapter.test.ts delete mode 100644 app/src/types/metadata/userPolicyMetadata.ts delete mode 100644 app/src/types/payloads/PolicyCreationPayload.ts delete mode 100644 app/src/types/payloads/UserPolicyCreationPayload.ts diff --git a/app/src/adapters/PolicyAdapter.ts b/app/src/adapters/PolicyAdapter.ts index 7697111ea..dc1ac8a89 100644 --- a/app/src/adapters/PolicyAdapter.ts +++ b/app/src/adapters/PolicyAdapter.ts @@ -1,38 +1,11 @@ import { V2PolicyCreatePayload, V2PolicyParameterValue, V2PolicyResponse } from '@/api/policy'; import { Policy } from '@/types/ingredients/Policy'; import { ParameterMetadata } from '@/types/metadata'; -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; -import { PolicyCreationPayload } from '@/types/payloads'; -import { convertParametersToPolicyJson, convertPolicyJsonToParameters } from './conversionHelpers'; /** * Adapter for converting between Policy and API formats */ export class PolicyAdapter { - /** - * @deprecated Use fromV2Response for v2 API responses - * Converts PolicyMetadata from API GET response to Policy type - * Handles snake_case to camelCase conversion - */ - static fromMetadata(metadata: PolicyMetadata): Policy { - return { - id: String(metadata.id), - countryId: metadata.country_id, - apiVersion: metadata.api_version, - parameters: convertPolicyJsonToParameters(metadata.policy_json), - }; - } - - /** - * @deprecated Use toV2CreationPayload for v2 API - * Converts Policy to format for API POST request (v1 format) - */ - static toCreationPayload(policy: Policy): PolicyCreationPayload { - return { - data: convertParametersToPolicyJson(policy.parameters || []), - }; - } - /** * Converts V2 API response to Policy type */ diff --git a/app/src/adapters/UserPolicyAdapter.ts b/app/src/adapters/UserPolicyAdapter.ts deleted file mode 100644 index ac73d1b5a..000000000 --- a/app/src/adapters/UserPolicyAdapter.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { CountryId } from '@/libs/countries'; -import { UserPolicy } from '@/types/ingredients/UserPolicy'; -import { - UserPolicyCreationMetadata, - UserPolicyMetadata, - UserPolicyUpdateMetadata, -} from '@/types/metadata/userPolicyMetadata'; - -/** - * Adapter for converting between UserPolicy and API formats - */ -export class UserPolicyAdapter { - /** - * Convert UserPolicy to API creation payload - * Handles camelCase to snake_case conversion - */ - static toCreationPayload( - userPolicy: Omit - ): UserPolicyCreationMetadata { - return { - user_id: String(userPolicy.userId), - policy_id: String(userPolicy.policyId), - country_id: userPolicy.countryId, - label: userPolicy.label, - }; - } - - /** - * Convert UserPolicy updates to API update payload - * Handles camelCase to snake_case conversion - */ - static toUpdatePayload(updates: Partial): UserPolicyUpdateMetadata { - return { - label: updates.label, - }; - } - - /** - * Convert API response to UserPolicy - * Handles snake_case to camelCase conversion - * Explicitly coerces IDs to strings to handle JSON.parse type mismatches - */ - static fromApiResponse(apiData: UserPolicyMetadata): UserPolicy { - return { - id: String(apiData.id), - userId: String(apiData.user_id), - policyId: String(apiData.policy_id), - countryId: apiData.country_id as CountryId, - label: apiData.label ?? undefined, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, - isCreated: true, - }; - } -} diff --git a/app/src/adapters/index.ts b/app/src/adapters/index.ts index 203c07c14..b6c1554cc 100644 --- a/app/src/adapters/index.ts +++ b/app/src/adapters/index.ts @@ -8,7 +8,6 @@ export type { DatasetEntry } from './MetadataAdapter'; // User Ingredient Adapters export { UserReportAdapter } from './UserReportAdapter'; -export { UserPolicyAdapter } from './UserPolicyAdapter'; export { UserSimulationAdapter } from './UserSimulationAdapter'; export { UserHouseholdAdapter } from './UserHouseholdAdapter'; export { UserGeographicAdapter } from './UserGeographicAdapter'; diff --git a/app/src/api/policyAssociation.ts b/app/src/api/policyAssociation.ts index 9d2ee952c..af854b568 100644 --- a/app/src/api/policyAssociation.ts +++ b/app/src/api/policyAssociation.ts @@ -1,105 +1,45 @@ -import { UserPolicyAdapter } from '@/adapters/UserPolicyAdapter'; -import { API_V2_BASE_URL } from '@/api/v2/taxBenefitModels'; -import { UserPolicyCreationMetadata } from '@/types/metadata/userPolicyMetadata'; import { UserPolicy } from '../types/ingredients/UserPolicy'; +import { + createUserPolicyAssociationV2, + deleteUserPolicyAssociationV2, + fetchUserPolicyAssociationByIdV2, + fetchUserPolicyAssociationsV2, + updateUserPolicyAssociationV2, +} from './v2/userPolicyAssociations'; export interface UserPolicyStore { create: (policy: Omit) => Promise; findByUser: (userId: string, countryId?: string) => Promise; findById: (userPolicyId: string) => Promise; - update: (userPolicyId: string, updates: Partial) => Promise; - delete: (userPolicyId: string) => Promise; + update: (userPolicyId: string, updates: Partial, userId: string) => Promise; + delete: (userPolicyId: string, userId: string) => Promise; } export class ApiPolicyStore implements UserPolicyStore { - private readonly BASE_URL = `${API_V2_BASE_URL}/user-policies`; - async create(policy: Omit): Promise { - const payload: UserPolicyCreationMetadata = UserPolicyAdapter.toCreationPayload(policy); - - const response = await fetch(`${this.BASE_URL}/`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || 'Failed to create policy association'); - } - - const apiResponse = await response.json(); - return UserPolicyAdapter.fromApiResponse(apiResponse); + return createUserPolicyAssociationV2(policy); } async findByUser(userId: string, countryId?: string): Promise { - const params = new URLSearchParams({ user_id: userId }); - if (countryId) { - params.append('country_id', countryId); - } - - const response = await fetch(`${this.BASE_URL}/?${params}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - throw new Error('Failed to fetch user associations'); - } - - const apiResponses = await response.json(); - return apiResponses.map((apiData: any) => UserPolicyAdapter.fromApiResponse(apiData)); + return fetchUserPolicyAssociationsV2(userId, countryId); } async findById(userPolicyId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userPolicyId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - throw new Error('Failed to fetch association'); - } - - const apiData = await response.json(); - return UserPolicyAdapter.fromApiResponse(apiData); + return fetchUserPolicyAssociationByIdV2(userPolicyId); } - async update(userPolicyId: string, updates: Partial): Promise { - const payload = UserPolicyAdapter.toUpdatePayload(updates); - - const response = await fetch(`${this.BASE_URL}/${userPolicyId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + async update( + userPolicyId: string, + updates: Partial, + userId: string + ): Promise { + return updateUserPolicyAssociationV2(userPolicyId, userId, { + label: updates.label ?? null, }); - - if (response.status === 404) { - throw new Error('User-policy association not found'); - } - - if (!response.ok) { - throw new Error('Failed to update policy association'); - } - - const apiData = await response.json(); - return UserPolicyAdapter.fromApiResponse(apiData); } - async delete(userPolicyId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userPolicyId}`, { - method: 'DELETE', - }); - - if (response.status === 404) { - throw new Error('User-policy association not found'); - } - - if (!response.ok) { - throw new Error('Failed to delete association'); - } + async delete(userPolicyId: string, userId: string): Promise { + return deleteUserPolicyAssociationV2(userPolicyId, userId); } } @@ -170,7 +110,11 @@ export class LocalStoragePolicyStore implements UserPolicyStore { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(policies)); } - async update(userPolicyId: string, updates: Partial): Promise { + async update( + userPolicyId: string, + updates: Partial, + _userId: string + ): Promise { const policies = this.getStoredPolicies(); // Find by userPolicy.id (the "sup-" prefixed ID), NOT policyId @@ -193,7 +137,7 @@ export class LocalStoragePolicyStore implements UserPolicyStore { return updated; } - async delete(userPolicyId: string): Promise { + async delete(userPolicyId: string, _userId: string): Promise { const policies = this.getStoredPolicies(); const index = policies.findIndex((p) => p.id === userPolicyId); diff --git a/app/src/api/v2/userPolicyAssociations.ts b/app/src/api/v2/userPolicyAssociations.ts new file mode 100644 index 000000000..45ca1f771 --- /dev/null +++ b/app/src/api/v2/userPolicyAssociations.ts @@ -0,0 +1,232 @@ +/** + * User Policy Associations API - v2 Alpha + * + * Handles CRUD operations for user-policy associations via API v2 alpha. + * These associations link users to their saved policies. + * + * API Endpoints (from policyengine-api-v2-alpha): + * - POST /user-policies/ - Create association + * - GET /user-policies/?user_id=...&country_id=.. - List by user (optional country_id filter) + * - GET /user-policies/{user_policy_id} - Get by ID + * - PATCH /user-policies/{user_policy_id}?user_id=. - Update association (ownership verified) + * - DELETE /user-policies/{user_policy_id}?user_id=. - Delete association (ownership verified) + */ + +import { CountryId } from '@/libs/countries'; +import { UserPolicy } from '@/types/ingredients/UserPolicy'; + +import { API_V2_BASE_URL } from './taxBenefitModels'; + +// ============================================================================ +// Types for v2 Alpha API +// ============================================================================ + +/** + * API response format (snake_case) - matches backend UserPolicyRead + */ +export interface UserPolicyAssociationV2Response { + id: string; + user_id: string; + policy_id: string; + country_id: string; + label: string | null; + created_at: string; + updated_at: string; +} + +/** + * API request format for creating associations - matches backend UserPolicyCreate + */ +export interface UserPolicyAssociationV2CreateRequest { + user_id: string; + policy_id: string; + country_id: string; + label?: string | null; +} + +/** + * API request format for updating associations - matches backend UserPolicyUpdate + */ +export interface UserPolicyAssociationV2UpdateRequest { + label?: string | null; +} + +// ============================================================================ +// Conversion Functions +// ============================================================================ + +/** + * Convert app format to v2 API create request + */ +export function toV2CreateRequest( + userPolicy: Omit +): UserPolicyAssociationV2CreateRequest { + return { + user_id: userPolicy.userId, + policy_id: userPolicy.policyId, + country_id: userPolicy.countryId, + label: userPolicy.label ?? null, + }; +} + +/** + * Convert v2 API response to app format + */ +export function fromV2Response(response: UserPolicyAssociationV2Response): UserPolicy { + return { + id: response.id, + userId: response.user_id, + policyId: response.policy_id, + countryId: response.country_id as CountryId, + label: response.label ?? undefined, + createdAt: response.created_at, + updatedAt: response.updated_at ?? undefined, + isCreated: true, + }; +} + +// ============================================================================ +// API Functions +// ============================================================================ + +const BASE_PATH = '/user-policies'; + +/** + * Create a new user-policy association + * POST /user-policies/ + */ +export async function createUserPolicyAssociationV2( + userPolicy: Omit +): Promise { + const url = `${API_V2_BASE_URL}${BASE_PATH}/`; + const body = toV2CreateRequest(userPolicy); + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create policy association: ${res.status} ${errorText}`); + } + + const json: UserPolicyAssociationV2Response = await res.json(); + return fromV2Response(json); +} + +/** + * Fetch associations by user ID, optionally filtered by country + * GET /user-policies/?user_id=...&country_id=... + */ +export async function fetchUserPolicyAssociationsV2( + userId: string, + countryId?: string +): Promise { + const params = new URLSearchParams({ user_id: userId }); + if (countryId) { + params.append('country_id', countryId); + } + + const url = `${API_V2_BASE_URL}${BASE_PATH}/?${params}`; + + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to fetch user policy associations: ${res.status} ${errorText}`); + } + + const json: UserPolicyAssociationV2Response[] = await res.json(); + return json.map(fromV2Response); +} + +/** + * Fetch a single association by its ID + * GET /user-policies/{user_policy_id} + */ +export async function fetchUserPolicyAssociationByIdV2( + userPolicyId: string +): Promise { + const url = `${API_V2_BASE_URL}${BASE_PATH}/${userPolicyId}`; + + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (res.status === 404) { + return null; + } + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to fetch policy association: ${res.status} ${errorText}`); + } + + const json: UserPolicyAssociationV2Response = await res.json(); + return fromV2Response(json); +} + +/** + * Update an existing association + * PATCH /user-policies/{user_policy_id}?user_id=... + * + * Backend requires user_id as query param for ownership verification. + */ +export async function updateUserPolicyAssociationV2( + userPolicyId: string, + userId: string, + updates: UserPolicyAssociationV2UpdateRequest +): Promise { + const params = new URLSearchParams({ user_id: userId }); + const url = `${API_V2_BASE_URL}${BASE_PATH}/${userPolicyId}?${params}`; + + const res = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(updates), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to update policy association: ${res.status} ${errorText}`); + } + + const json: UserPolicyAssociationV2Response = await res.json(); + return fromV2Response(json); +} + +/** + * Delete an association + * DELETE /user-policies/{user_policy_id}?user_id=... + * + * Backend requires user_id as query param for ownership verification. + */ +export async function deleteUserPolicyAssociationV2( + userPolicyId: string, + userId: string +): Promise { + const params = new URLSearchParams({ user_id: userId }); + const url = `${API_V2_BASE_URL}${BASE_PATH}/${userPolicyId}?${params}`; + + const res = await fetch(url, { + method: 'DELETE', + }); + + // API returns 204 on success, 404 if not found (both are acceptable for delete) + if (!res.ok && res.status !== 404) { + const errorText = await res.text(); + throw new Error(`Failed to delete policy association: ${res.status} ${errorText}`); + } +} diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index a7cc19923..49b081ed6 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -1,8 +1,8 @@ -// Import auth hook here in future; for now, mocked out below import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { PolicyAdapter } from '@/adapters'; import { fetchPolicyById } from '@/api/policy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useUserId } from '@/hooks/useUserId'; import { Policy } from '@/types/ingredients/Policy'; import { ApiPolicyStore, LocalStoragePolicyStore } from '../api/policyAssociation'; import { queryConfig } from '../libs/queryConfig'; @@ -23,7 +23,6 @@ export const usePolicyAssociationsByUser = (userId: string) => { const store = useUserPolicyStore(); const countryId = useCurrentCountry(); const isLoggedIn = false; // TODO: Replace with actual auth check in future - // TODO: Should we determine user ID from auth context here? Or pass as arg? const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; return useQuery({ @@ -55,20 +54,16 @@ export const useCreatePolicyAssociation = () => { mutationFn: (userPolicy: Omit) => store.create(userPolicy), onSuccess: (newAssociation) => { // Invalidate and refetch related queries - // Note: country/model filtering now happens via Policy.tax_benefit_model_id, not UserPolicy queryClient.invalidateQueries({ - queryKey: policyAssociationKeys.byUser(newAssociation.userId.toString()), + queryKey: policyAssociationKeys.byUser(newAssociation.userId, newAssociation.countryId), }); queryClient.invalidateQueries({ - queryKey: policyAssociationKeys.byPolicy(newAssociation.policyId.toString()), + queryKey: policyAssociationKeys.byPolicy(newAssociation.policyId), }); // Update specific query cache queryClient.setQueryData( - policyAssociationKeys.specific( - newAssociation.userId.toString(), - newAssociation.policyId.toString() - ), + policyAssociationKeys.specific(newAssociation.userId, newAssociation.policyId), newAssociation ); }, @@ -78,6 +73,7 @@ export const useCreatePolicyAssociation = () => { export const useUpdatePolicyAssociation = () => { const store = useUserPolicyStore(); const queryClient = useQueryClient(); + const userId = useUserId(); return useMutation({ mutationFn: ({ @@ -86,13 +82,14 @@ export const useUpdatePolicyAssociation = () => { }: { userPolicyId: string; updates: Partial; - }) => store.update(userPolicyId, updates), + }) => store.update(userPolicyId, updates, userId), onSuccess: (updatedAssociation) => { - // Invalidate all related queries to trigger refetch - // Note: country/model filtering now happens via Policy.tax_benefit_model_id, not UserPolicy queryClient.invalidateQueries({ - queryKey: policyAssociationKeys.byUser(updatedAssociation.userId), + queryKey: policyAssociationKeys.byUser( + updatedAssociation.userId, + updatedAssociation.countryId + ), }); queryClient.invalidateQueries({ @@ -108,27 +105,23 @@ export const useUpdatePolicyAssociation = () => { }); }; -// Not yet implemented, but keeping for future use -/* -export const useDeleteAssociation = () => { +export const useDeletePolicyAssociation = () => { const store = useUserPolicyStore(); const queryClient = useQueryClient(); + const userId = useUserId(); return useMutation({ - mutationFn: ({ userId, policyId }: { userId: string; policyId: string }) => - store.delete(userId, policyId), - onSuccess: (_, { userId, policyId }) => { + mutationFn: ({ userPolicyId, policyId }: { userPolicyId: string; policyId: string }) => + store.delete(userPolicyId, userId).then(() => ({ policyId })), + onSuccess: (_, { userPolicyId, policyId }) => { queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byUser(userId) }); queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byPolicy(policyId) }); - queryClient.setQueryData( - policyAssociationKeys.specific(userId, policyId), - null - ); + queryClient.setQueryData(policyAssociationKeys.specific(userId, policyId), null); + queryClient.removeQueries({ queryKey: policyAssociationKeys.byId(userPolicyId) }); }, }); }; -*/ // Type for the combined data structure export interface UserPolicyWithAssociation { @@ -170,10 +163,10 @@ export const useUserPolicies = (userId: string) => { // This ensures cache consistency with useUserReports and useUserSimulations const policyQueries = useQueries({ queries: policyIds.map((policyId) => ({ - queryKey: policyKeys.byId(policyId.toString()), + queryKey: policyKeys.byId(policyId), queryFn: async () => { try { - const response = await fetchPolicyById(policyId.toString()); + const response = await fetchPolicyById(policyId); return PolicyAdapter.fromV2Response(response); } catch (error) { // Add context to help debug which policy failed diff --git a/app/src/libs/queryKeys.ts b/app/src/libs/queryKeys.ts index 799bab8fc..88f9afbe4 100644 --- a/app/src/libs/queryKeys.ts +++ b/app/src/libs/queryKeys.ts @@ -1,9 +1,9 @@ export const policyAssociationKeys = { all: ['policy-associations'] as const, byId: (userPolicyId: string) => [...policyAssociationKeys.all, 'id', userPolicyId] as const, - byUser: (userId: string, taxBenefitModelId?: string) => - taxBenefitModelId - ? ([...policyAssociationKeys.all, 'user_id', userId, 'model', taxBenefitModelId] as const) + byUser: (userId: string, countryId?: string) => + countryId + ? ([...policyAssociationKeys.all, 'user_id', userId, 'country', countryId] as const) : ([...policyAssociationKeys.all, 'user_id', userId] as const), byPolicy: (policyId: string) => [...policyAssociationKeys.all, 'policy_id', policyId] as const, specific: (userId: string, policyId: string) => diff --git a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx index 23e09e58c..4ff166e0c 100644 --- a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx +++ b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Group, Select, Stack, Text } from '@mantine/core'; -import { PolicyAdapter } from '@/adapters/PolicyAdapter'; +import { convertParametersToPolicyJson } from '@/adapters/conversionHelpers'; import { spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useHouseholdVariation } from '@/hooks/useHouseholdVariation'; @@ -55,11 +55,13 @@ export default function EarningsVariationSubPage({ const baselinePolicy = policies?.find((p) => p.id === simulations[0]?.policyId); const reformPolicy = simulations[1] && policies?.find((p) => p.id === simulations[1].policyId); - // Convert policies to API format + // Convert policies to API format for calculate-full endpoint const baselinePolicyData = baselinePolicy - ? PolicyAdapter.toCreationPayload(baselinePolicy).data + ? convertParametersToPolicyJson(baselinePolicy.parameters || []) + : {}; + const reformPolicyData = reformPolicy + ? convertParametersToPolicyJson(reformPolicy.parameters || []) : {}; - const reformPolicyData = reformPolicy ? PolicyAdapter.toCreationPayload(reformPolicy).data : {}; // Fetch baseline variation const { diff --git a/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx b/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx index aba197795..dbb7fea58 100644 --- a/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx +++ b/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx @@ -3,7 +3,7 @@ import type { Layout } from 'plotly.js'; import Plot from 'react-plotly.js'; import { Group, Radio, Stack, Text } from '@mantine/core'; import { useMediaQuery, useViewportSize } from '@mantine/hooks'; -import { PolicyAdapter } from '@/adapters/PolicyAdapter'; +import { convertParametersToPolicyJson } from '@/adapters/conversionHelpers'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useHouseholdVariation } from '@/hooks/useHouseholdVariation'; @@ -67,11 +67,13 @@ export default function MarginalTaxRatesSubPage({ const baselinePolicy = policies?.find((p) => p.id === simulations[0]?.policyId); const reformPolicy = simulations[1] && policies?.find((p) => p.id === simulations[1].policyId); - // Convert policies to API format + // Convert policies to API format for calculate-full endpoint const baselinePolicyData = baselinePolicy - ? PolicyAdapter.toCreationPayload(baselinePolicy).data + ? convertParametersToPolicyJson(baselinePolicy.parameters || []) + : {}; + const reformPolicyData = reformPolicy + ? convertParametersToPolicyJson(reformPolicy.parameters || []) : {}; - const reformPolicyData = reformPolicy ? PolicyAdapter.toCreationPayload(reformPolicy).data : {}; // Fetch baseline variation const { diff --git a/app/src/tests/fixtures/adapters/PolicyAdapterMocks.ts b/app/src/tests/fixtures/adapters/PolicyAdapterMocks.ts index f430a4ced..a7d35a906 100644 --- a/app/src/tests/fixtures/adapters/PolicyAdapterMocks.ts +++ b/app/src/tests/fixtures/adapters/PolicyAdapterMocks.ts @@ -1,5 +1,5 @@ import type { Policy } from '@/types/ingredients/Policy'; -import type { PolicyMetadata, PolicyMetadataParams } from '@/types/metadata/policyMetadata'; +import type { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; import type { Parameter } from '@/types/subIngredients/parameter'; export const TEST_POLICY_IDS = { @@ -17,36 +17,6 @@ export const TEST_PARAMETER_NAMES = { BENEFIT_AMOUNT: 'benefit_amount', } as const; -export const mockPolicyMetadata = (overrides?: Partial): PolicyMetadata => ({ - id: TEST_POLICY_IDS.POLICY_1, - country_id: TEST_COUNTRIES.US, - api_version: '1.0.0', - policy_hash: 'hash-123', - policy_json: { - tax_rate: { - '2024-01-01.2024-12-31': 0.25, - '2025-01-01.2025-12-31': 0.27, - }, - }, - ...overrides, -}); - -export const mockPolicyMetadataMultipleParams = (): PolicyMetadata => ({ - id: TEST_POLICY_IDS.POLICY_2, - country_id: TEST_COUNTRIES.UK, - api_version: '1.0.0', - policy_hash: 'hash-456', - policy_json: { - tax_rate: { - '2024-01-01.2024-12-31': 0.2, - }, - benefit_amount: { - '2024-01-01.2024-12-31': 1000, - '2025-01-01.2025-12-31': 1100, - }, - }, -}); - export const mockPolicyJson = (): PolicyMetadataParams => ({ tax_rate: { '2024-01-01.2024-12-31': 0.25, @@ -57,7 +27,6 @@ export const mockPolicyJson = (): PolicyMetadataParams => ({ export const mockPolicy = (overrides?: Partial): Policy => ({ id: '1', countryId: TEST_COUNTRIES.US, - apiVersion: '1.0.0', parameters: [ { name: TEST_PARAMETER_NAMES.TAX_RATE, diff --git a/app/src/tests/fixtures/adapters/userAssociationMocks.ts b/app/src/tests/fixtures/adapters/userAssociationMocks.ts index b68c1ca24..07ed18904 100644 --- a/app/src/tests/fixtures/adapters/userAssociationMocks.ts +++ b/app/src/tests/fixtures/adapters/userAssociationMocks.ts @@ -1,7 +1,7 @@ +import { UserPolicyAssociationV2Response } from '@/api/v2/userPolicyAssociations'; import { UserPolicy } from '@/types/ingredients/UserPolicy'; import { UserReport } from '@/types/ingredients/UserReport'; import { UserSimulation } from '@/types/ingredients/UserSimulation'; -import { UserPolicyMetadata } from '@/types/metadata/userPolicyMetadata'; import { UserReportCreationPayload, UserSimulationCreationPayload } from '@/types/payloads'; import { TEST_COUNTRIES, @@ -18,6 +18,7 @@ export const mockUserPolicyUS: UserPolicy = { id: 'user-policy-123', // Association ID from backend userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, + countryId: TEST_COUNTRIES.US, label: TEST_LABELS.MY_POLICY, createdAt: TEST_TIMESTAMPS.CREATED_AT, updatedAt: TEST_TIMESTAMPS.UPDATED_AT, @@ -33,19 +34,15 @@ export const mockUserPolicyUK: UserPolicy = { export const mockUserPolicyWithoutOptionalFields: Omit = { userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, + countryId: TEST_COUNTRIES.US, isCreated: true, }; -export const mockUserPolicyCreationPayload = { - user_id: TEST_USER_IDS.USER_123, - policy_id: TEST_POLICY_IDS.POLICY_789, - label: TEST_LABELS.MY_POLICY, -}; - -export const mockUserPolicyApiResponse: UserPolicyMetadata = { +export const mockUserPolicyApiResponse: UserPolicyAssociationV2Response = { id: 'user-policy-123', policy_id: TEST_POLICY_IDS.POLICY_789, user_id: TEST_USER_IDS.USER_123, + country_id: TEST_COUNTRIES.US, label: TEST_LABELS.MY_POLICY, created_at: TEST_TIMESTAMPS.CREATED_AT, updated_at: TEST_TIMESTAMPS.UPDATED_AT, diff --git a/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts b/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts index 2f9100062..c584bcabd 100644 --- a/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts @@ -18,6 +18,7 @@ export const mockUserPolicyAssociation1: UserPolicy = { id: 'assoc-1', userId: TEST_USER_ID, policyId: TEST_POLICY_ID_1, + countryId: TEST_COUNTRY_ID, label: 'Test Policy 1', createdAt: '2024-01-15T10:00:00Z', isCreated: true, @@ -27,6 +28,7 @@ export const mockUserPolicyAssociation2: UserPolicy = { id: 'assoc-2', userId: TEST_USER_ID, policyId: TEST_POLICY_ID_2, + countryId: TEST_COUNTRY_ID, label: 'Test Policy 2', createdAt: '2024-02-20T14:30:00Z', isCreated: true, @@ -34,21 +36,6 @@ export const mockUserPolicyAssociation2: UserPolicy = { export const mockUserPolicyAssociations = [mockUserPolicyAssociation1, mockUserPolicyAssociation2]; -// Mock policy metadata (API response format) -export const mockPolicyMetadata1 = { - id: 456, - country_id: TEST_COUNTRY_ID, - api_version: 'v1', - policy_json: {}, -}; - -export const mockPolicyMetadata2 = { - id: 789, - country_id: TEST_COUNTRY_ID, - api_version: 'v1', - policy_json: {}, -}; - // Mock hook return values export const createMockAssociationsHookReturn = () => ({ data: mockUserPolicyAssociations, diff --git a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts index 47ecafd5d..0e216468e 100644 --- a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts @@ -8,9 +8,7 @@ import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { UserSimulation } from '@/types/ingredients/UserSimulation'; import { MetadataState } from '@/types/metadata'; import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; -import { US_REGION_TYPES } from '@/types/regionTypes'; import { mockReport } from '../adapters/reportMocks'; import { TEST_USER_ID } from '../api/reportAssociationMocks'; import { DEFAULT_LOADING_STATES } from '../reducers/metadataReducerMocks'; @@ -51,7 +49,7 @@ export const mockSimulation2: Simulation = { isCreated: true, }; -// Mock Policy entities (matching PolicyAdapter.fromMetadata structure) +// Mock Policy entities (matching PolicyAdapter.fromV2Response structure) export const mockPolicy1: Policy = { id: TEST_POLICY_ID_1, countryId: TEST_COUNTRIES.US, @@ -105,6 +103,7 @@ export const mockUserPolicies: UserPolicy[] = [ id: 'user-pol-1', userId: TEST_USER_ID, policyId: TEST_POLICY_ID_1, + countryId: TEST_COUNTRIES.US, label: 'My Policy 1', createdAt: '2025-01-01T09:00:00Z', }, @@ -112,6 +111,7 @@ export const mockUserPolicies: UserPolicy[] = [ id: 'user-pol-2', userId: TEST_USER_ID, policyId: TEST_POLICY_ID_2, + countryId: TEST_COUNTRIES.US, label: 'My Policy 2', createdAt: '2025-01-02T09:00:00Z', }, @@ -148,30 +148,6 @@ export const mockSimulationMetadata2: SimulationMetadata = { policy_id: TEST_POLICY_ID_2, // policy-789 }; -/** - * @deprecated Use mockV2PolicyResponse1 for v2 API - */ -export const mockPolicyMetadata1: PolicyMetadata = { - id: TEST_POLICY_ID_1, - country_id: TEST_COUNTRIES.US, - api_version: 'v1', - policy_json: {}, - policy_hash: 'hash-456', - label: 'Test Policy 1', -}; - -/** - * @deprecated Use mockV2PolicyResponse2 for v2 API - */ -export const mockPolicyMetadata2: PolicyMetadata = { - id: TEST_POLICY_ID_2, - country_id: TEST_COUNTRIES.US, - api_version: 'v1', - policy_json: {}, - policy_hash: 'hash-789', - label: 'Test Policy 2', -}; - // V2 API Policy Responses export const mockV2PolicyResponse1: V2PolicyResponse = { id: TEST_POLICY_ID_1, diff --git a/app/src/tests/fixtures/utils/countParameterChangesMocks.ts b/app/src/tests/fixtures/utils/countParameterChangesMocks.ts deleted file mode 100644 index 0018fc406..000000000 --- a/app/src/tests/fixtures/utils/countParameterChangesMocks.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; - -export const mockPolicyWithNoJson: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: {}, - policy_hash: 'abc123', -}; - -export const mockPolicyWithOneParameterOneRange: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': { - '2024-01-01.2024-12-31': 3000, - }, - }, - policy_hash: 'abc123', -}; - -export const mockPolicyWithOneParameterMultipleRanges: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': { - '2024-01-01.2024-12-31': 3000, - '2025-01-01.2025-12-31': 3500, - '2026-01-01.2026-12-31': 4000, - }, - }, - policy_hash: 'abc123', -}; - -export const mockPolicyWithMultipleParameters: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': { - '2024-01-01.2024-12-31': 3000, - '2025-01-01.2025-12-31': 3500, - }, - 'gov.irs.credits.eitc.max': { - '2024-01-01.2024-12-31': 6000, - '2025-01-01.2025-12-31': 6500, - '2026-01-01.2026-12-31': 7000, - }, - 'gov.irs.income.standard_deduction': { - '2024-01-01.2024-12-31': 13850, - }, - }, - policy_hash: 'abc123', -}; - -export const mockPolicyWithNullParameter: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': null as any, - }, - policy_hash: 'abc123', -}; - -export const mockPolicyWithEmptyParameter: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': {}, - }, - policy_hash: 'abc123', -}; diff --git a/app/src/tests/unit/adapters/PolicyAdapter.test.ts b/app/src/tests/unit/adapters/PolicyAdapter.test.ts index 081bf812c..55f787719 100644 --- a/app/src/tests/unit/adapters/PolicyAdapter.test.ts +++ b/app/src/tests/unit/adapters/PolicyAdapter.test.ts @@ -1,120 +1,131 @@ import { describe, expect, it } from 'vitest'; import { PolicyAdapter } from '@/adapters/PolicyAdapter'; -import { - mockPolicy, - mockPolicyMetadata, - mockPolicyMetadataMultipleParams, - TEST_COUNTRIES, - TEST_PARAMETER_NAMES, - TEST_POLICY_IDS, -} from '@/tests/fixtures/adapters/PolicyAdapterMocks'; +import { V2PolicyResponse } from '@/api/policy'; +import { mockPolicy, TEST_PARAMETER_NAMES } from '@/tests/fixtures/adapters/PolicyAdapterMocks'; describe('PolicyAdapter', () => { - describe('fromMetadata', () => { - it('given policy metadata then converts to Policy', () => { + describe('fromV2Response', () => { + it('given v2 response then converts to Policy', () => { // Given - const metadata = mockPolicyMetadata(); + const response: V2PolicyResponse = { + id: 'policy-uuid-123', + name: 'Test policy', + description: 'A test policy', + tax_benefit_model_id: 'model-uuid-456', + created_at: '2025-01-15T10:00:00Z', + updated_at: '2025-01-15T10:00:00Z', + }; // When - const result = PolicyAdapter.fromMetadata(metadata); + const result = PolicyAdapter.fromV2Response(response); // Then - expect(result).toEqual({ - id: TEST_POLICY_IDS.POLICY_1, - countryId: TEST_COUNTRIES.US, - apiVersion: '1.0.0', - parameters: [ - { - name: TEST_PARAMETER_NAMES.TAX_RATE, - values: [ - { startDate: '2024-01-01', endDate: '2024-12-31', value: 0.25 }, - { startDate: '2025-01-01', endDate: '2025-12-31', value: 0.27 }, - ], - }, - ], - }); + expect(result.id).toBe('policy-uuid-123'); + expect(result.taxBenefitModelId).toBe('model-uuid-456'); + expect(result.parameters).toEqual([]); }); - it('given metadata with multiple parameters then converts all', () => { + it('given v2 response with null description then converts without error', () => { // Given - const metadata = mockPolicyMetadataMultipleParams(); + const response: V2PolicyResponse = { + id: 'policy-uuid-789', + name: 'Unnamed policy', + description: null, + tax_benefit_model_id: 'model-uuid-456', + created_at: '2025-01-15T10:00:00Z', + updated_at: '2025-01-15T10:00:00Z', + }; // When - const result = PolicyAdapter.fromMetadata(metadata); + const result = PolicyAdapter.fromV2Response(response); // Then - expect(result.parameters).toHaveLength(2); - expect(result.parameters?.[0].name).toBe(TEST_PARAMETER_NAMES.TAX_RATE); - expect(result.parameters?.[1].name).toBe(TEST_PARAMETER_NAMES.BENEFIT_AMOUNT); - expect(result.parameters?.[1].values).toHaveLength(2); + expect(result.id).toBe('policy-uuid-789'); + expect(result.parameters).toEqual([]); }); + }); - it('given empty policy_json then converts to empty parameters', () => { + describe('toV2CreationPayload', () => { + it('given policy with parameters then creates v2 payload', () => { // Given - const metadata = mockPolicyMetadata({ policy_json: {} }); + const policy = mockPolicy(); + const parametersMetadata = { + [TEST_PARAMETER_NAMES.TAX_RATE]: { + id: 'param-uuid-001', + parameter: TEST_PARAMETER_NAMES.TAX_RATE, + }, + }; + const taxBenefitModelId = 'model-uuid-456'; // When - const result = PolicyAdapter.fromMetadata(metadata); + const payload = PolicyAdapter.toV2CreationPayload( + policy, + parametersMetadata as any, + taxBenefitModelId, + 'My policy' + ); // Then - expect(result.parameters).toEqual([]); + expect(payload.name).toBe('My policy'); + expect(payload.tax_benefit_model_id).toBe('model-uuid-456'); + expect(payload.parameter_values).toHaveLength(2); + expect(payload.parameter_values[0]).toEqual({ + parameter_id: 'param-uuid-001', + value_json: 0.25, + start_date: '2024-01-01T00:00:00Z', + end_date: '2024-12-31T00:00:00Z', + }); }); - it('given UK policy then uses correct country', () => { + it('given no name then defaults to "Unnamed policy"', () => { // Given - const metadata = mockPolicyMetadata({ country_id: TEST_COUNTRIES.UK }); + const policy = mockPolicy({ parameters: [] }); // When - const result = PolicyAdapter.fromMetadata(metadata); + const payload = PolicyAdapter.toV2CreationPayload(policy, {}, 'model-id'); // Then - expect(result.countryId).toBe(TEST_COUNTRIES.UK); + expect(payload.name).toBe('Unnamed policy'); }); - }); - describe('toCreationPayload', () => { - it('given policy with parameters then creates payload', () => { + it('given policy with far-future end date then converts to null', () => { // Given - const policy = mockPolicy(); - - // When - const payload = PolicyAdapter.toCreationPayload(policy); - - // Then - expect(payload).toEqual({ - data: { - tax_rate: { - '2024-01-01.2024-12-31': 0.25, - '2025-01-01.2025-12-31': 0.27, + const policy = mockPolicy({ + parameters: [ + { + name: TEST_PARAMETER_NAMES.TAX_RATE, + values: [{ startDate: '2025-01-01', endDate: '9999-12-31', value: 0.3 }], }, - }, + ], }); - }); - - it('given policy with no parameters then creates empty payload', () => { - // Given - const policy = mockPolicy({ parameters: [] }); + const parametersMetadata = { + [TEST_PARAMETER_NAMES.TAX_RATE]: { + id: 'param-uuid-001', + parameter: TEST_PARAMETER_NAMES.TAX_RATE, + }, + }; // When - const payload = PolicyAdapter.toCreationPayload(policy); + const payload = PolicyAdapter.toV2CreationPayload( + policy, + parametersMetadata as any, + 'model-id' + ); // Then - expect(payload).toEqual({ - data: {}, - }); + expect(payload.parameter_values[0].end_date).toBeNull(); }); - it('given policy with undefined parameters then creates empty payload', () => { + it('given unknown parameter name then skips it', () => { // Given - const policy = mockPolicy({ parameters: undefined }); + const policy = mockPolicy(); + const parametersMetadata = {}; // No matching metadata // When - const payload = PolicyAdapter.toCreationPayload(policy); + const payload = PolicyAdapter.toV2CreationPayload(policy, parametersMetadata, 'model-id'); // Then - expect(payload).toEqual({ - data: {}, - }); + expect(payload.parameter_values).toHaveLength(0); }); }); }); diff --git a/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts b/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts deleted file mode 100644 index 619768258..000000000 --- a/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { UserPolicyAdapter } from '@/adapters/UserPolicyAdapter'; -import { - mockUserPolicyApiResponse, - mockUserPolicyCreationPayload, - mockUserPolicyUS, - mockUserPolicyWithoutOptionalFields, - TEST_LABELS, - TEST_POLICY_IDS, - TEST_TIMESTAMPS, - TEST_USER_IDS, -} from '@/tests/fixtures'; -import { UserPolicy } from '@/types/ingredients/UserPolicy'; - -describe('UserPolicyAdapter', () => { - describe('toCreationPayload', () => { - test('given UserPolicy with all fields then creates proper payload', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_789, - label: TEST_LABELS.MY_POLICY, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result).toEqual(mockUserPolicyCreationPayload); - }); - - test('given UserPolicy without label then creates payload without label', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_789, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.user_id).toBe(TEST_USER_IDS.USER_123); - expect(result.policy_id).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.label).toBeUndefined(); - }); - - test('given UserPolicy with numeric IDs then converts to strings', () => { - // Given - const userPolicy: Omit = { - userId: 123 as any, - policyId: 456 as any, - label: TEST_LABELS.MY_POLICY, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.user_id).toBe('123'); - expect(result.policy_id).toBe('456'); - }); - - test('given UserPolicy without label then includes undefined label', () => { - // Given - const userPolicy = mockUserPolicyWithoutOptionalFields; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.label).toBeUndefined(); - }); - }); - - describe('fromApiResponse', () => { - test('given API response with all fields then creates UserPolicy', () => { - // Given - const apiData = mockUserPolicyApiResponse; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result).toEqual(mockUserPolicyUS); - }); - - test('given API response with null label then creates UserPolicy with undefined label', () => { - // Given - const apiData = { - id: 'user-policy-456', - policy_id: TEST_POLICY_IDS.POLICY_789, - user_id: TEST_USER_IDS.USER_123, - label: null, - created_at: TEST_TIMESTAMPS.CREATED_AT, - updated_at: TEST_TIMESTAMPS.UPDATED_AT, - }; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result.id).toBe('user-policy-456'); - expect(result.userId).toBe(TEST_USER_IDS.USER_123); - expect(result.policyId).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.label).toBeUndefined(); - expect(result.createdAt).toBe(TEST_TIMESTAMPS.CREATED_AT); - expect(result.updatedAt).toBe(TEST_TIMESTAMPS.UPDATED_AT); - expect(result.isCreated).toBe(true); - }); - - test('given API response with string id then preserves id', () => { - // Given - const apiData = { - id: 'custom-association-id', - policy_id: TEST_POLICY_IDS.POLICY_789, - user_id: TEST_USER_IDS.USER_123, - label: TEST_LABELS.MY_POLICY, - created_at: TEST_TIMESTAMPS.CREATED_AT, - updated_at: TEST_TIMESTAMPS.UPDATED_AT, - }; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result.id).toBe('custom-association-id'); - }); - }); -}); diff --git a/app/src/tests/unit/api/policyAssociation.test.ts b/app/src/tests/unit/api/policyAssociation.test.ts index e4a58b78d..9b670bb4a 100644 --- a/app/src/tests/unit/api/policyAssociation.test.ts +++ b/app/src/tests/unit/api/policyAssociation.test.ts @@ -52,7 +52,7 @@ describe('ApiPolicyStore', () => { `${API_V2_BASE_URL}/user-policies/`, expect.objectContaining({ method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }) ); expect(result).toMatchObject({ @@ -67,7 +67,7 @@ describe('ApiPolicyStore', () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 500, - json: async () => ({}), + text: async () => 'Internal Server Error', }); // When/Then @@ -92,7 +92,7 @@ describe('ApiPolicyStore', () => { expect(fetch).toHaveBeenCalledWith( `${API_V2_BASE_URL}/user-policies/?user_id=user-123`, expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, + headers: { Accept: 'application/json' }, }) ); expect(result).toHaveLength(1); @@ -118,7 +118,7 @@ describe('ApiPolicyStore', () => { expect(fetch).toHaveBeenCalledWith( `${API_V2_BASE_URL}/user-policies/?user_id=user-123&country_id=us`, expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, + headers: { Accept: 'application/json' }, }) ); expect(result).toHaveLength(1); @@ -129,11 +129,12 @@ describe('ApiPolicyStore', () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 500, + text: async () => 'Internal Server Error', }); // When/Then await expect(store.findByUser('user-123')).rejects.toThrow( - 'Failed to fetch user associations' + 'Failed to fetch user policy associations' ); }); }); @@ -154,7 +155,7 @@ describe('ApiPolicyStore', () => { expect(fetch).toHaveBeenCalledWith( `${API_V2_BASE_URL}/user-policies/user-policy-abc123`, expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, + headers: { Accept: 'application/json' }, }) ); expect(result).toMatchObject({ @@ -183,11 +184,12 @@ describe('ApiPolicyStore', () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 500, + text: async () => 'Internal Server Error', }); // When/Then await expect(store.findById('user-policy-abc123')).rejects.toThrow( - 'Failed to fetch association' + 'Failed to fetch policy association' ); }); }); @@ -206,14 +208,14 @@ describe('ApiPolicyStore', () => { }); // When - const result = await store.update('user-policy-abc123', { label: 'Updated Label' }); + const result = await store.update('user-policy-abc123', { label: 'Updated Label' }, 'user-123'); // Then expect(fetch).toHaveBeenCalledWith( - `${API_V2_BASE_URL}/user-policies/user-policy-abc123`, + `${API_V2_BASE_URL}/user-policies/user-policy-abc123?user_id=user-123`, expect.objectContaining({ method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }) ); expect(result.label).toBe('Updated Label'); @@ -224,10 +226,11 @@ describe('ApiPolicyStore', () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 500, + text: async () => 'Internal Server Error', }); // When/Then - await expect(store.update('user-policy-abc123', { label: 'Updated Label' })).rejects.toThrow( + await expect(store.update('user-policy-abc123', { label: 'Updated Label' }, 'user-123')).rejects.toThrow( 'Failed to update policy association' ); }); @@ -242,11 +245,11 @@ describe('ApiPolicyStore', () => { }); // When - await store.delete('user-policy-abc123'); + await store.delete('user-policy-abc123', 'user-123'); // Then expect(fetch).toHaveBeenCalledWith( - `${API_V2_BASE_URL}/user-policies/user-policy-abc123`, + `${API_V2_BASE_URL}/user-policies/user-policy-abc123?user_id=user-123`, expect.objectContaining({ method: 'DELETE', }) @@ -258,11 +261,12 @@ describe('ApiPolicyStore', () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 500, + text: async () => 'Internal Server Error', }); // When/Then - await expect(store.delete('user-policy-abc123')).rejects.toThrow( - 'Failed to delete association' + await expect(store.delete('user-policy-abc123', 'user-123')).rejects.toThrow( + 'Failed to delete policy association' ); }); }); @@ -428,7 +432,7 @@ describe('LocalStoragePolicyStore', () => { const created = await store.create(mockPolicyInput1); // When - const result = await store.update(created.id!, { label: 'Updated Label' }); + const result = await store.update(created.id!, { label: 'Updated Label' }, 'user-123'); // Then expect(result.label).toBe('Updated Label'); @@ -441,7 +445,7 @@ describe('LocalStoragePolicyStore', () => { // Given - no policy created // When & Then - await expect(store.update('sup-nonexistent', { label: 'Updated Label' })).rejects.toThrow( + await expect(store.update('sup-nonexistent', { label: 'Updated Label' }, 'user-123')).rejects.toThrow( 'UserPolicy with id sup-nonexistent not found' ); }); @@ -452,7 +456,7 @@ describe('LocalStoragePolicyStore', () => { const beforeUpdate = new Date().toISOString(); // When - const result = await store.update(created.id!, { label: 'Updated Label' }); + const result = await store.update(created.id!, { label: 'Updated Label' }, 'user-123'); // Then expect(result.updatedAt).toBeDefined(); @@ -464,7 +468,7 @@ describe('LocalStoragePolicyStore', () => { const created = await store.create(mockPolicyInput1); // When - await store.update(created.id!, { label: 'Updated Label' }); + await store.update(created.id!, { label: 'Updated Label' }, 'user-123'); // Then const persisted = await store.findById(created.id!); @@ -477,7 +481,7 @@ describe('LocalStoragePolicyStore', () => { const created2 = await store.create(mockPolicyInput2); // When - await store.update(created1.id!, { label: 'Updated Label' }); + await store.update(created1.id!, { label: 'Updated Label' }, 'user-123'); // Then const updated = await store.findById(created1.id!); @@ -491,7 +495,7 @@ describe('LocalStoragePolicyStore', () => { const created = await store.create(mockPolicyInput1); // When - const result = await store.update(created.id!, { label: 'Updated Label' }); + const result = await store.update(created.id!, { label: 'Updated Label' }, 'user-123'); // Then expect(result.label).toBe('Updated Label'); diff --git a/app/src/tests/unit/utils/countParameterChanges.test.ts b/app/src/tests/unit/utils/countParameterChanges.test.ts index 47eeef65a..48db59472 100644 --- a/app/src/tests/unit/utils/countParameterChanges.test.ts +++ b/app/src/tests/unit/utils/countParameterChanges.test.ts @@ -1,71 +1,67 @@ import { describe, expect, test } from 'vitest'; -import { - mockPolicyWithEmptyParameter, - mockPolicyWithMultipleParameters, - mockPolicyWithNoJson, - mockPolicyWithNullParameter, - mockPolicyWithOneParameterMultipleRanges, - mockPolicyWithOneParameterOneRange, -} from '@/tests/fixtures/utils/countParameterChangesMocks'; -import { countParameterChanges } from '@/utils/countParameterChanges'; +import { countPolicyModifications } from '@/utils/countParameterChanges'; -describe('countParameterChanges', () => { +describe('countPolicyModifications', () => { test('given undefined policy then returns 0', () => { - // Given - const policy = undefined; - - // When - const result = countParameterChanges(policy); - - // Then - expect(result).toBe(0); + expect(countPolicyModifications(undefined)).toBe(0); }); - test('given policy with no policy_json then returns 0', () => { - // When - const result = countParameterChanges(mockPolicyWithNoJson); - - // Then - expect(result).toBe(0); + test('given null policy then returns 0', () => { + expect(countPolicyModifications(null)).toBe(0); }); - test('given policy with one parameter and one date range then returns 1', () => { - // When - const result = countParameterChanges(mockPolicyWithOneParameterOneRange); - - // Then - expect(result).toBe(1); + test('given policy with no parameters then returns 0', () => { + expect(countPolicyModifications({ parameters: undefined })).toBe(0); }); - test('given policy with one parameter and multiple date ranges then returns count of date ranges', () => { - // When - const result = countParameterChanges(mockPolicyWithOneParameterMultipleRanges); - - // Then - expect(result).toBe(3); + test('given policy with empty parameters then returns 0', () => { + expect(countPolicyModifications({ parameters: [] })).toBe(0); }); - test('given policy with multiple parameters then returns sum of all date ranges', () => { - // When - const result = countParameterChanges(mockPolicyWithMultipleParameters); - - // Then - expect(result).toBe(6); // 2 + 3 + 1 + test('given policy with one parameter and one value interval then returns 1', () => { + const policy = { + parameters: [ + { + name: 'tax_rate', + values: [{ startDate: '2024-01-01', endDate: '2024-12-31', value: 0.25 }], + }, + ], + }; + expect(countPolicyModifications(policy)).toBe(1); }); - test('given policy with parameter having null values then handles gracefully', () => { - // When - const result = countParameterChanges(mockPolicyWithNullParameter); - - // Then - expect(result).toBe(0); + test('given policy with one parameter and multiple value intervals then returns count', () => { + const policy = { + parameters: [ + { + name: 'tax_rate', + values: [ + { startDate: '2024-01-01', endDate: '2024-12-31', value: 0.25 }, + { startDate: '2025-01-01', endDate: '2025-12-31', value: 0.27 }, + { startDate: '2026-01-01', endDate: '2026-12-31', value: 0.3 }, + ], + }, + ], + }; + expect(countPolicyModifications(policy)).toBe(3); }); - test('given policy with empty parameter object then returns 0', () => { - // When - const result = countParameterChanges(mockPolicyWithEmptyParameter); - - // Then - expect(result).toBe(0); + test('given policy with multiple parameters then returns sum of all value intervals', () => { + const policy = { + parameters: [ + { + name: 'tax_rate', + values: [ + { startDate: '2024-01-01', endDate: '2024-12-31', value: 0.25 }, + { startDate: '2025-01-01', endDate: '2025-12-31', value: 0.27 }, + ], + }, + { + name: 'benefit_amount', + values: [{ startDate: '2024-01-01', endDate: '2024-12-31', value: 1000 }], + }, + ], + }; + expect(countPolicyModifications(policy)).toBe(3); }); }); diff --git a/app/src/types/metadata/policyMetadata.ts b/app/src/types/metadata/policyMetadata.ts index 06f0ee749..5ed2c0fe8 100644 --- a/app/src/types/metadata/policyMetadata.ts +++ b/app/src/types/metadata/policyMetadata.ts @@ -1,14 +1,3 @@ -import { countryIds } from '@/libs/countries'; - -export interface PolicyMetadata { - id: string; - country_id: (typeof countryIds)[number]; - label?: string; - api_version: string; - policy_json: PolicyMetadataParams; - policy_hash: string; -} - export interface PolicyMetadataParams { [param: string]: PolicyMetadataParamValues; } diff --git a/app/src/types/metadata/userPolicyMetadata.ts b/app/src/types/metadata/userPolicyMetadata.ts deleted file mode 100644 index 6d396a5fa..000000000 --- a/app/src/types/metadata/userPolicyMetadata.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * API response format for user policy associations - * Uses snake_case to match API conventions - * Matches backend UserPolicyRead schema - */ -export interface UserPolicyMetadata { - id: string; - user_id: string; - policy_id: string; - country_id: string; - label: string | null; - created_at: string; - updated_at: string; -} - -/** - * API creation payload format for user policy associations - * Uses snake_case to match API conventions - * Matches backend UserPolicyCreate schema - */ -export interface UserPolicyCreationMetadata { - user_id: string; - policy_id: string; - country_id: string; - label?: string | null; -} - -/** - * API update payload format for user policy associations - * Uses snake_case to match API conventions - * Matches backend UserPolicyUpdate schema - */ -export interface UserPolicyUpdateMetadata { - label?: string | null; -} diff --git a/app/src/types/payloads/PolicyCreationPayload.ts b/app/src/types/payloads/PolicyCreationPayload.ts deleted file mode 100644 index eacdb7aa4..000000000 --- a/app/src/types/payloads/PolicyCreationPayload.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; - -/** - * Payload format for creating a policy via the API - */ -export interface PolicyCreationPayload { - label?: string; - data: PolicyMetadataParams; -} diff --git a/app/src/types/payloads/UserPolicyCreationPayload.ts b/app/src/types/payloads/UserPolicyCreationPayload.ts deleted file mode 100644 index a9c35a353..000000000 --- a/app/src/types/payloads/UserPolicyCreationPayload.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { countryIds } from '@/libs/countries'; - -/** - * Payload format for creating a user-policy association via the API - * Note: This endpoint doesn't exist yet. Currently uses the same format as UserPolicyMetadata. - * In the future, we may create a separate type if the creation payload differs from the response format. - */ -export interface UserPolicyCreationPayload { - user_id: string; - policy_id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - created_at?: string; - updated_at?: string; -} diff --git a/app/src/types/payloads/index.ts b/app/src/types/payloads/index.ts index 6dbf3f5bc..bd10898fc 100644 --- a/app/src/types/payloads/index.ts +++ b/app/src/types/payloads/index.ts @@ -1,9 +1,7 @@ -export type { PolicyCreationPayload } from './PolicyCreationPayload'; export type { SimulationCreationPayload } from './SimulationCreationPayload'; export type { SimulationSetOutputPayload } from './SimulationSetOutputPayload'; export type { HouseholdCreationPayload } from './HouseholdCreationPayload'; export type { ReportCreationPayload } from './ReportCreationPayload'; export type { ReportSetOutputPayload } from './ReportSetOutputPayload'; -export type { UserPolicyCreationPayload } from './UserPolicyCreationPayload'; export type { UserReportCreationPayload } from './UserReportCreationPayload'; export type { UserSimulationCreationPayload } from './UserSimulationCreationPayload'; diff --git a/app/src/utils/countParameterChanges.ts b/app/src/utils/countParameterChanges.ts index e3cb1089b..95d845bc5 100644 --- a/app/src/utils/countParameterChanges.ts +++ b/app/src/utils/countParameterChanges.ts @@ -1,34 +1,10 @@ import { Policy } from '@/types/ingredients/Policy'; -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; /** - * Counts the number of parameter changes in a policy metadata object. - * Each parameter can have multiple date ranges, and each date range counts as one change. - * - * @param policy - The policy metadata to count parameter changes for - * @returns The total number of parameter changes across all parameters - */ -export const countParameterChanges = (policy: PolicyMetadata | undefined): number => { - if (!policy?.policy_json) { - return 0; - } - - let count = 0; - - for (const paramName in policy.policy_json) { - if (policy.policy_json[paramName]) { - count += Object.keys(policy.policy_json[paramName]).length; - } - } - - return count; -}; - -/** - * Counts the number of value intervals (parameter modifications) in a Redux Policy object. + * Counts the number of value intervals (parameter modifications) in a Policy object. * Each value interval in each parameter counts as one modification. * - * @param policy - The Redux policy to count modifications for + * @param policy - The policy to count modifications for * @returns The total number of value intervals across all parameters */ export const countPolicyModifications = (policy: Policy | undefined | null): number => {