diff --git a/README.md b/README.md index a638141c3..7b70f4297 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,43 @@ Format code: make format ``` +## Sign-in and saved work (v3 workspace) + +The v3 workspace (the flagship shell: Ask, Build, Reforms, reports and the +account page) can sign users in with social sign-on (Auth0) and save their +reforms and reports to their PolicyEngine account through the PolicyEngine +API (`/v2/me` and `/v2/user-*`). It is off unless all of these are set at +build time (see `calculator-app/.env.example`), alongside +`NEXT_PUBLIC_FLAGSHIP_SHELL=true`: + +| Variable | Purpose | +| --- | --- | +| `NEXT_PUBLIC_AUTH_ENABLED` | `true` turns sign-in on | +| `NEXT_PUBLIC_AUTH0_DOMAIN` | Auth0 (custom) domain | +| `NEXT_PUBLIC_AUTH0_CLIENT_ID` | SPA application for the environment | +| `NEXT_PUBLIC_AUTH0_AUDIENCE` | PolicyEngine API resource server identifier | +| `NEXT_PUBLIC_API_URL` | PolicyEngine API origin (default `https://api.policyengine.org`); set to the staging API on staging | + +- On the US and UK workspace, saved reforms and reports always go to the + PolicyEngine API. Signed in, they belong to the account. Signed out, they + belong to this browser's unauthenticated user: a random UUID made on the + first visit, kept in localStorage (`pe-browser-user-id`) with no expiry, + and sent as the `PolicyEngine-Unauthenticated-User` header. It is the + credential for that work, so it never goes in a URL or a log. +- A saved reform is a pointer to its PolicyEngine API policy (with its + name, baseline and origin); its changes are read back from the policy. + Running a report creates its policy, simulations and report in the API. +- Signing in upgrades the browser's unauthenticated user + (`POST /v2/me/upgrade`): on a first sign-in it becomes the account, so + everything saved stays; for an existing account its work moves into that + account. The browser then starts over with a new UUID. A failed upgrade + keeps the UUID, so the next sign-in tries again. +- With sign-in off, and outside the US and UK, saved work stays in this + browser, in v3's own keys. +- The legacy calculator (policies, households, simulations and reports + built outside the workspace) has no sign-in and keeps its saved work in + localStorage, as before. v3 never reads or writes that storage. + ## Project structure ``` diff --git a/app/package.json b/app/package.json index 1aec39bfe..f583b6b47 100644 --- a/app/package.json +++ b/app/package.json @@ -35,7 +35,7 @@ "chromatic": "VITE_APP_MODE=website chromatic --only-changed --build-script-name='storybook:build'" }, "dependencies": { - "policyengine-household-wizard": "^0.1.0", + "@auth0/auth0-react": "2.27.0", "@policyengine/ui-kit": "^0.4.0", "@reduxjs/toolkit": "^2.8.2", "@tabler/icons-react": "^3.34.0", @@ -53,6 +53,7 @@ "html-to-image": "^1.11.13", "jsonp": "^0.2.1", "lucide-react": "^0.575.0", + "policyengine-household-wizard": "^0.1.0", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/app/src/adapters/ReformAdapter.ts b/app/src/adapters/ReformAdapter.ts deleted file mode 100644 index 96b9294c1..000000000 --- a/app/src/adapters/ReformAdapter.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { clampStoreText } from '@/libs/flagship/storeText'; -import { Reform } from '@/types/ingredients/Reform'; -import { - ReformCreationMetadata, - ReformMetadata, - ReformParameterMetadata, - ReformUpdateMetadata, -} from '@/types/metadata/reformMetadata'; -import { Parameter } from '@/types/subIngredients/parameter'; - -/** - * Adapter for converting between Reform and API formats - */ -export class ReformAdapter { - private static parameterToMetadata(parameter: Parameter): ReformParameterMetadata { - return { - name: parameter.name, - values: parameter.values.map((interval) => ({ - start_date: interval.startDate, - end_date: interval.endDate, - value: interval.value, - })), - }; - } - - private static parameterFromMetadata(metadata: ReformParameterMetadata): Parameter { - return { - name: metadata.name, - values: metadata.values.map((interval) => ({ - startDate: interval.start_date, - endDate: interval.end_date, - value: interval.value, - })), - }; - } - - /** - * Convert Reform to API creation payload - * Handles camelCase to snake_case conversion - */ - static toCreationPayload( - reform: Omit - ): ReformCreationMetadata { - return { - country_id: reform.countryId, - label: clampStoreText(reform.label ?? null), - policy_id: reform.policyId ?? null, - parameters: reform.parameters.map(ReformAdapter.parameterToMetadata), - baseline: reform.baseline, - provenance: { - source: reform.provenance.source, - ref: reform.provenance.ref ?? null, - confidence: reform.provenance.confidence ?? null, - }, - }; - } - - /** - * Convert partial Reform updates to API update payload - */ - static toUpdatePayload( - updates: Partial> - ): ReformUpdateMetadata { - const payload: ReformUpdateMetadata = {}; - if (updates.label !== undefined) { - payload.label = clampStoreText(updates.label); - } - if (updates.policyId !== undefined) { - payload.policy_id = updates.policyId; - } - if (updates.parameters !== undefined) { - payload.parameters = updates.parameters.map(ReformAdapter.parameterToMetadata); - } - if (updates.baseline !== undefined) { - payload.baseline = updates.baseline; - } - if (updates.provenance !== undefined) { - payload.provenance = { - source: updates.provenance.source, - ref: updates.provenance.ref ?? null, - confidence: updates.provenance.confidence ?? null, - }; - } - return payload; - } - - /** - * Convert API response to Reform - * Handles snake_case to camelCase conversion - * Explicitly coerces IDs to strings to handle JSON.parse type mismatches - */ - static fromApiResponse(apiData: ReformMetadata): Reform { - return { - id: String(apiData.id), - countryId: apiData.country_id, - label: apiData.label ?? undefined, - policyId: apiData.policy_id != null ? String(apiData.policy_id) : undefined, - parameters: apiData.parameters.map(ReformAdapter.parameterFromMetadata), - baseline: apiData.baseline, - provenance: { - source: apiData.provenance.source, - ref: apiData.provenance.ref ?? undefined, - confidence: apiData.provenance.confidence ?? undefined, - }, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, - }; - } -} diff --git a/app/src/api/flagshipReportStore.ts b/app/src/api/flagshipReportStore.ts index 661c85882..8edc125c4 100644 --- a/app/src/api/flagshipReportStore.ts +++ b/app/src/api/flagshipReportStore.ts @@ -1,33 +1,40 @@ -import { CountryId } from '@/libs/countries'; import { - addOwnedItem, - editTokenHeaders, - findOwnedReportByApiReportId, - isUuid, - ItemNotOwnedError, - readOwnedItems, -} from '@/libs/flagship/ownedItems'; + AccountAssociationRoutes, + isAccountId, + isLegacyId, + type ApiStoreContext, +} from '@/api/v2Associations'; +import { CountryId } from '@/libs/countries'; import { parseReportRecord, - parseReportRow, readableReports, stampReportRecord, unwrapReport, + type ReportParseResult, } from '@/libs/flagship/reportSchema'; -import { ReportValidationSnapshot, validationToWire } from '@/libs/flagship/reportValidation'; -import { RunReportProvision } from '@/libs/flagship/runReport'; -import { clampStoreText } from '@/libs/flagship/storeText'; +import { + ReportValidationSnapshot, + validationFromWire, + validationToWire, +} from '@/libs/flagship/reportValidation'; +import type { RunReportProvision } from '@/libs/flagship/runReport'; +import { clampStoreText, MAX_STORE_NOTE_LENGTH } from '@/libs/flagship/storeText'; +import type { + ReportProvisionWire, + SharedReportItem, + UserReportCreateBody, + UserReportItem, +} from '@/types/v2User'; /** - * Store for flagship report records: a pointer to the computed output - * on the PolicyEngine API plus the provenance snapshot for the report - * header. Central Supabase-backed store first, localStorage fallback - * when it is unconfigured — same resilience pattern as the reform - * store, and the same ownership model: no user identifier on the - * server, this browser's index of the records it created, and an edit - * token on every change. + * Store for flagship report records: a pointer to the computed output on + * the PolicyEngine API plus the provenance snapshot for the report + * header. Signed out (and outside the US and UK) records live in + * localStorage; signed in, a record is the account's `/v2/user-reports` + * row for the report, carrying the v3 fields. */ export interface FlagshipReportRecord { + /** The account row's ID, which is the report's link (see reportLink); `lfr-…` in this browser. */ id: string; countryId: CountryId; /** Report id on api.policyengine.org — the pointer to the outputs. */ @@ -35,174 +42,247 @@ export interface FlagshipReportRecord { title: string | null; sourceNote: string | null; provisions: RunReportProvision[]; - year: string; + /** The simulated year; localStorage records only (the report itself carries it). */ + year?: string; createdAt: string; /** Saved reform this report came from, when known. */ reformId?: string | null; /** What validation matched this report against, pinned at match time. */ validation?: ReportValidationSnapshot | null; + /** Years in the budget window run with the report, from its year; none for one year. */ + budgetWindowYears?: number | null; + /** + * The saved reform the report was compared with, when not current law. + * Its name is the baseline's label, so no label is stored here. + */ + baselineReformId?: string | null; } export type NewFlagshipReport = Omit; +/** Newest first. */ +function newestFirst(records: FlagshipReportRecord[]): FlagshipReportRecord[] { + return [...records].sort((first, second) => second.createdAt.localeCompare(first.createdAt)); +} + export interface FlagshipReportStore { create: (report: NewFlagshipReport) => Promise; - /** The records this browser saved, newest first. */ + /** This session's records, newest first. */ findMine: (countryId?: string) => Promise; findById: (id: string) => Promise; - /** The record this browser saved for an API report, when it has one. */ + /** The record pointing at an API report, when this session has one. */ findByApiReportId: (apiReportId: string) => Promise; /** Pins the validation snapshot to a record. */ saveValidation: (id: string, snapshot: ReportValidationSnapshot) => Promise; } -class ReportStoreUnavailableError extends Error { - constructor() { - super('Report store unavailable'); - this.name = 'ReportStoreUnavailableError'; - } +/** A provision's growth settings on the wire, each only when set. */ +function growthToWire(provision: RunReportProvision): Partial { + return { + ...(provision.growth ? { growth: provision.growth } : {}), + ...(provision.growthStart !== undefined ? { growth_start: provision.growthStart } : {}), + ...(provision.growthBase !== undefined ? { growth_base: provision.growthBase } : {}), + ...(provision.growthRoundTo !== undefined ? { growth_round_to: provision.growthRoundTo } : {}), + ...(provision.growthRound ? { growth_round: provision.growthRound } : {}), + }; } -function throwIfUnavailable(response: Response): void { - if (response.status === 503 || response.status === 404) { - throw new ReportStoreUnavailableError(); - } +/** Provisions on the wire: snake_case, dated changes as start_date/end_date. */ +export function provisionsToWire(provisions: RunReportProvision[]): ReportProvisionWire[] { + return provisions.map((provision) => ({ + path: provision.path, + breadcrumb: provision.breadcrumb, + unit: provision.unit ?? null, + baseline_value: provision.baselineValue ?? null, + value: provision.value ?? null, + intervals: + provision.intervals && provision.intervals.length > 0 + ? provision.intervals.map((interval) => ({ + start_date: interval.startDate, + end_date: interval.endDate, + value: interval.value ?? null, + })) + : null, + ...growthToWire(provision), + })); } -/** The central store took its limit of new records this minute (429). */ -class ReportStoreBusyError extends Error { - constructor() { - super('Report store is taking no new records this minute'); - this.name = 'ReportStoreBusyError'; - } +export function provisionsFromWire( + wire: ReportProvisionWire[] | null | undefined +): RunReportProvision[] { + return (wire ?? []).map((provision) => ({ + path: provision.path, + breadcrumb: provision.breadcrumb, + unit: provision.unit ?? null, + baselineValue: provision.baseline_value, + value: provision.value, + ...(provision.intervals && provision.intervals.length > 0 + ? { + intervals: provision.intervals.map((interval) => ({ + startDate: interval.start_date, + endDate: interval.end_date, + value: interval.value as any, + })), + } + : {}), + ...(provision.growth ? { growth: provision.growth } : {}), + ...(provision.growth_start != null ? { growthStart: provision.growth_start } : {}), + ...(provision.growth_base != null ? { growthBase: provision.growth_base } : {}), + ...(provision.growth_round_to != null ? { growthRoundTo: provision.growth_round_to } : {}), + ...(provision.growth_round ? { growthRound: provision.growth_round } : {}), + })); } -/** Ids per batch read, so the query string stays well under URL limits. */ -const IDS_PER_REQUEST = 50; +/** + * An account row as a record, read through the report schema (see + * reportSchema) like a browser record, so a damaged field is a typed + * error instead of a crash in the UI. + */ +export function parseReportItem(item: UserReportItem): ReportParseResult { + return parseReportRecord( + stampReportRecord({ + id: item.id, + countryId: item.country_id, + apiReportId: String(item.legacy_report_id), + title: item.name, + sourceNote: item.source_note, + provisions: provisionsFromWire(item.provisions), + createdAt: item.created_at, + reformId: item.user_policy_id, + validation: validationFromWire(item.validation), + budgetWindowYears: item.budget_window_years ?? null, + baselineReformId: item.baseline_user_policy_id ?? null, + }) + ); +} -function newestFirst(a: FlagshipReportRecord, b: FlagshipReportRecord): number { - return b.createdAt.localeCompare(a.createdAt); +/** + * A shared report (`/v2/shared-reports`) as a record, read through the + * report schema like an account row. It carries no links to its owner's + * saved reforms: those stay private. + */ +export function parseSharedReportItem( + item: SharedReportItem +): ReportParseResult { + return parseReportRecord( + stampReportRecord({ + id: item.id, + countryId: item.country_id, + apiReportId: String(item.legacy_report_id), + title: item.name, + sourceNote: item.source_note, + provisions: provisionsFromWire(item.provisions), + createdAt: item.created_at, + reformId: null, + validation: validationFromWire(item.validation), + budgetWindowYears: item.budget_window_years ?? null, + baselineReformId: null, + }) + ); } -/** A central row as a record (see reportSchema); throws ReportSchemaError when unreadable. */ -function fromMetadata(data: unknown): FlagshipReportRecord { - return unwrapReport(parseReportRow(data)); +/** + * A report's link. An account row's ID opens for anyone, through + * `/v2/shared-reports`; a record kept only in this browser has nothing to + * share, so its link is the v1 report's number. + */ +export function reportLink(record: FlagshipReportRecord): string { + return isAccountId(record.id) ? record.id : record.apiReportId; } +/** + * This session's record a report link names: the account row by its ID, + * or, in this browser, the first record of the v1 report the link numbers. + */ +export function findReportRecordByLink( + store: FlagshipReportStore, + link: string +): Promise { + return isAccountId(link) ? store.findById(link) : store.findByApiReportId(link); +} + +/** An account row as a record; throws ReportSchemaError when unreadable. */ +export function recordFromItem(item: UserReportItem): FlagshipReportRecord { + return unwrapReport(parseReportItem(item)); +} + +/** A link to a saved reform, when it is one of the account's rows. */ +const accountLink = (reformId: string | null | undefined) => + isAccountId(reformId) ? reformId : null; + +/** + * Flagship report records as the account's `/v2/user-reports` rows. Only + * account IDs open: a record saved in this browser while signed out + * (`lfr-…`) answers BrowserOnlyIdError. + */ export class ApiFlagshipReportStore implements FlagshipReportStore { - private readonly BASE_URL = '/api/reports'; + private readonly routes: AccountAssociationRoutes; + + constructor(private readonly context: ApiStoreContext) { + this.routes = new AccountAssociationRoutes(context, 'user-reports', 'Report'); + } + + private creationBody(report: NewFlagshipReport): UserReportCreateBody { + // Links must be the account's own rows; a reform saved in this + // browser while signed out is not linked. + const userPolicyId = accountLink(report.reformId); + const baselineUserPolicyId = accountLink(report.baselineReformId); + return { + country_id: this.routes.writeCountry(report.countryId), + legacy_report_id: Number(report.apiReportId), + name: clampStoreText(report.title) || null, + source_note: clampStoreText(report.sourceNote, MAX_STORE_NOTE_LENGTH) || null, + provisions: provisionsToWire(report.provisions), + ...(userPolicyId ? { user_policy_id: userPolicyId } : {}), + ...(baselineUserPolicyId ? { baseline_user_policy_id: baselineUserPolicyId } : {}), + ...(report.budgetWindowYears && report.budgetWindowYears > 1 + ? { budget_window_years: report.budgetWindowYears } + : {}), + last_run_at: new Date().toISOString(), + }; + } async create(report: NewFlagshipReport): Promise { - const response = await fetch(this.BASE_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - country_id: report.countryId, - api_report_id: report.apiReportId, - // Shortened to the store's text limit, so a long bill title saves. - title: clampStoreText(report.title), - source_note: clampStoreText(report.sourceNote), - provisions: report.provisions.map((p) => ({ - path: p.path, - breadcrumb: p.breadcrumb, - unit: p.unit, - baseline_value: p.baselineValue, - value: p.value, - })), - year: report.year, - // A reform still in the localStorage fallback has no central row - // to point at. - reform_id: report.reformId && isUuid(report.reformId) ? report.reformId : null, - }), - }); - if (!response.ok) { - throwIfUnavailable(response); - if (response.status === 429) { - throw new ReportStoreBusyError(); - } - throw new Error('Failed to save the report record'); - } - const { edit_token: editToken, ...data } = await response.json(); - const record = fromMetadata(data); - addOwnedItem('reports', { - id: record.id, - countryId: record.countryId, - apiReportId: record.apiReportId, - editToken, - }); - return record; + return recordFromItem(await this.routes.create(this.creationBody(report))); } async findMine(countryId?: string): Promise { - const ids = readOwnedItems('reports', countryId).map((item) => item.id); - const records: FlagshipReportRecord[] = []; - for (let start = 0; start < ids.length; start += IDS_PER_REQUEST) { - const params = new URLSearchParams({ - ids: ids.slice(start, start + IDS_PER_REQUEST).join(','), - }); - const response = await fetch(`${this.BASE_URL}?${params}`); - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to load reports'); - } - // A row that can't be read is left out of the list, not fatal to it. - const rows: unknown = await response.json(); - records.push(...readableReports((Array.isArray(rows) ? rows : []).map(parseReportRow))); + if (countryId && countryId !== this.context.countryId) { + return []; } - return records.sort(newestFirst); + const items = await this.routes.list(); + // A row that can't be read is left out of the list, not fatal to it. + return newestFirst( + readableReports( + items.filter((item) => isLegacyId(item.legacy_report_id)).map(parseReportItem) + ) + ); } async findById(id: string): Promise { - const response = await fetch(`${this.BASE_URL}/${id}`); - if (response.status === 404) { - return null; - } - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to load the report'); - } - return fromMetadata(await response.json()); + const item = await this.routes.get(id); + return item && isLegacyId(item.legacy_report_id) ? recordFromItem(item) : null; } - /** - * Resolved through this browser's index: API report ids are sequential - * integers, so the server offers no lookup by them. - */ async findByApiReportId(apiReportId: string): Promise { - const owned = findOwnedReportByApiReportId(apiReportId); - return owned ? this.findById(owned.id) : null; + if (!isLegacyId(apiReportId)) { + return null; + } + const item = await this.routes.findFirst({ legacy_report_id: apiReportId }); + return item ? recordFromItem(item) : null; } async saveValidation( id: string, snapshot: ReportValidationSnapshot ): Promise { - const response = await fetch(`${this.BASE_URL}/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...editTokenHeaders('reports', id) }, - body: JSON.stringify({ validation: validationToWire(snapshot) }), - }); - // Item-level 404 means the record is gone, not that the store is. - if (response.status === 503) { - throw new ReportStoreUnavailableError(); - } - if (response.status === 403) { - throw new ItemNotOwnedError(); - } - if (!response.ok) { - throw new Error('Failed to save the validation snapshot'); - } - return fromMetadata(await response.json()); + return recordFromItem(await this.routes.patch(id, { validation: validationToWire(snapshot) })); } } -const LOCAL_KEY = 'pe-flagship-reports'; +export const FLAGSHIP_REPORTS_KEY = 'pe-flagship-reports'; -/** - * The browser-only fallback. Everything in it was saved in this - * browser, so all of it is "mine" — including records saved before the - * central store dropped user ids, which still carry a userId field. - */ +/** Records saved in this browser (accounts off, or outside the US and UK). */ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { /** * The entries as stored, of any schema version. Writes keep the ones @@ -210,7 +290,7 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { */ private readStored(): unknown[] { try { - const parsed = JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]'); + const parsed = JSON.parse(localStorage.getItem(FLAGSHIP_REPORTS_KEY) ?? '[]'); return Array.isArray(parsed) ? parsed : []; } catch { return []; @@ -219,7 +299,7 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { private write(entries: unknown[]): void { try { - localStorage.setItem(LOCAL_KEY, JSON.stringify(entries)); + localStorage.setItem(FLAGSHIP_REPORTS_KEY, JSON.stringify(entries)); } catch { // Reports simply won't persist locally. } @@ -258,9 +338,9 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { } async findMine(countryId?: string): Promise { - return this.read() - .filter((record) => !countryId || record.countryId === countryId) - .sort(newestFirst); + return newestFirst( + this.read().filter((record) => !countryId || record.countryId === countryId) + ); } async findById(id: string): Promise { @@ -286,93 +366,7 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { } } -let centralUnavailable = false; - -class ResilientFlagshipReportStore implements FlagshipReportStore { - constructor( - private readonly api: FlagshipReportStore = new ApiFlagshipReportStore(), - private readonly local: FlagshipReportStore = new LocalStorageFlagshipReportStore() - ) {} - - private async withFallback( - run: (store: FlagshipReportStore) => Promise, - whenUnavailable: (store: FlagshipReportStore) => Promise = run - ): Promise { - if (!centralUnavailable) { - try { - return await run(this.api); - } catch (error) { - if (!(error instanceof ReportStoreUnavailableError)) { - throw error; - } - centralUnavailable = true; - } - } - return whenUnavailable(this.local); - } - - // A busy central store (429) takes this one record locally and stays - // in use for the next. - async create(report: NewFlagshipReport): Promise { - try { - return await this.withFallback((store) => store.create(report)); - } catch (error) { - if (error instanceof ReportStoreBusyError) { - return this.local.create(report); - } - throw error; - } - } - - // Records already in the localStorage fallback stay there with their - // ids: lists show them beside the central ones, and pins stay local. - async findMine(countryId?: string): Promise { - const local = await this.local.findMine(countryId); - const central = await this.withFallback( - (store) => store.findMine(countryId), - async () => [] - ); - return [...central, ...local].sort(newestFirst); - } - - async findById(id: string): Promise { - const local = await this.local.findById(id); - if (local) { - return local; - } - return this.withFallback( - (store) => store.findById(id), - async () => null - ); - } - - async findByApiReportId(apiReportId: string): Promise { - const local = await this.local.findByApiReportId(apiReportId); - if (local) { - return local; - } - return this.withFallback( - (store) => store.findByApiReportId(apiReportId), - async () => null - ); - } - - async saveValidation( - id: string, - snapshot: ReportValidationSnapshot - ): Promise { - if (await this.local.findById(id)) { - return this.local.saveValidation(id, snapshot); - } - return this.withFallback((store) => store.saveValidation(id, snapshot)); - } -} - -/** Test hook: reset the remembered central-store availability. */ -export function resetFlagshipReportStoreAvailability(): void { - centralUnavailable = false; -} - -export function getFlagshipReportStore(): FlagshipReportStore { - return new ResilientFlagshipReportStore(); +/** The report store for a session: the account's when given one, else localStorage. */ +export function getFlagshipReportStore(account?: ApiStoreContext | null): FlagshipReportStore { + return account ? new ApiFlagshipReportStore(account) : new LocalStorageFlagshipReportStore(); } diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 157738ef2..3660709cd 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -1,201 +1,79 @@ -import { ReformAdapter } from '@/adapters/ReformAdapter'; +import { convertDateRangeMapToValueIntervals } from '@/adapters/conversionHelpers'; +import { fetchPolicyById } from '@/api/policy'; import { - addOwnedItem, - editTokenHeaders, - ItemNotOwnedError, - readOwnedItems, - removeOwnedItem, -} from '@/libs/flagship/ownedItems'; -import { getStoreBackend } from '@/libs/storeBackend'; -import { Reform } from '@/types/ingredients/Reform'; -import { ReformCreatedMetadata, ReformMetadata } from '@/types/metadata/reformMetadata'; - -export type NewReform = Omit; -export type ReformUpdates = Partial>; + AccountAssociationRoutes, + BrowserOnlyIdError, + isAccountId, + isLegacyId, + type ApiStoreContext, +} from '@/api/v2Associations'; +import { createReformPolicy } from '@/libs/flagship/runReport'; +import { clampStoreText } from '@/libs/flagship/storeText'; +import { Reform, ReformBaseline, ReformProvenance } from '@/types/ingredients/Reform'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; +import type { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; +import { Parameter } from '@/types/subIngredients/parameter'; +import { ValueInterval } from '@/types/subIngredients/valueInterval'; +import type { + ProvenanceWire, + UserPolicyCreateBody, + UserPolicyItem, + UserPolicyPatchBody, +} from '@/types/v2User'; /** - * Store for the flagship app's canonical reform objects. + * Store for the flagship app's reform objects. * - * Reforms are mutable content persisted centrally (unlike canonical - * Policy records, which live in the main PolicyEngine API and are - * immutable). The API implementation talks to /api/reforms, served by - * Next.js route handlers backed by Postgres. With no sign-in, the server - * stores no user identifier: "mine" is this browser's index of the ids - * it created, and each change carries the item's edit token. + * Either way, saving a reform materializes its parameters as a v1 policy + * (`POST /{country}/policy`, which dedupes identical ones), and a save + * that cannot create the policy fails. Signed out (and outside the US and + * UK) the reform, with its policy ID, lives in localStorage. Signed in, it + * is a row in the account's `/v2/user-policies` carrying the policy ID, + * the name, the baseline, and the provenance. */ +export type NewReform = Omit; +export type ReformUpdates = Partial>; + export interface ReformStore { create: (reform: NewReform) => Promise; - /** The reforms this browser saved, most recently updated first. */ + /** This session's reforms, most recently updated first. */ findMine: (countryId?: string) => Promise; findById: (reformId: string) => Promise; update: (reformId: string, updates: ReformUpdates) => Promise; delete: (reformId: string) => Promise; } -/** - * The central store reported itself unconfigured (503, e.g. no - * DATABASE_URL) or absent (collection-level 404). Distinct from real - * failures so the resilient store knows falling back is safe. - */ -export class ReformStoreUnavailableError extends Error { - constructor() { - super('Reform store unavailable'); - this.name = 'ReformStoreUnavailableError'; - } -} - -function throwIfUnavailable(response: Response): void { - if (response.status === 503 || response.status === 404) { - throw new ReformStoreUnavailableError(); - } +/** Most recently updated first. */ +function byRecentUpdate(reforms: Reform[]): Reform[] { + return [...reforms].sort((first, second) => + (second.updatedAt ?? second.createdAt ?? '').localeCompare( + first.updatedAt ?? first.createdAt ?? '' + ) + ); } -/** - * The central store took its limit of new reforms this minute (429). The - * resilient store saves that one reform locally and keeps using the - * central store. - */ -export class ReformStoreBusyError extends Error { +/** The reform is not in this store: deleted, or saved in the other sign-in state. */ +export class ReformNotFoundError extends Error { constructor() { - super('Reform store is taking no new reforms this minute'); - this.name = 'ReformStoreBusyError'; + super('Failed to update reform'); + this.name = 'ReformNotFoundError'; } } -/** Ids per batch read, so the query string stays well under URL limits. */ -const IDS_PER_REQUEST = 50; - -function byMostRecentlyUpdated(a: Reform, b: Reform): number { - return (b.updatedAt ?? b.createdAt ?? '').localeCompare(a.updatedAt ?? a.createdAt ?? ''); -} - -export class ApiReformStore implements ReformStore { - private readonly BASE_URL = '/api/reforms'; - - async create(reform: NewReform): Promise { - const payload = ReformAdapter.toCreationPayload(reform); - - const response = await fetch(this.BASE_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throwIfUnavailable(response); - if (response.status === 429) { - throw new ReformStoreBusyError(); - } - throw new Error('Failed to create reform'); - } - - const { edit_token: editToken, ...apiData }: ReformCreatedMetadata = await response.json(); - const created = ReformAdapter.fromApiResponse(apiData); - addOwnedItem('reforms', { id: created.id!, countryId: created.countryId, editToken }); - return created; - } - - async findMine(countryId?: string): Promise { - const ids = readOwnedItems('reforms', countryId).map((item) => item.id); - const reforms: Reform[] = []; - for (let start = 0; start < ids.length; start += IDS_PER_REQUEST) { - const params = new URLSearchParams({ - ids: ids.slice(start, start + IDS_PER_REQUEST).join(','), - }); - const response = await fetch(`${this.BASE_URL}?${params.toString()}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to fetch reforms'); - } - - const apiData: ReformMetadata[] = await response.json(); - reforms.push(...apiData.map((item) => ReformAdapter.fromApiResponse(item))); - } - return reforms.sort(byMostRecentlyUpdated); - } - - async findById(reformId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${reformId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - // Item-level 404 legitimately means "not found", so only 503 - // signals an unconfigured store here. - if (response.status === 503) { - throw new ReformStoreUnavailableError(); - } - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - throw new Error('Failed to fetch reform'); - } - - const apiData: ReformMetadata = await response.json(); - return ReformAdapter.fromApiResponse(apiData); - } - - async update(reformId: string, updates: ReformUpdates): Promise { - const payload = ReformAdapter.toUpdatePayload(updates); - - const response = await fetch(`${this.BASE_URL}/${reformId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...editTokenHeaders('reforms', reformId) }, - body: JSON.stringify(payload), - }); - - if (response.status === 503) { - throw new ReformStoreUnavailableError(); - } - - if (response.status === 403) { - throw new ItemNotOwnedError(); - } - - if (!response.ok) { - throw new Error('Failed to update reform'); - } - - const apiData: ReformMetadata = await response.json(); - return ReformAdapter.fromApiResponse(apiData); - } - - async delete(reformId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${reformId}`, { - method: 'DELETE', - headers: editTokenHeaders('reforms', reformId), - }); - - if (response.status === 503) { - throw new ReformStoreUnavailableError(); - } - - if (response.status === 403) { - throw new ItemNotOwnedError(); - } - - if (!response.ok) { - throw new Error('Failed to delete reform'); - } - - removeOwnedItem('reforms', reformId); - } -} - -/** - * The browser-only fallback. Everything in it was saved in this - * browser, so all of it is "mine" — including reforms saved before the - * central store dropped user ids, which still carry a userId field. - */ +/** Reforms saved in this browser, each with the ID of its v1 policy. */ export class LocalStorageReformStore implements ReformStore { private readonly STORAGE_KEY = 'reforms'; + private readonly materializePolicy: MaterializeReformPolicy; + + constructor(options: Pick = {}) { + this.materializePolicy = options.materializePolicy ?? materializeReformPolicy; + } async create(reform: NewReform): Promise { + const policyId = isLegacyId(reform.policyId) + ? String(reform.policyId) + : String(await this.materializePolicy(reform.countryId, reform.parameters)); + // Generate a unique ID for local storage // Format: "rf-[short-timestamp][random]" // Use base36 encoding for compactness @@ -206,6 +84,7 @@ export class LocalStorageReformStore implements ReformStore { const now = new Date().toISOString(); const newReform: Reform = { ...reform, + policyId, id: uniqueId, createdAt: now, updatedAt: now, @@ -216,9 +95,9 @@ export class LocalStorageReformStore implements ReformStore { } async findMine(countryId?: string): Promise { - return this.getStoredReforms() - .filter((r) => !countryId || r.countryId === countryId) - .sort(byMostRecentlyUpdated); + return byRecentUpdate( + this.getStoredReforms().filter((reform) => !countryId || reform.countryId === countryId) + ); } async findById(reformId: string): Promise { @@ -226,16 +105,27 @@ export class LocalStorageReformStore implements ReformStore { } async update(reformId: string, updates: ReformUpdates): Promise { + const existing = await this.findById(reformId); + if (!existing) { + throw new ReformNotFoundError(); + } + // Changed parameters are a new v1 policy; a rename keeps the policy. + const policyId = + updates.parameters !== undefined + ? String(await this.materializePolicy(existing.countryId, updates.parameters)) + : (updates.policyId ?? existing.policyId); + + // Read again after the request, so a change made meanwhile is kept. const reforms = this.getStoredReforms(); const index = reforms.findIndex((r) => r.id === reformId); - if (index === -1) { - throw new Error('Failed to update reform'); + throw new ReformNotFoundError(); } const updated: Reform = { ...reforms[index], ...updates, + policyId, updatedAt: new Date().toISOString(), }; reforms[index] = updated; @@ -265,106 +155,227 @@ export class LocalStorageReformStore implements ReformStore { } } -// Once the central store reports itself unavailable, stay on -// localStorage for the rest of the session rather than hammering it. -let centralStoreUnavailable = false; +/** A v1 policy's date-range map back to dated values, without metadata coercion. */ +function rawIntervals(dateValueMap: Record): ValueInterval[] { + return Object.entries(dateValueMap).map(([dateRange, value]) => { + const [startDate, endDate] = dateRange.split('.'); + return { startDate, endDate, value: value as ValueInterval['value'] }; + }); +} -/** Test hook: reset the session-level availability flag. */ -export function resetReformStoreAvailabilityForTesting(): void { - centralStoreUnavailable = false; +/** + * A v1 policy's `policy_json` as reform parameters. Uses the app's + * policy → parameter conversion (with value coercion) when the parameter's + * metadata is loaded, and the stored values as-is otherwise. + */ +export function parametersFromPolicyJson( + policyJson: PolicyMetadataParams, + parameterMetadata?: ParameterMetadataCollection | null +): Parameter[] { + return Object.entries(policyJson ?? {}).map(([name, dateValueMap]) => { + let values: ValueInterval[]; + try { + values = parameterMetadata?.[name] + ? convertDateRangeMapToValueIntervals(name, dateValueMap, parameterMetadata) + : rawIntervals(dateValueMap); + } catch { + values = rawIntervals(dateValueMap); + } + return { name, values }; + }); +} + +function provenanceToWire(provenance: ReformProvenance): ProvenanceWire { + return { + source: provenance.source, + ref: provenance.ref ?? null, + confidence: provenance.confidence ?? null, + }; } /** - * Tries the central store first and falls back to localStorage when it - * is unconfigured (503) or absent — so the flagship shell works before - * the database is provisioned. Real failures (500s, validation) still - * surface to the caller. Reforms already in the localStorage fallback - * stay there with their ids: lists show them beside the central ones, - * and their edits stay local. + * Whether an account policy row is a v3 reform. Reforms always carry + * provenance; policies saved from the legacy builder never do. */ -export class ResilientReformStore implements ReformStore { +function isAccountReform(item: UserPolicyItem): boolean { + return isLegacyId(item.legacy_policy_id) && item.provenance !== null; +} + +function provenanceFromWire(wire: ProvenanceWire | null): ReformProvenance { + if (!wire) { + return { source: 'manual' }; + } + return { + source: wire.source, + ...(wire.ref ? { ref: wire.ref } : {}), + ...(wire.confidence ? { confidence: wire.confidence } : {}), + }; +} + +/** Materializes parameters as an unlabelled v1 policy; returns its ID. */ +export type MaterializeReformPolicy = ( + countryId: string, + parameters: Parameter[] +) => Promise; + +export interface ApiReformStoreOptions { + /** Current parameter metadata, for coercing values read back from v1 policies. */ + getParameterMetadata?: () => ParameterMetadataCollection | null | undefined; + materializePolicy?: MaterializeReformPolicy; + /** Reads a v1 policy's parameter map. */ + fetchPolicyJson?: (countryId: string, policyId: string) => Promise; +} + +/** + * The v1 policy for a set of parameters, via the run bridge's policy + * creation. The reform's name stays on its user record, never the policy. + */ +export function materializeReformPolicy( + countryId: string, + parameters: Parameter[] +): Promise { + return createReformPolicy( + countryId as Reform['countryId'], + parameters.map((parameter) => ({ + path: parameter.name, + value: parameter.values[0]?.value, + intervals: parameter.values, + })) + ); +} + +/** A v1 policy's parameter map. v1 policies never change once created. */ +export async function fetchV1PolicyJson( + countryId: string, + policyId: string +): Promise { + return (await fetchPolicyById(countryId, policyId)).policy_json ?? {}; +} + +/** + * Reforms in the account (`/v2/user-policies`). Parameter edits create a + * new v1 policy and repoint the row (`legacy_policy_id`); a rename only + * patches the name. Only account IDs open: a reform saved in this browser + * while signed out (`rf-…`) answers BrowserOnlyIdError. + */ +export class ApiReformStore implements ReformStore { + private readonly routes: AccountAssociationRoutes; + private readonly getParameterMetadata: NonNullable; + private readonly materializePolicy: MaterializeReformPolicy; + private readonly fetchPolicyJson: NonNullable; + constructor( - private readonly api: ReformStore = new ApiReformStore(), - private readonly local: ReformStore = new LocalStorageReformStore() - ) {} - - private async withFallback( - viaApi: (store: ReformStore) => Promise, - viaLocal: (store: ReformStore) => Promise - ): Promise { - if (!centralStoreUnavailable) { - try { - return await viaApi(this.api); - } catch (error) { - if (!(error instanceof ReformStoreUnavailableError)) { - throw error; - } - centralStoreUnavailable = true; - } - } - return viaLocal(this.local); + private readonly context: ApiStoreContext, + options: ApiReformStoreOptions = {} + ) { + this.routes = new AccountAssociationRoutes(context, 'user-policies', 'Reform'); + this.getParameterMetadata = options.getParameterMetadata ?? (() => null); + this.materializePolicy = options.materializePolicy ?? materializeReformPolicy; + this.fetchPolicyJson = options.fetchPolicyJson ?? fetchV1PolicyJson; } - /** Whether a reform lives in the localStorage fallback rather than centrally. */ - private async isLocal(reformId: string): Promise { - return (await this.local.findById(reformId)) !== null; + /** The row for a reform; its parameters become a v1 policy first when they are not one yet. */ + private async creationBody(reform: NewReform): Promise { + const countryId = this.routes.writeCountry(reform.countryId); + const legacyPolicyId = isLegacyId(reform.policyId) + ? Number(reform.policyId) + : await this.materializePolicy(countryId, reform.parameters); + return { + country_id: countryId, + legacy_policy_id: legacyPolicyId, + ...(reform.label ? { name: clampStoreText(reform.label) } : {}), + baseline: reform.baseline, + provenance: provenanceToWire(reform.provenance), + }; } async create(reform: NewReform): Promise { - try { - return await this.withFallback( - (store) => store.create(reform), - (store) => store.create(reform) - ); - } catch (error) { - if (error instanceof ReformStoreBusyError) { - return this.local.create(reform); - } - throw error; - } + const item = await this.routes.create(await this.creationBody(reform)); + return this.toReform(item, reform.parameters); } async findMine(countryId?: string): Promise { - const local = await this.local.findMine(countryId); - const central = await this.withFallback( - (store) => store.findMine(countryId), - async () => [] + if (countryId && countryId !== this.context.countryId) { + return []; + } + const items = (await this.routes.list()).filter(isAccountReform); + // Each reform's changes come from its own v1 policy. One that fails to + // load is listed without them (flagged), rather than failing the list. + const loaded = await Promise.allSettled(items.map((item) => this.toReform(item))); + return byRecentUpdate( + loaded.map((outcome, index) => + outcome.status === 'fulfilled' + ? outcome.value + : { ...this.reformFromItem(items[index], []), parametersUnavailable: true } + ) ); - return [...central, ...local].sort(byMostRecentlyUpdated); } async findById(reformId: string): Promise { - const local = await this.local.findById(reformId); - if (local) { - return local; - } - return this.withFallback( - (store) => store.findById(reformId), - async () => null - ); + const item = await this.routes.get(reformId); + return item && isAccountReform(item) ? this.toReform(item) : null; } async update(reformId: string, updates: ReformUpdates): Promise { - if (await this.isLocal(reformId)) { - return this.local.update(reformId, updates); + // Refuse a browser ID before creating a v1 policy for it. + if (!isAccountId(reformId)) { + throw new BrowserOnlyIdError(); } - return this.withFallback( - (store) => store.update(reformId, updates), - (store) => store.update(reformId, updates) - ); + const body: UserPolicyPatchBody = {}; + if (updates.label !== undefined) { + body.name = clampStoreText(updates.label) || null; + } + if (updates.baseline !== undefined) { + body.baseline = updates.baseline; + } + if (updates.provenance !== undefined) { + body.provenance = provenanceToWire(updates.provenance); + } + if (updates.parameters !== undefined) { + body.legacy_policy_id = await this.materializePolicy( + this.context.countryId, + updates.parameters + ); + } else if (updates.policyId !== undefined && isLegacyId(updates.policyId)) { + body.legacy_policy_id = Number(updates.policyId); + } + const item = await this.routes.patch(reformId, body); + return this.toReform(item, updates.parameters); } - async delete(reformId: string): Promise { - if (await this.isLocal(reformId)) { - return this.local.delete(reformId); - } - return this.withFallback( - (store) => store.delete(reformId), - (store) => store.delete(reformId) - ); + delete(reformId: string): Promise { + return this.routes.delete(reformId); + } + + private async toReform(item: UserPolicyItem, knownParameters?: Parameter[]): Promise { + const parameters = + knownParameters ?? + parametersFromPolicyJson( + await this.fetchPolicyJson(item.country_id, String(item.legacy_policy_id)), + this.getParameterMetadata() + ); + return this.reformFromItem(item, parameters); + } + + private reformFromItem(item: UserPolicyItem, parameters: Parameter[]): Reform { + return { + id: item.id, + countryId: item.country_id, + label: item.name ?? undefined, + policyId: String(item.legacy_policy_id), + parameters, + baseline: (item.baseline ?? 'current-law') as ReformBaseline, + provenance: provenanceFromWire(item.provenance), + createdAt: item.created_at, + updatedAt: item.updated_at, + }; } } -export function getReformStore(): ReformStore { - return getStoreBackend() === 'api' ? new ResilientReformStore() : new LocalStorageReformStore(); +/** The reform store for a session: the account's when given one, else localStorage. */ +export function getReformStore( + account?: ApiStoreContext | null, + options?: ApiReformStoreOptions +): ReformStore { + return account ? new ApiReformStore(account, options) : new LocalStorageReformStore(options); } diff --git a/app/src/api/sharedReport.ts b/app/src/api/sharedReport.ts new file mode 100644 index 000000000..b62f63529 --- /dev/null +++ b/app/src/api/sharedReport.ts @@ -0,0 +1,33 @@ +import { createV2UserClient, isV2ApiError, type V2UserClient } from '@/api/v2UserClient'; +import type { CountryId } from '@/libs/countries'; +import { isAssociationCountry, type SharedReportItem, type V2ItemResult } from '@/types/v2User'; + +/** + * A saved report by its link, the report's user-report ID, read with no + * credential: anyone holding the link opens the report with its owner's + * title, source note, provisions and comparison. Null when no saved report + * has that ID in the country (deleted, or a bad link). + */ +export async function fetchSharedReport( + countryId: CountryId, + userReportId: string, + client: V2UserClient = createV2UserClient({ credentials: { kind: 'none' } }) +): Promise { + // Only the US and UK keep saved reports in the API. + if (!isAssociationCountry(countryId)) { + return null; + } + try { + const result = await client.request>( + 'GET', + `shared-reports/${encodeURIComponent(userReportId)}`, + { query: { country_id: countryId } } + ); + return result.item; + } catch (error) { + if (isV2ApiError(error, 404)) { + return null; + } + throw error; + } +} diff --git a/app/src/api/v2Associations.ts b/app/src/api/v2Associations.ts new file mode 100644 index 000000000..3a2c652e2 --- /dev/null +++ b/app/src/api/v2Associations.ts @@ -0,0 +1,147 @@ +import { isV2ApiError, V2ApiError, type V2Query, type V2UserClient } from '@/api/v2UserClient'; +import { + isAssociationCountry, + type AssociationCountryId, + type V2ItemResult, + type V2PageResult, +} from '@/types/v2User'; + +/** + * What every v3 account store needs: an authenticated client and the + * country its routes are called with (`country_id`). The server derives + * the owner from the token. + */ +export interface ApiStoreContext { + client: V2UserClient; + countryId: AssociationCountryId; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Account rows have UUIDs; items saved in this browser (`rf-…`, `lfr-…`) never do. */ +export function isAccountId(id: unknown): id is string { + return typeof id === 'string' && UUID_PATTERN.test(id); +} + +/** + * What an API store answers when handed the ID of something saved only in + * this browser (sign-in off, or before saved work moved to the API): only + * items saved through the API open. + */ +export class BrowserOnlyIdError extends Error { + constructor() { + super('This was saved only in this browser, so it cannot be opened here.'); + this.name = 'BrowserOnlyIdError'; + } +} + +/** + * v1 artifact IDs are positive integers on the wire; the app carries them + * as strings. Anything else cannot be saved to an account. + */ +export function isLegacyId(value: unknown): value is string | number { + if (typeof value !== 'string' && typeof value !== 'number') { + return false; + } + const text = String(value).trim(); + return /^\d+$/.test(text) && Number.isSafeInteger(Number(text)) && Number(text) >= 1; +} + +/** + * The operations every `/v2/user-*` resource supports, scoped to the + * store's country. Stores map items to app types; this class owns the + * wire calls. It accepts account IDs only. + */ +export class AccountAssociationRoutes { + constructor( + private readonly context: ApiStoreContext, + private readonly path: string, + private readonly what: string + ) {} + + get countryId(): AssociationCountryId { + return this.context.countryId; + } + + /** + * The country a write goes to: the item's own when it has account + * storage, otherwise the store's (the page's) country. + */ + writeCountry(itemCountryId: unknown): AssociationCountryId { + return isAssociationCountry(itemCountryId) ? itemCountryId : this.context.countryId; + } + + async create(body: TBody): Promise { + const result = await this.context.client.request>('POST', this.path, { + query: { country_id: body.country_id }, + body, + }); + return result.item; + } + + list(filters: V2Query = {}): Promise { + return this.context.client.listAll(this.path, { + ...filters, + country_id: this.context.countryId, + }); + } + + /** The first (oldest) row matching a list filter, or null. */ + async findFirst(filters: V2Query): Promise { + const page = await this.context.client.request>('GET', this.path, { + query: { ...filters, country_id: this.context.countryId, limit: 1 }, + }); + return page.items[0] ?? null; + } + + /** The row with this ID; null when it does not exist or belongs to someone else. */ + async get(id: string): Promise { + const accountId = this.requireAccountId(id); + try { + const result = await this.context.client.request>( + 'GET', + `${this.path}/${encodeURIComponent(accountId)}`, + { query: { country_id: this.context.countryId } } + ); + return result.item; + } catch (error) { + if (isV2ApiError(error, 404)) { + return null; + } + throw error; + } + } + + /** Applies only the supplied fields; an empty patch just reads the row. */ + async patch(id: string, body: object): Promise { + const accountId = this.requireAccountId(id); + const result = await this.context.client.request>( + Object.keys(body).length === 0 ? 'GET' : 'PATCH', + `${this.path}/${encodeURIComponent(accountId)}`, + { + query: { country_id: this.context.countryId }, + ...(Object.keys(body).length === 0 ? {} : { body }), + } + ); + return result.item; + } + + async delete(id: string): Promise { + const accountId = this.requireAccountId(id); + await this.context.client.request( + 'DELETE', + `${this.path}/${encodeURIComponent(accountId)}`, + { query: { country_id: this.context.countryId } } + ); + } + + private requireAccountId(id: string): string { + if (!id.trim()) { + throw new V2ApiError(404, `${this.what} with id ${id} not found`); + } + if (!isAccountId(id)) { + throw new BrowserOnlyIdError(); + } + return id; + } +} diff --git a/app/src/api/v2Me.ts b/app/src/api/v2Me.ts new file mode 100644 index 000000000..24b81949c --- /dev/null +++ b/app/src/api/v2Me.ts @@ -0,0 +1,56 @@ +import { UNAUTHENTICATED_USER_HEADER, type V2UserClient } from '@/api/v2UserClient'; +import type { + AssociationCountryId, + MeExportResult, + MeItem, + MeUpgradeResult, + V2ItemResult, +} from '@/types/v2User'; + +/** + * "Who am I": the PolicyEngine user behind the session. Signed in, the + * first call provisions the account, with `primary_country` set from + * `countryId`. + */ +export async function fetchMe( + client: V2UserClient, + countryId: AssociationCountryId +): Promise { + const result = await client.request>('GET', 'me', { + query: { country_id: countryId }, + }); + return result.item; +} + +/** Everything saved to the account, across all countries. */ +export function exportMe( + client: V2UserClient, + countryId: AssociationCountryId +): Promise { + return client.request('GET', 'me/export', { query: { country_id: countryId } }); +} + +/** Deletes the user, their identities, and every association (and the Auth0 user). */ +export async function deleteMe( + client: V2UserClient, + countryId: AssociationCountryId +): Promise { + await client.request('DELETE', 'me', { query: { country_id: countryId } }); +} + +/** + * Upgrades this browser's unauthenticated user at sign-in: on a first + * sign-in it becomes the account (same user, so every saved reform and + * report stays); for an existing account its work moves into that + * account. Sent with the session's token and the browser's user. + */ +export async function upgradeAccount( + client: V2UserClient, + countryId: AssociationCountryId, + browserUserId: string +): Promise { + return client.request('POST', 'me/upgrade', { + query: { country_id: countryId }, + headers: { [UNAUTHENTICATED_USER_HEADER]: browserUserId }, + }); +} diff --git a/app/src/api/v2UserClient.ts b/app/src/api/v2UserClient.ts new file mode 100644 index 000000000..11cbdddac --- /dev/null +++ b/app/src/api/v2UserClient.ts @@ -0,0 +1,212 @@ +import { BASE_URL } from '@/constants'; +import type { V2Envelope, V2PageResult } from '@/types/v2User'; + +/** + * Fetch wrapper for the PolicyEngine API's saved-work routes + * (`${BASE_URL}/v2/me*` and `/v2/user-*`). Every request carries who it + * is made as: a signed-in session's access token (`Authorization: Bearer …`), + * or, signed out, this browser's unauthenticated user + * (`PolicyEngine-Unauthenticated-User: `). The server derives the + * owner from that, so callers never send a user ID in a body or query. + * Shared reports (`/v2/shared-reports`) are read with no credential. + * Responses are the API's `{status, message, result}` envelope, unwrapped + * to `result`. + */ + +/** Names this browser's unauthenticated user on a signed-out request. */ +export const UNAUTHENTICATED_USER_HEADER = 'PolicyEngine-Unauthenticated-User'; + +/** Largest page the list routes serve. */ +export const V2_MAX_PAGE_SIZE = 500; + +/** A failed signed-in request: HTTP status (0 for a network failure) and the API's message. */ +export class V2ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'V2ApiError'; + this.status = status; + } +} + +export function isV2ApiError(error: unknown, status?: number): error is V2ApiError { + return error instanceof V2ApiError && (status === undefined || error.status === status); +} + +export type AccessTokenGetter = () => Promise; + +/** + * Who requests are made as: a signed-in session, this browser's + * unauthenticated user, or no one (routes anyone may call). + */ +export type V2Credentials = + | { kind: 'session'; getAccessToken: AccessTokenGetter } + | { kind: 'unauthenticated'; userId: string } + | { kind: 'none' }; + +type QueryValue = string | number | boolean | null | undefined; +export type V2Query = Record; + +export interface V2RequestOptions { + query?: V2Query; + body?: unknown; + /** Extra request headers (the upgrade sends the browser's user beside the session). */ + headers?: Record; +} + +export interface V2UserClient { + /** Sends a request and returns the envelope's `result` (undefined for 204). */ + request: ( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + options?: V2RequestOptions + ) => Promise; + /** Reads every page of a list route. */ + listAll: (path: string, query?: V2Query) => Promise; +} + +export interface CreateV2UserClientOptions { + credentials: V2Credentials; + baseUrl?: string; + fetchImpl?: typeof fetch; +} + +/** + * Messages for errors that arrive without one from the API. 409 and 422 + * cover several causes on the server, so their messages name the likely + * ones rather than asserting one. + */ +/** The token request itself failed (no network, a timeout); the session may be fine. */ +const SIGN_IN_UNREACHABLE_MESSAGE = + 'Could not reach the sign-in service. Check your connection and try again.'; + +const STATUS_MESSAGES: Record = { + 400: 'The request was not valid.', + 401: 'Your session has ended. Sign in again to continue.', + 403: 'This account cannot use saved work.', + 404: 'Not found.', + 409: 'This conflicts with what is already saved: it may exist already, or be kept up to date from elsewhere.', + 413: 'Too much data in one request.', + 422: 'This could not be saved: something in it is not valid, or something it refers to may no longer exist.', + 503: 'Saved work is temporarily unavailable.', +}; + +function queryString(query: V2Query | undefined): string { + if (!query) { + return ''; + } + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + params.set(key, String(value)); + } + } + const text = params.toString(); + return text ? `?${text}` : ''; +} + +async function readEnvelope(response: Response): Promise | null> { + try { + return (await response.json()) as V2Envelope; + } catch { + return null; + } +} + +/** The header naming who a request is made as. */ +async function credentialHeaders(credentials: V2Credentials): Promise> { + if (credentials.kind === 'none') { + return {}; + } + if (credentials.kind === 'unauthenticated') { + return { [UNAUTHENTICATED_USER_HEADER]: credentials.userId }; + } + let token: string | null; + try { + token = await credentials.getAccessToken(); + } catch { + throw new V2ApiError(0, SIGN_IN_UNREACHABLE_MESSAGE); + } + if (!token) { + throw new V2ApiError(401, STATUS_MESSAGES[401]); + } + return { Authorization: `Bearer ${token}` }; +} + +export function createV2UserClient({ + credentials, + baseUrl, + fetchImpl, +}: CreateV2UserClientOptions): V2UserClient { + async function request( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + { query, body, headers: extraHeaders }: V2RequestOptions = {} + ): Promise { + const headers: Record = { + Accept: 'application/json', + ...extraHeaders, + ...(await credentialHeaders(credentials)), + }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + // Resolved per request so building a client never touches configuration. + const url = `${baseUrl ?? BASE_URL}/v2/${path.replace(/^\/+/, '')}${queryString(query)}`; + let response: Response; + try { + response = await (fetchImpl ?? fetch)(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (error) { + throw new V2ApiError( + 0, + error instanceof Error ? `Network error: ${error.message}` : 'Network error' + ); + } + + if (response.status === 204) { + return undefined as R; + } + + const envelope = await readEnvelope(response); + if (!response.ok) { + throw new V2ApiError( + response.status, + envelope?.message || + STATUS_MESSAGES[response.status] || + `Request failed (${response.status})` + ); + } + if (!envelope || envelope.status !== 'ok') { + throw new V2ApiError( + response.status, + envelope?.message || 'Unexpected response from the API' + ); + } + return envelope.result as R; + } + + async function listAll(path: string, query: V2Query = {}): Promise { + const items: T[] = []; + let offset = 0; + // A generous cap keeps a misbehaving server from looping forever. + for (let page = 0; page < 100; page += 1) { + const result = await request>('GET', path, { + query: { ...query, offset, limit: V2_MAX_PAGE_SIZE }, + }); + items.push(...result.items); + if (!result.has_more || result.items.length === 0) { + break; + } + offset += result.items.length; + } + return items; + } + + return { request, listAll }; +} diff --git a/app/src/app.css b/app/src/app.css index 8f8af4404..023c22936 100644 --- a/app/src/app.css +++ b/app/src/app.css @@ -6,6 +6,7 @@ opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: none; diff --git a/app/src/components/auth/AccountUpgradeNotice.tsx b/app/src/components/auth/AccountUpgradeNotice.tsx new file mode 100644 index 000000000..d866ce619 --- /dev/null +++ b/app/src/components/auth/AccountUpgradeNotice.tsx @@ -0,0 +1,119 @@ +import type { ReactNode } from 'react'; +import { IconAlertTriangle, IconCheck, IconX } from '@tabler/icons-react'; +import { FLAGSHIP_TOP_BAR_HEIGHT } from '@/components/flagship/FlagshipTopBar'; +import { Button } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import { + dismissAccountUpgradeNotice, + useAccountUpgradeNotice, + type AccountUpgradeNotice as Notice, +} from '@/libs/auth/accountUpgrade'; + +/** The heading, icon and its tint for each outcome, in the v3 panel style. */ +const NOTICE_LOOK: Record< + Notice['kind'], + { title: string; icon: ReactNode; tint: string; iconColor: string } +> = { + success: { + title: 'Added to your account', + icon: , + tint: colors.primary[50], + iconColor: colors.primary[600], + }, + failure: { + title: 'Not added to your account yet', + icon: , + // The warning yellow, tinted, as on a failed report. + tint: `${colors.warning}33`, + iconColor: colors.text.warning, + }, +}; + +/** + * Reports the upgrade at sign-in (see runAccountUpgrade). Mounted once in + * the v3 workspace, so it stays up after the callback page has moved on; + * it floats below the top bar in the workspace's panel style. + */ +export default function AccountUpgradeNotice() { + const notice = useAccountUpgradeNotice(); + if (!notice) { + return null; + } + const look = NOTICE_LOOK[notice.kind]; + return ( +
+ + {look.icon} + +
+
+

+ {look.title} +

+

+ {notice.message} +

+
+ {notice.retry && ( +
+ +
+ )} +
+ +
+ ); +} diff --git a/app/src/components/auth/AuthMenu.tsx b/app/src/components/auth/AuthMenu.tsx new file mode 100644 index 000000000..cd19b05cb --- /dev/null +++ b/app/src/components/auth/AuthMenu.tsx @@ -0,0 +1,138 @@ +import { IconLogout, IconUserCircle } from '@tabler/icons-react'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { colors, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useAuthStatus, type AuthContextValue } from '@/libs/auth/AuthContext'; +import { isAssociationCountry } from '@/types/v2User'; + +interface AuthMenuProps { + /** 'inverse' for the teal calculator header; 'default' for light bars. */ + variant?: 'default' | 'inverse'; +} + +/** The name or email to show for the signed-in user (from the Auth0 ID token). */ +export function accountDisplayName(auth: Pick): string { + return auth.profile?.name || auth.profile?.email || 'Account'; +} + +function initialOf(name: string): string { + return (name.trim()[0] ?? '?').toUpperCase(); +} + +/** + * The v3 header's sign-in control: "Sign in" when signed out (with a note + * when the last session ended on its own), the user menu (My account, + * Sign out) when signed in, and nothing when sign-in is disabled or + * outside the US and UK. + */ +export default function AuthMenu({ variant = 'default' }: AuthMenuProps) { + const auth = useAuthStatus(); + const countryId = useCurrentCountry(); + const nav = useAppNavigate(); + const inverse = variant === 'inverse'; + + // Accounts hold US and UK work only; elsewhere v3 stays in this browser. + if (auth.status === 'disabled' || !isAssociationCountry(countryId)) { + return null; + } + + if (auth.status === 'signed_out') { + return ( +
+ {auth.sessionEnded && ( + + Your session ended. Sign in again to keep saving to your account. + + )} + +
+ ); + } + + const name = accountDisplayName(auth); + const email = auth.profile?.email; + + return ( + + + + + + +
{name}
+ {email && email !== name && ( +
+ {email} +
+ )} +
+ + nav.push(`/${countryId}/account`)}> + + My account + + auth.signOut()}> + + Sign out + +
+
+ ); +} diff --git a/app/src/components/flagship/FlagshipTopBar.tsx b/app/src/components/flagship/FlagshipTopBar.tsx index c804d6a55..7eb3dcb95 100644 --- a/app/src/components/flagship/FlagshipTopBar.tsx +++ b/app/src/components/flagship/FlagshipTopBar.tsx @@ -19,6 +19,7 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useDraftReform } from '@/libs/draftReform'; +import AuthMenu from '../auth/AuthMenu'; import FlagshipBrand from './FlagshipBrand'; const NAV_ITEMS = [ @@ -27,13 +28,14 @@ const NAV_ITEMS = [ ]; /** Height of the bar. */ -const FLAGSHIP_TOP_BAR_HEIGHT = 56; +export const FLAGSHIP_TOP_BAR_HEIGHT = 56; /** - * The flagship shell's navigation: brand, the two sections, and the - * website links, in one slim bar across the top. Two destinations do - * not earn a sidebar's width. On a phone the sections and the links - * fold into one menu, so the bar keeps room for the brand and the draft. + * The flagship shell's navigation: brand, then the two sections and the + * website links as one group, in one slim bar across the top. Two + * destinations do not earn a sidebar's width. On a phone the sections + * and the links fold into one menu, so the bar keeps room for the brand, + * the draft and the account menu. */ export default function FlagshipTopBar() { const nav = useAppNavigate(); @@ -121,115 +123,120 @@ export default function FlagshipTopBar() { ); })} - - - {draftCount > 0 && ( - - )} - {/* A phone's one menu: the sections, then the website links. */} - - - - - - {NAV_ITEMS.map(({ slug, label, icon: Icon }) => { - const active = isActive(slug); - return ( - goTo(slug)} - aria-current={active ? 'page' : undefined} - className="tw:min-h-10 tw:cursor-pointer" - style={ - active - ? { color: colors.primary[700], fontWeight: typography.fontWeight.semibold } - : undefined - } - > - - {label} + + + + + + {moreLinks.map((link) => ( + + + {link.label} + - ); - })} - - {moreLinks.map((link) => ( - - - {link.label} - - - ))} - - + ))} + + + - - + {/* The draft chip, a phone's menu and the sign-in / user menu sit together on the right. */} +
+ {draftCount > 0 && ( - - - {moreLinks.map((link) => ( - - - {link.label} - - - ))} - - + )} + + {/* A phone's one menu: the sections, then the website links. */} + + + + + + {NAV_ITEMS.map(({ slug, label, icon: Icon }) => { + const active = isActive(slug); + return ( + goTo(slug)} + aria-current={active ? 'page' : undefined} + className="tw:min-h-10 tw:cursor-pointer" + style={ + active + ? { color: colors.primary[700], fontWeight: typography.fontWeight.semibold } + : undefined + } + > + + {label} + + ); + })} + + {moreLinks.map((link) => ( + + + {link.label} + + + ))} + + + + {/* Sign in / user menu; renders nothing unless sign-in is enabled. */} + +
); } diff --git a/app/src/components/flagship/ReformReports.tsx b/app/src/components/flagship/ReformReports.tsx index 6a1f35dbe..5e36c3321 100644 --- a/app/src/components/flagship/ReformReports.tsx +++ b/app/src/components/flagship/ReformReports.tsx @@ -2,12 +2,14 @@ import dayjs from 'dayjs'; import { IconArrowRight, IconRefresh } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; import { fetchReportById } from '@/api/report'; +import { fetchSimulationById } from '@/api/simulation'; import { Badge, Button, Stack, Text } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import type { CountryId } from '@/libs/countries'; +import { ranAgainstReform, UNNAMED_COMPARISON } from '@/libs/flagship/reportComparison'; import { flagshipReportQueryKeys, readReportMeta } from '@/libs/flagship/runReport'; import type { RootState } from '@/store'; @@ -22,7 +24,7 @@ function modelVersionOf(output: unknown): string | null { } } -/** The API's record of a report: how it ended, and when. */ +/** The API's record of a report: its year, how it ended, and when. */ function useApiReport(countryId: CountryId, apiReportId: string) { return useQuery({ queryKey: flagshipReportQueryKeys.status(countryId, apiReportId), @@ -69,13 +71,52 @@ export function LatestReportBadge({ return In progress; } +/** + * The policy a report's baseline simulation ran, read only when asked + * (`simulationId` set): whether the report was compared with a reform its + * record no longer names. + */ +function useBaselinePolicyId(countryId: CountryId, simulationId: string | undefined) { + return useQuery({ + queryKey: flagshipReportQueryKeys.baselineSimulation(countryId, simulationId ?? ''), + queryFn: () => fetchSimulationById(countryId, simulationId!), + enabled: !!simulationId, + staleTime: Infinity, + retry: false, + }).data?.policy_id; +} + /** * What a report compared the reform with, when not current law: the same - * reform against a saved reform is another report, and reads as one. + * reform against a saved reform is another report, and reads as one. The + * run's details name it in the session that ran it; otherwise an account + * record links the saved reform, named from this session's reforms. A + * record that no longer links one (the reform was deleted, or the report + * recomputed someone else's) shows its comparison by its baseline policy. */ -function ReportBaseline({ apiReportId }: { apiReportId: string }) { - const baseline = readReportMeta(apiReportId)?.baseline; - if (!baseline) { +function ReportBaseline({ + countryId, + report, + baselineSimulationId, + reformName, +}: { + countryId: CountryId; + report: FlagshipReportRecord; + baselineSimulationId: string | undefined; + reformName?: (reformId: string) => string | null | undefined; +}) { + const namedLabel = + readReportMeta(reportLink(report))?.baseline?.label ?? + (report.baselineReformId ? reformName?.(report.baselineReformId) || UNNAMED_COMPARISON : null); + const currentLawPolicyId = useSelector((state: RootState) => state.metadata.currentLawId); + const baselinePolicyId = useBaselinePolicyId( + countryId, + namedLabel ? undefined : baselineSimulationId + ); + const label = + namedLabel ?? + (ranAgainstReform(baselinePolicyId, currentLawPolicyId) ? UNNAMED_COMPARISON : null); + if (!label) { return null; } return ( @@ -83,7 +124,7 @@ function ReportBaseline({ apiReportId }: { apiReportId: string }) { className="tw:truncate" style={{ minWidth: 0, fontSize: typography.fontSize.xs, color: colors.text.secondary }} > - Compared with {baseline.label} + Compared with {label} ); } @@ -100,6 +141,7 @@ function ReportRow({ onOpen, onRerun, rerunning, + reformName, }: { countryId: CountryId; report: FlagshipReportRecord; @@ -107,8 +149,12 @@ function ReportRow({ onOpen: () => void; onRerun?: () => void; rerunning: boolean; + reformName?: (reformId: string) => string | null | undefined; }) { const api = useApiReport(countryId, report.apiReportId); + // A record saved through the API keeps no year (the report itself + // carries it), so it is read from the API's record. + const year = report.year ?? api?.year; const done = api?.status === 'complete'; const currentModel = useSelector((state: RootState) => state.metadata.version); // The same reform on the same model gives the same results: a re-run @@ -137,7 +183,7 @@ function ReportRow({ color: colors.text.primary, }} > - {report.year} report + {year ? `${year} report` : 'Report'} - + @@ -189,6 +240,7 @@ export default function ReformReports({ reports, onRerun, rerunning = false, + reformName, }: { countryId: CountryId; reports: FlagshipReportRecord[]; @@ -196,6 +248,8 @@ export default function ReformReports({ onRerun?: (report: FlagshipReportRecord) => void; /** A run is starting; Re-run waits for it. */ rerunning?: boolean; + /** A saved reform's name, for the reform a report was compared with. */ + reformName?: (reformId: string) => string | null | undefined; }) { const nav = useAppNavigate(); const sorted = [...reports].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); @@ -227,9 +281,12 @@ export default function ReformReports({ countryId={countryId} report={report} first={index === 0} - onOpen={() => nav.push(`/${countryId}/report/${report.apiReportId}`)} + // A report opens at its link: the account row's ID, which anyone + // can open, or in this browser the v1 report's number. + onOpen={() => nav.push(`/${countryId}/report/${reportLink(report)}`)} onRerun={onRerun && (() => onRerun(report))} rerunning={rerunning} + reformName={reformName} /> ))} diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 931549d92..cf6486ae8 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { IconChartBar, IconPencil, IconX } from '@tabler/icons-react'; import { useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { getReformStore } from '@/api/reformStore'; import { Button, Input, @@ -18,9 +17,11 @@ import { CURRENT_YEAR, FOREVER } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useReformStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; import { loadProvisionsIntoDraft, + provisionAdjusts, provisionIntervals, sameParameterValue, } from '@/libs/draftReform'; @@ -104,6 +105,7 @@ function AdjustPanel({ const runReport = useRunFlagshipReport(); const nav = useAppNavigate(); const countryId = useCurrentCountry(); + const store = useReformStore(); const queryClient = useQueryClient(); const [removed, setRemoved] = useState>(new Set()); const [reconcileError, setReconcileError] = useState(null); @@ -156,6 +158,7 @@ function AdjustPanel({ (a, b) => a - b ); + const parameterMetadata = useSelector((state: RootState) => state.metadata.parameters); const active = provisions.filter((provision) => !removed.has(provision.path)); const isDirty = removed.size > 0 || @@ -229,6 +232,18 @@ function AdjustPanel({ }; }); + // Current law for a provision: the parameter's own history, or, before + // the metadata has it, the current law the report recorded, for every year. + const currentLawFor = (provision: RunReportProvision) => + parameterMetadata?.[provision.path]?.values ?? + (provision.baselineValue !== undefined && provision.baselineValue !== null + ? { '0001-01-01': provision.baselineValue } + : undefined); + // A recompute with nothing differing from current law has no report to run. + const changesLaw = adjustedProvisions().some((provision) => + provisionAdjusts(provision, currentLawFor(provision)) + ); + // Back to Build with the reform as the panel has it, to add to it there. const editInBuild = () => { const label = name.trim() || title; @@ -254,9 +269,8 @@ function AdjustPanel({ values: provisionIntervals(provision), })); - // Reconcile with this browser's saved reforms: reuse one that sets + // Reconcile with this session's saved reforms: reuse one that sets // the same values over the same dates, otherwise save this one. - const store = getReformStore(); const match = findMatchingReform(await store.findMine(countryId), parameters); // Only more or fewer years: the same reform, under its own name. @@ -552,10 +566,12 @@ function AdjustPanel({ The baseline policy could not be loaded, so a recompute cannot compare with it. )} - diff --git a/app/src/components/flagship/ReportComputing.tsx b/app/src/components/flagship/ReportComputing.tsx index d3b52f4de..c8d38548d 100644 --- a/app/src/components/flagship/ReportComputing.tsx +++ b/app/src/components/flagship/ReportComputing.tsx @@ -234,7 +234,10 @@ export function ReportComputingScreen({ ); } -/** A link this browser cannot resolve: the report id belongs to another browser's local records. */ +/** + * A link that no longer opens: a report's link is its saved report's ID, + * and older links carried the v1 report's number or a browser-only ID. + */ export function ReportUnresolvable() { return ( @@ -245,12 +248,11 @@ export function ReportUnresolvable() { color: colors.text.primary, }} > - This link only opens in the browser that ran the report. + Report not found. - It carries a local report id. Reports run from now on carry a shareable id in their link; - ask for the link again from the browser that created this one, or run the reform afresh from - Build. + This is an old report link, which no longer opens. Run the reform again from Build to get a + new link. ); diff --git a/app/src/components/flagship/reform/BaselinePicker.tsx b/app/src/components/flagship/reform/BaselinePicker.tsx index 4dc62a33b..2f3817ee1 100644 --- a/app/src/components/flagship/reform/BaselinePicker.tsx +++ b/app/src/components/flagship/reform/BaselinePicker.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { IconCheck, IconChevronDown, IconScale } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { getReformStore } from '@/api/reformStore'; import { Command, CommandEmpty, @@ -16,6 +15,7 @@ import { PopoverTrigger, } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; +import { useReformStore, useV3SavedWork } from '@/hooks/useFlagshipStores'; import { provisionAdjusts, provisionIntervals, type DraftReform } from '@/libs/draftReform'; import { sameReformParameters } from '@/libs/flagship/reformMatch'; import type { RunReportBaseline } from '@/libs/flagship/runReport'; @@ -36,9 +36,12 @@ export function savedReformName(reform: Pick): string { */ export function useDraftBaseline(draft: DraftReform) { const currentLaw = useSelector((state: RootState) => state.metadata.parameters); + // This session's saved reforms, cached under the key Your reforms uses. + const reformStore = useReformStore(); + const { scope } = useV3SavedWork(); const { data: saved, isPending } = useQuery({ - queryKey: ['reforms', draft.countryId], - queryFn: () => getReformStore().findMine(draft.countryId), + queryKey: ['reforms', scope, draft.countryId], + queryFn: () => reformStore.findMine(draft.countryId), }); // A reform that sets nothing runs as current law, so it is not offered. const options = (saved ?? []).filter( diff --git a/app/src/components/flagship/reform/DraftHeader.tsx b/app/src/components/flagship/reform/DraftHeader.tsx index 0e2a5206a..ee22ffef2 100644 --- a/app/src/components/flagship/reform/DraftHeader.tsx +++ b/app/src/components/flagship/reform/DraftHeader.tsx @@ -2,7 +2,9 @@ import { useState } from 'react'; import { IconCalendarEvent, IconChartBar, IconChevronDown, IconPencil } from '@tabler/icons-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { getReformStore } from '@/api/reformStore'; +import { ReformNotFoundError } from '@/api/reformStore'; +import { BrowserOnlyIdError } from '@/api/v2Associations'; +import { isV2ApiError } from '@/api/v2UserClient'; import { Button, Popover, @@ -17,6 +19,7 @@ import { } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; +import { useReformStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; import { clearDraftReform, @@ -24,22 +27,32 @@ import { draftToReform, draftYear, provisionAdjusts, - provisionsForYear, + provisionsToRun, setDraftBaseline, + setDraftEditingReform, setDraftLabel, setDraftPopulation, - setDraftSavedAs, setDraftWindowYears, setDraftYear, } from '@/libs/draftReform'; import { UNTITLED_REFORM } from '@/libs/flagship/draftLabels'; import { findMatchingReform } from '@/libs/flagship/reformMatch'; +import type { RunReportBaseline } from '@/libs/flagship/runReport'; import { getTaxYears } from '@/libs/metadataUtils'; import { RootState } from '@/store'; -import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; +import type { Reform } from '@/types/ingredients/Reform'; import BaselinePicker, { savedReformName, useDraftBaseline } from './BaselinePicker'; import PopulationPicker from './PopulationPicker'; +/** The reform the draft edits is not in this session's store. */ +function isMissingFromStore(error: unknown): boolean { + return ( + error instanceof BrowserOnlyIdError || + error instanceof ReformNotFoundError || + isV2ApiError(error, 404) + ); +} + /** * How the draft came about, for the report's own record. This is * provenance the report carries, not a label the page needs to wear. @@ -255,17 +268,20 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { * while the reform is longer than the screen, so the run is never a * scroll away, and settles in place under the table once the table ends. */ +/** What "Save and run" saves and runs, fixed when the button is pressed. */ +interface DraftRunRequest { + runDraft: DraftReform; + runBaseline: RunReportBaseline | undefined; +} + export function DraftRunBar({ draft }: { draft: DraftReform }) { const nav = useAppNavigate(); const queryClient = useQueryClient(); const runReport = useRunFlagshipReport(); + const reformStore = useReformStore(); const parameters = useSelector((state: RootState) => state.metadata.parameters); const regions = useSelector((state: RootState) => state.metadata.economyOptions.region); const taxYears = useSelector(getTaxYears); - const currentLawAt = (path: string, at: number) => { - const values = parameters?.[path]?.values; - return values ? new ValueIntervalCollection(values).getValueAtDate(`${at}-01-01`) : undefined; - }; // Saving keeps what the run models: the values that differ from current law. const currentLawValues = (path: string) => parameters?.[path]?.values; const year = draftYear(draft); @@ -280,74 +296,49 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) { // What the report compares the reform with: current law, or a saved reform. const baseline = useDraftBaseline(draft); - // A run keeps its reform: it saves the draft to Your reforms (or - // updates the saved reform it edits, or reuses a saved one that sets - // the same values), so the report links to it and both can be found - // again after the tab closes. A reused reform stays as it is, its name - // too; a new one becomes the reform the draft edits, so running again - // after a failed run updates it rather than saving another. Best-effort: - // the run goes ahead unsaved if the store fails. - const [savingForRun, setSavingForRun] = useState(false); - const saveForRun = async (): Promise => { - try { - const store = getReformStore(); - const payload = draftToReform(draft, currentLawValues); - if (draft.editingReformId) { - await store.update(draft.editingReformId, { + // Saving changes the reform the draft came from, or saves a new one. + // When that reform is no longer in this store (deleted, or another + // user's), the draft is saved as a new reform instead. + const saveReform = async (draftToSave: DraftReform): Promise => { + const payload = draftToReform(draftToSave, currentLawValues); + if (draftToSave.editingReformId) { + try { + return await reformStore.update(draftToSave.editingReformId, { label: payload.label, parameters: payload.parameters, provenance: payload.provenance, }); - return draft.editingReformId; + } catch (error) { + if (!isMissingFromStore(error)) { + throw error; + } } - const match = findMatchingReform(await store.findMine(draft.countryId), payload.parameters); - if (match?.id) { - return match.id; - } - const created = (await store.create(payload)).id; - if (created) { - setDraftSavedAs(created, draft.startedAt); - } - return created; - } catch { - return undefined; - } finally { - queryClient.invalidateQueries({ queryKey: ['reforms'] }); } + return reformStore.create(payload); }; - const runAndKeep = async () => { - setSavingForRun(true); - const reformId = await saveForRun(); - setSavingForRun(false); - await runReport.run( - draft.label.trim() || UNTITLED_REFORM, - SOURCE_NOTES[draft.source] ?? 'Draft reform', - // Described as they stand in the year the report simulates. - provisionsForYear(adjusted, year, currentLawAt), - reformId, - year, - { - fromDraft: true, - region, - windowYears: draft.windowYears, - baseline: baseline.forRun, + + // A run keeps its reform: it saves the draft first (updating the saved + // reform it edits, or reusing a saved one that sets the same values), + // so the report links to it and both can be found again. A reused + // reform stays as it is, its name too. A save that fails runs nothing. + const saveForRun = async ( + runDraft: DraftReform + ): Promise<{ reform: Reform; reused: boolean }> => { + if (!runDraft.editingReformId) { + const payload = draftToReform(runDraft, currentLawValues); + const match = findMatchingReform( + await reformStore.findMine(runDraft.countryId), + payload.parameters + ); + if (match?.id) { + return { reform: match, reused: true }; } - ); + } + return { reform: await saveReform(runDraft), reused: false }; }; const saveMutation = useMutation({ - mutationFn: async () => { - const store = getReformStore(); - const payload = draftToReform(draft, currentLawValues); - if (draft.editingReformId) { - return store.update(draft.editingReformId, { - label: payload.label, - parameters: payload.parameters, - provenance: payload.provenance, - }); - } - return store.create(payload); - }, + mutationFn: () => saveReform(draft), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reforms'] }); clearDraftReform(); @@ -355,6 +346,37 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) { }, }); + // A reform the run saved becomes the one the draft edits, so trying + // again after a failed run changes it rather than saving another. The + // run is the draft as it stood when "Save and run" was pressed, the one + // that was saved, whatever is edited while the save is in flight. + const saveAndRunMutation = useMutation({ + mutationFn: ({ runDraft }: DraftRunRequest) => saveForRun(runDraft), + onSuccess: ({ reform: saved, reused }, { runDraft, runBaseline }) => { + queryClient.invalidateQueries({ queryKey: ['reforms'] }); + if (!reused) { + setDraftEditingReform(saved.id!); + } + const runYear = draftYear(runDraft); + void runReport.run( + runDraft.label.trim() || UNTITLED_REFORM, + SOURCE_NOTES[runDraft.source] ?? 'Draft reform', + // Described as they stand in the year the report simulates. + provisionsToRun(runDraft.provisions, runYear, parameters), + saved.id, + runYear, + { + fromDraft: true, + region: runDraft.population?.region, + windowYears: runDraft.windowYears, + baseline: runBaseline, + } + ); + }, + }); + const saveError = saveMutation.error ?? saveAndRunMutation.error; + const starting = saveAndRunMutation.isPending || runReport.isRunning; + const empty = draft.provisions.length === 0; // The quick name box under the run bar, open while the reader types. const [naming, setNaming] = useState(false); @@ -430,16 +452,21 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) { @@ -544,12 +571,12 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) {

)} - {(runReport.error || saveMutation.isError) && ( + {(runReport.error || saveError) && (

- {runReport.error ?? 'Could not save the reform. Try again.'} + {saveError ? 'Could not save the reform. Try again.' : runReport.error}

)} diff --git a/app/src/constants.ts b/app/src/constants.ts index d08a8f708..bec7bc991 100644 --- a/app/src/constants.ts +++ b/app/src/constants.ts @@ -1,5 +1,12 @@ export const FOREVER = '2100-12-31'; -export const BASE_URL = 'https://api.policyengine.org'; +// PolicyEngine API origin. Builds set VITE_API_URL (Vite) or +// NEXT_PUBLIC_API_URL (calculator-app, mapped onto VITE_API_URL in +// next.config.ts) so staging talks to the staging API; production falls +// back to the public API. +export const BASE_URL = (import.meta.env.VITE_API_URL || 'https://api.policyengine.org').replace( + /\/+$/, + '' +); export const CURRENT_YEAR = '2026'; // Certified default US microdata dataset URI, pinned to the revision that diff --git a/app/src/hooks/useBillEconomy.ts b/app/src/hooks/useBillEconomy.ts index ebe09762e..f893f9b5c 100644 --- a/app/src/hooks/useBillEconomy.ts +++ b/app/src/hooks/useBillEconomy.ts @@ -62,7 +62,7 @@ export function useBillEconomy( const policy = useQuery({ queryKey: ['flagship-bill-policy', countryId, bill?.id, provisions], - queryFn: () => createReformPolicy(countryId, bill!.title, provisions, parameters), + queryFn: () => createReformPolicy(countryId, provisions, parameters), enabled: enabled && !!bill && provisions.length > 0, staleTime: Infinity, }); diff --git a/app/src/hooks/useFlagshipReport.ts b/app/src/hooks/useFlagshipReport.ts index 9254e883c..6ab5bda02 100644 --- a/app/src/hooks/useFlagshipReport.ts +++ b/app/src/hooks/useFlagshipReport.ts @@ -5,7 +5,6 @@ import { fetchReportById } from '@/api/report'; import { fetchSimulationById } from '@/api/simulation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useParameterMetadata } from '@/hooks/useParameterMetadata'; -import { useUserReportById } from '@/hooks/useUserReports'; import { isNotFoundError, retryUnlessNotFound } from '@/libs/flagship/loadError'; import { GC_TIME_5_MIN } from '@/libs/queryConfig'; import { Policy } from '@/types/ingredients/Policy'; @@ -15,9 +14,11 @@ import { policyKeys, reportKeys, simulationKeys } from '../libs/queryKeys'; import { extractUniqueIds, useParallelQueries } from './utils/queryUtils'; /** - * A flagship report URL carries the API report id, which anyone can - * resolve. Links minted before that carry the local association id - * ("sur-…"), which only the browser that ran the report can resolve. + * Whether an ID is a v1 report's number. With accounts, a flagship report + * URL carries the saved report's ID instead (see reportLink); without + * them, it carries the v1 report's number. Links from the legacy + * calculator carried its browser association ID ("sur-…"); v3 does not + * read that storage, so they no longer open. */ export function isApiReportId(id: string): boolean { return /^\d+$/.test(id); @@ -47,10 +48,10 @@ export interface FlagshipReportData { } /** - * Resolves a flagship report by either id form. The API form fetches the - * report, its simulations, and their policies straight from the - * PolicyEngine API, so a shared link opens with full provenance on any - * device; the association form keeps old links working. + * Resolves a flagship report by its API report id: the report, its + * simulations, and their policies come straight from the PolicyEngine + * API, so the report opens on any device. Any other id resolves to + * nothing. */ export function useFlagshipReport(id: string): FlagshipReportData { const country = useCurrentCountry(); @@ -58,8 +59,6 @@ export function useFlagshipReport(id: string): FlagshipReportData { useParameterMetadata(country); const direct = isApiReportId(id); - const legacy = useUserReportById(id, { enabled: !direct && !!id }); - const { data: report, isLoading: reportLoading, @@ -104,11 +103,11 @@ export function useFlagshipReport(id: string): FlagshipReportData { if (!direct) { return { - report: legacy.report, - simulations: legacy.simulations, - policies: legacy.policies ?? [], - isLoading: legacy.isLoading, - // A legacy link that resolves to nothing reads as unresolvable. + report: undefined, + simulations: [], + policies: [], + isLoading: false, + // The page says an id that is not an API report id cannot open. error: null, notFound: false, failedPolicyIds: [], diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts new file mode 100644 index 000000000..fecc89b22 --- /dev/null +++ b/app/src/hooks/useFlagshipStores.ts @@ -0,0 +1,166 @@ +import { useMemo, useRef } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { + findReportRecordByLink, + getFlagshipReportStore, + parseSharedReportItem, + type FlagshipReportRecord, + type FlagshipReportStore, +} from '@/api/flagshipReportStore'; +import { fetchV1PolicyJson, getReformStore, type ReformStore } from '@/api/reformStore'; +import { fetchSharedReport } from '@/api/sharedReport'; +import { isAccountId, type ApiStoreContext } from '@/api/v2Associations'; +import { useCountryContext } from '@/contexts/CountryContext'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useAuthStatus } from '@/libs/auth/AuthContext'; +import { getBrowserUserId } from '@/libs/auth/browserUser'; +import { useV2UserClient } from '@/libs/auth/useV2UserClient'; +import { unwrapReport } from '@/libs/flagship/reportSchema'; +import type { RootState } from '@/store'; +import { isAssociationCountry } from '@/types/v2User'; + +/** + * Where the v3 workspace keeps saved work. Wherever accounts exist (sign-in + * configured, US and UK) it is the PolicyEngine API: as the signed-in + * account, or signed out as this browser's unauthenticated user. Without + * sign-in (and outside the US and UK) it stays in this browser. Nothing + * else decides it, and nothing falls back: a request that fails is an + * ordinary error. + */ +export interface V3SavedWork { + /** The API routes for this session's user; null keeps saved work in this browser. */ + account: ApiStoreContext | null; + /** + * Scopes cached saved work to whose it is: the Auth0 subject when signed + * in, this browser's unauthenticated user when signed out, 'browser' + * otherwise. A session that ends without a reload moves every query to + * new keys. + */ + scope: string; +} + +export function useV3SavedWork(): V3SavedWork { + const { status, subject } = useAuthStatus(); + const countryId = useCountryContext(); + const client = useV2UserClient(); + // Made only where it is sent: signed out in the US or UK. + const browserUserId = + status === 'signed_out' && isAssociationCountry(countryId) ? getBrowserUserId() : null; + return useMemo(() => { + if (!isAssociationCountry(countryId)) { + return { account: null, scope: 'browser' }; + } + if (status === 'signed_in' && subject) { + return { account: { client, countryId }, scope: `account:${subject}` }; + } + if (browserUserId) { + return { account: { client, countryId }, scope: `browser-user:${browserUserId}` }; + } + return { account: null, scope: 'browser' }; + }, [status, subject, browserUserId, countryId, client]); +} + +/** A v1 policy's raw parameter map (`policy_json`), per country. */ +const v1PolicyJsonKey = (countryId: string, policyId: string) => + ['v1-policy-json', countryId, policyId] as const; + +/** How long a fetched v1 policy stays cached without a reader. */ +const V1_POLICY_JSON_GC_TIME = 30 * 60 * 1000; + +export function useReformStore(): ReformStore { + const { account } = useV3SavedWork(); + const parameters = useSelector((state: RootState) => state.metadata?.parameters); + // Read through a ref so metadata arriving later does not rebuild the store. + const parametersRef = useRef(parameters); + parametersRef.current = parameters; + const queryClient = useQueryClient(); + + return useMemo( + () => + getReformStore(account, { + getParameterMetadata: () => parametersRef.current, + // v1 policies never change, so each is fetched once per session + // rather than on every list of the account's reforms. + fetchPolicyJson: (countryId, policyId) => + queryClient.fetchQuery({ + queryKey: v1PolicyJsonKey(countryId, policyId), + queryFn: () => fetchV1PolicyJson(countryId, policyId), + staleTime: Infinity, + gcTime: V1_POLICY_JSON_GC_TIME, + }), + }), + [account, queryClient] + ); +} + +export interface FlagshipReportStoreSelection { + store: FlagshipReportStore; + /** True when records go to the account (one `/v2/user-reports` row per run). */ + isAccount: boolean; + /** The session scope for cached records (see V3SavedWork). */ + scope: string; +} + +export function useFlagshipReportStore(): FlagshipReportStoreSelection { + const { account, scope } = useV3SavedWork(); + return useMemo( + () => ({ + store: getFlagshipReportStore(account), + isAccount: !!account, + scope, + }), + [account, scope] + ); +} + +/** + * This session's record a report link names — the account's row when + * signed in (US/UK), signed out this browser's unauthenticated user's, and + * otherwise this browser's — with the saved reform it was run from. Null + * when there is none: a report someone else ran. + */ +export function useReportRecord(link: string | undefined) { + const { store, scope } = useFlagshipReportStore(); + const { data } = useQuery({ + queryKey: ['flagship-report-record', scope, link ?? ''], + queryFn: () => findReportRecordByLink(store, link!), + enabled: !!link, + staleTime: 5 * 60 * 1000, + retry: 1, + }); + return data ?? null; +} + +/** A saved report as anyone holding its link reads it. */ +export interface SharedReport { + /** The owner's title, source note, provisions, budget window and validation. */ + record: FlagshipReportRecord; + /** The owner's saved reform the report was compared with; null for current law. */ + baselineReform: { name: string | null } | null; +} + +/** + * The saved report a link names (an account row's ID), read with no + * credential, so whoever opens the link sees the report as its owner ran + * it. Null data when no saved report has that ID; an unreadable one is an + * error. + */ +export function useSharedReport(link: string | undefined) { + const countryId = useCurrentCountry(); + return useQuery({ + queryKey: ['shared-report', countryId, link ?? ''], + queryFn: async (): Promise => { + const item = await fetchSharedReport(countryId, link!); + return item + ? { + record: unwrapReport(parseSharedReportItem(item)), + baselineReform: item.baseline_reform, + } + : null; + }, + enabled: !!link && isAccountId(link), + staleTime: 5 * 60 * 1000, + retry: 1, + }); +} diff --git a/app/src/hooks/useReportValidationSnapshot.ts b/app/src/hooks/useReportValidationSnapshot.ts index 7a620c0ca..798033a3d 100644 --- a/app/src/hooks/useReportValidationSnapshot.ts +++ b/app/src/hooks/useReportValidationSnapshot.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { getFlagshipReportStore } from '@/api/flagshipReportStore'; +import { findReportRecordByLink } from '@/api/flagshipReportStore'; +import { useFlagshipReportStore } from '@/hooks/useFlagshipStores'; import { isLiveValidationSettled, LiveValidation, @@ -16,16 +17,19 @@ export interface ReportValidationState { } /** - * Pins the live validation to the report's store record the first time - * both checks settle, and reports drift between that pin and what the - * checks see now. Best-effort: a report without a record in this - * browser (another browser ran it, or storage was unavailable at run + * Pins the live validation to the record the report's link names the + * first time both checks settle, and reports drift between that pin and + * what the checks see now. Only the session that ran the report pins; + * anyone else sees the owner's pin (`sharedSnapshot`), when there is one. + * Best-effort: a report without a record (storage was unavailable at run * time) simply never pins. */ export function useReportValidationSnapshot( - apiReportId: string | undefined, - live: LiveValidation + userReportId: string | undefined, + live: LiveValidation, + sharedSnapshot: ReportValidationSnapshot | null = null ): ReportValidationState { + const { store } = useFlagshipReportStore(); const [snapshot, setSnapshot] = useState(null); const [recordId, setRecordId] = useState(null); const pinned = useRef(false); @@ -35,11 +39,10 @@ export function useReportValidationSnapshot( pinned.current = false; setSnapshot(null); setRecordId(null); - if (!apiReportId) { + if (!userReportId) { return; } - getFlagshipReportStore() - .findByApiReportId(apiReportId) + findReportRecordByLink(store, userReportId) .then((record) => { if (cancelled || !record) { return; @@ -56,7 +59,7 @@ export function useReportValidationSnapshot( return () => { cancelled = true; }; - }, [apiReportId]); + }, [store, userReportId]); const settled = isLiveValidationSettled(live); useEffect(() => { @@ -65,7 +68,7 @@ export function useReportValidationSnapshot( } pinned.current = true; const fresh = snapshotFromLive(live); - getFlagshipReportStore() + store .saveValidation(recordId, fresh) .then((record) => setSnapshot(record.validation ?? fresh)) .catch(() => { @@ -74,5 +77,6 @@ export function useReportValidationSnapshot( // `live` is read once at pin time; re-running on its identity would re-pin. }, [recordId, settled]); - return { snapshot, drift: snapshot ? validationDrift(snapshot, live) : [] }; + const shown = snapshot ?? sharedSnapshot; + return { snapshot: shown, drift: shown ? validationDrift(shown, live) : [] }; } diff --git a/app/src/hooks/useRunFlagshipReport.ts b/app/src/hooks/useRunFlagshipReport.ts index 6a13f8bc8..cb49e6e4e 100644 --- a/app/src/hooks/useRunFlagshipReport.ts +++ b/app/src/hooks/useRunFlagshipReport.ts @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useFlagshipReportStore } from '@/hooks/useFlagshipStores'; import { markDraftRun } from '@/libs/draftReform'; import { flagshipReportQueryKeys, @@ -22,6 +23,7 @@ export function useRunFlagshipReport() { const countryId = useCurrentCountry(); const queryClient = useQueryClient(); const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); + const reportStore = useFlagshipReportStore(); const parameters = useSelector((state: RootState) => state.metadata.parameters); const [isRunning, setIsRunning] = useState(false); const [error, setError] = useState(null); @@ -80,6 +82,9 @@ export function useRunFlagshipReport() { region, windowYears, baseline, + // With accounts, the run's record goes through the API (the account, + // or this browser's unauthenticated user); otherwise this browser's. + accountReportStore: reportStore.isAccount ? reportStore.store : null, }); if (fromDraft) { markDraftRun(userReportId); diff --git a/app/src/libs/auth/AuthContext.ts b/app/src/libs/auth/AuthContext.ts new file mode 100644 index 000000000..336a49c25 --- /dev/null +++ b/app/src/libs/auth/AuthContext.ts @@ -0,0 +1,58 @@ +import { createContext, useContext } from 'react'; + +/** + * Sign-in state for the v3 workspace. This module has no Auth0 + * dependency, so code that only reads the state never bundles the SDK. + * Without an AuthProvider in the tree (the legacy calculator, or sign-in + * turned off) the state is 'disabled' and saved work stays in this + * browser. + * + * Two states only, decided by the Auth0 session: the provider renders a + * loading state while Auth0 restores the session, so nothing below it + * ever sees an in-between state. + */ +export type AuthStatus = 'disabled' | 'signed_out' | 'signed_in'; + +/** Display details from the Auth0 ID token. */ +export interface AuthProfile { + name: string | null; + email: string | null; + picture: string | null; +} + +export interface AuthContextValue { + status: AuthStatus; + /** From the ID token while signed in; null otherwise. */ + profile: AuthProfile | null; + /** The Auth0 subject while signed in; scopes cached saved work to the session. */ + subject: string | null; + /** True when a signed-in session ended because its token could not be renewed. */ + sessionEnded: boolean; + /** Why the last sign-in did not complete, when it did not. */ + error: Error | null; + /** Redirects to the hosted sign-in page; returns to `returnTo` (default: here). */ + signIn: (returnTo?: string) => Promise; + signOut: () => Promise; + /** + * An access token for the PolicyEngine API, or null when signed out or + * the session has just ended. Rejects when sign-in cannot be reached. + */ + getAccessToken: () => Promise; +} + +export const DISABLED_AUTH: AuthContextValue = { + status: 'disabled', + profile: null, + subject: null, + sessionEnded: false, + error: null, + signIn: async () => {}, + signOut: async () => {}, + getAccessToken: async () => null, +}; + +export const AuthContext = createContext(DISABLED_AUTH); + +export function useAuthStatus(): AuthContextValue { + return useContext(AuthContext); +} diff --git a/app/src/libs/auth/AuthProvider.tsx b/app/src/libs/auth/AuthProvider.tsx new file mode 100644 index 000000000..b972ee8c2 --- /dev/null +++ b/app/src/libs/auth/AuthProvider.tsx @@ -0,0 +1,242 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Auth0Provider, useAuth0, type AppState } from '@auth0/auth0-react'; +import { Spinner, Stack, Text } from '@/components/ui'; +import { useCountryContext } from '@/contexts/CountryContext'; +import { colors, spacing } from '@/designTokens'; +import { DEFAULT_COUNTRY } from '@/libs/countries'; +import { + AUTH_SCOPE, + callbackPath, + getAuth0Config, + isCallbackPath, + safeReturnTo, + type Auth0Config, +} from './authConfig'; +import { AuthContext, type AuthContextValue } from './AuthContext'; +import { clearSignedInSessionData, endSignedInSession, settleDraftOwner } from './sessionData'; + +/** + * Social sign-on for the v3 workspace. With sign-in off (or Auth0 not + * configured) this renders its children unchanged and the workspace sees + * status 'disabled'. Otherwise the browser session is managed by + * @auth0/auth0-react: tokens stay in memory and are renewed with rotating + * refresh tokens (falling back to silent re-authentication against the + * Auth0 custom domain after a reload). + * + * While Auth0 restores the session the provider renders a loading state + * instead of its children, so the workspace only ever sees signed in or + * signed out. + * + * Must sit inside the CountryProvider. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const config = getAuth0Config(); + if (!config) { + return <>{children}; + } + return {children}; +} + +let signInReturnTo: string | null = null; + +/** + * Where the sign-in that just completed started, once per sign-in. The + * callback page reads it to navigate on after setting up the account. + */ +export function takeSignInReturnTo(): string | null { + const returnTo = signInReturnTo; + signInReturnTo = null; + return returnTo; +} + +function Auth0SessionProvider({ config, children }: { config: Auth0Config; children: ReactNode }) { + const countryId = useCountryContext() ?? DEFAULT_COUNTRY; + const inBrowser = typeof window !== 'undefined'; + + const onRedirectCallback = useCallback( + (appState?: AppState) => { + signInReturnTo = safeReturnTo(appState?.returnTo, `/${countryId}`); + // Drop ?code&state (the SDK's default does), so reloading the + // callback page does not replay a code that is already used. + window.history.replaceState(window.history.state, '', window.location.pathname); + }, + [countryId] + ); + + return ( + + + {children} + + + ); +} + +function stringClaim(value: unknown): string | null { + return typeof value === 'string' && value ? value : null; +} + +/** Auth0 errors meaning the session cannot be renewed without signing in again. */ +const SESSION_ENDED_ERRORS = new Set([ + 'login_required', + 'consent_required', + 'interaction_required', + 'missing_refresh_token', + 'invalid_grant', +]); + +function isSessionEndedError(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('error' in error)) { + return false; + } + return SESSION_ENDED_ERRORS.has(String(error.error)); +} + +/** Shown in place of the workspace while Auth0 restores the session. */ +function SessionRestoring() { + return ( + + + Loading… + + ); +} + +function Auth0SessionBridge({ + config, + countryId, + children, +}: { + config: Auth0Config; + countryId: string; + children: ReactNode; +}) { + const { + isLoading, + isAuthenticated, + error, + user, + getAccessTokenSilently, + loginWithRedirect, + logout, + } = useAuth0(); + const [sessionEnded, setSessionEnded] = useState(false); + const sessionEndedHere = useRef(false); + const [draftOwnerSettled, setDraftOwnerSettled] = useState(false); + + const subject = isAuthenticated ? stringClaim(user?.sub) : null; + + // Whose draft this browser holds is settled before the workspace + // renders, and again on a sign-in. A session that ends in this page + // keeps the draft for the person still here (see endSignedInSession). + useEffect(() => { + if (isLoading) { + return; + } + if (subject) { + sessionEndedHere.current = false; + settleDraftOwner(subject); + } else if (!sessionEndedHere.current) { + settleDraftOwner(null); + } + setDraftOwnerSettled(true); + }, [isLoading, subject]); + + // A token that cannot be renewed ends the session: the user is signed + // out here (without a redirect) and invited to sign in again. Any other + // failure (a timeout, no network) leaves the session as it is and + // rejects, so the request reports that sign-in could not be reached. + const getAccessToken = useCallback(async () => { + try { + const token = await getAccessTokenSilently({ + authorizationParams: { audience: config.audience }, + }); + return token || null; + } catch (caught) { + if (!isSessionEndedError(caught)) { + throw caught; + } + sessionEndedHere.current = true; + setSessionEnded(true); + endSignedInSession(); + await logout({ openUrl: false }); + return null; + } + }, [getAccessTokenSilently, config.audience, logout]); + + const signIn = useCallback( + async (returnTo?: string) => { + const here = + typeof window !== 'undefined' + ? `${window.location.pathname}${window.location.search}` + : `/${countryId}`; + await loginWithRedirect({ + appState: { returnTo: safeReturnTo(returnTo ?? here, `/${countryId}`) }, + authorizationParams: { + audience: config.audience, + redirect_uri: `${window.location.origin}${callbackPath(countryId)}`, + scope: AUTH_SCOPE, + }, + }); + }, + [loginWithRedirect, countryId, config.audience] + ); + + const signOut = useCallback(async () => { + clearSignedInSessionData(); + await logout({ logoutParams: { returnTo: `${window.location.origin}/${countryId}` } }); + }, [logout, countryId]); + + const name = stringClaim(user?.name); + const email = stringClaim(user?.email); + const picture = stringClaim(user?.picture); + + const value = useMemo( + () => ({ + status: isAuthenticated ? 'signed_in' : 'signed_out', + profile: isAuthenticated ? { name, email, picture } : null, + subject, + sessionEnded: !isAuthenticated && sessionEnded, + error: error ?? null, + signIn, + signOut, + getAccessToken, + }), + [ + isAuthenticated, + name, + email, + picture, + subject, + sessionEnded, + error, + signIn, + signOut, + getAccessToken, + ] + ); + + if (isLoading || !draftOwnerSettled) { + return ; + } + return {children}; +} diff --git a/app/src/libs/auth/accountUpgrade.ts b/app/src/libs/auth/accountUpgrade.ts new file mode 100644 index 000000000..97c8ddce0 --- /dev/null +++ b/app/src/libs/auth/accountUpgrade.ts @@ -0,0 +1,83 @@ +import { useSyncExternalStore } from 'react'; +import { upgradeAccount } from '@/api/v2Me'; +import type { V2UserClient } from '@/api/v2UserClient'; +import type { AssociationCountryId } from '@/types/v2User'; +import { getBrowserUserId, replaceBrowserUserId } from './browserUser'; + +/** + * The upgrade at sign-in. Signed out, saved work belongs to this browser's + * unauthenticated user in the PolicyEngine API; signing in upgrades that + * user (`POST /v2/me/upgrade`). On a first sign-in it becomes the account, + * so everything saved stays where it is; for an existing account its work + * moves into that account. Then the browser starts over with a new + * unauthenticated user, so whoever is next here signed out starts empty. + * A failed upgrade keeps the browser's user, so the next sign-in tries again. + */ + +export interface AccountUpgradeNotice { + kind: 'success' | 'failure'; + message: string; + /** Runs the upgrade again; present after a failure. */ + retry: (() => void) | null; +} + +let currentNotice: AccountUpgradeNotice | null = null; +const listeners = new Set<() => void>(); + +function publishNotice(notice: AccountUpgradeNotice | null): void { + currentNotice = notice; + listeners.forEach((listener) => listener()); +} + +export function dismissAccountUpgradeNotice(): void { + publishNotice(null); +} + +/** The notice after the upgrade, which outlives the callback page. */ +export function useAccountUpgradeNotice(): AccountUpgradeNotice | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => currentNotice, + () => null + ); +} + +export const UPGRADE_SUCCESS_MESSAGE = + 'The reforms and reports you saved on this browser are now in your account.'; + +export const UPGRADE_FAILURE_MESSAGE = + 'The reforms and reports you saved on this browser could not be added to your account. They will be added the next time you sign in.'; + +export interface RunAccountUpgradeOptions { + /** A client for the signed-in session. */ + client: V2UserClient; + /** The country signed in from; a new account records it. */ + countryId: AssociationCountryId; +} + +/** + * Upgrades this browser's unauthenticated user into the signed-in account + * and publishes a notice when work moved, or when the upgrade failed. + */ +export async function runAccountUpgrade(options: RunAccountUpgradeOptions): Promise { + try { + const { outcome } = await upgradeAccount(options.client, options.countryId, getBrowserUserId()); + replaceBrowserUserId(); + publishNotice( + outcome === 'none' ? null : { kind: 'success', message: UPGRADE_SUCCESS_MESSAGE, retry: null } + ); + } catch (error) { + console.error('[accountUpgrade] Upgrade failed:', error); + publishNotice({ + kind: 'failure', + message: UPGRADE_FAILURE_MESSAGE, + retry: () => { + dismissAccountUpgradeNotice(); + void runAccountUpgrade(options); + }, + }); + } +} diff --git a/app/src/libs/auth/authConfig.ts b/app/src/libs/auth/authConfig.ts new file mode 100644 index 000000000..a10cb5b81 --- /dev/null +++ b/app/src/libs/auth/authConfig.ts @@ -0,0 +1,72 @@ +/** + * Sign-in configuration, read from build-time environment variables. + * + * Sign-in is inert unless the deployment sets all of: + * - VITE_AUTH_ENABLED=true (calculator-app: NEXT_PUBLIC_AUTH_ENABLED) + * - VITE_AUTH0_DOMAIN, VITE_AUTH0_CLIENT_ID, VITE_AUTH0_AUDIENCE + * (calculator-app: NEXT_PUBLIC_AUTH0_*) + * + * calculator-app maps the NEXT_PUBLIC_* names onto import.meta.env.VITE_* + * in next.config.ts, so shared code reads one set of names. Values are + * read at call time so tests can stub them. + */ + +export interface Auth0Config { + domain: string; + clientId: string; + /** The PolicyEngine API resource server; access tokens are minted for it. */ + audience: string; +} + +/** Refresh tokens need offline_access; profile and email feed the user menu. */ +export const AUTH_SCOPE = 'openid profile email offline_access'; + +function trimmed(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function isAuthFlagOn(): boolean { + return trimmed(import.meta.env.VITE_AUTH_ENABLED) === 'true'; +} + +/** The Auth0 settings, or null when sign-in is off or not fully configured. */ +export function getAuth0Config(): Auth0Config | null { + if (!isAuthFlagOn()) { + return null; + } + const domain = trimmed(import.meta.env.VITE_AUTH0_DOMAIN); + const clientId = trimmed(import.meta.env.VITE_AUTH0_CLIENT_ID); + const audience = trimmed(import.meta.env.VITE_AUTH0_AUDIENCE); + if (!domain || !clientId || !audience) { + return null; + } + return { domain, clientId, audience }; +} + +/** Where Auth0 sends the browser back to: `/{countryId}/callback`. */ +export function callbackPath(countryId: string): string { + return `/${countryId}/callback`; +} + +export function isCallbackPath(pathname: string): boolean { + return /^\/[a-z]{2}\/callback\/?$/.test(pathname); +} + +/** + * A same-origin path to land on after sign-in. Anything that is not a + * plain absolute path (another origin, a protocol-relative URL, the + * callback itself) falls back, so the redirect cannot leave the app. + */ +export function safeReturnTo(returnTo: unknown, fallback: string): string { + if (typeof returnTo !== 'string' || !returnTo.startsWith('/') || returnTo.startsWith('//')) { + return fallback; + } + if (returnTo.includes('\\')) { + return fallback; + } + const pathname = returnTo.split(/[?#]/)[0]; + if (isCallbackPath(pathname)) { + return fallback; + } + return returnTo; +} diff --git a/app/src/libs/auth/browserUser.ts b/app/src/libs/auth/browserUser.ts new file mode 100644 index 000000000..2e5ed3b8b --- /dev/null +++ b/app/src/libs/auth/browserUser.ts @@ -0,0 +1,73 @@ +/** + * This browser's unauthenticated user: a random UUID made on the first + * visit and kept with no expiry. Signed out, the v3 workspace saves work + * to the PolicyEngine API as this user (the `PolicyEngine-Unauthenticated-User` + * header), so nothing saved lives only in the browser. The UUID is the + * credential for that work: it never goes in a URL or a log. + * + * Signing in upgrades this user into the account (`POST /v2/me/upgrade`), + * after which the browser starts over with a new UUID, so the next person + * signed out on this browser starts with nothing. + */ + +const BROWSER_USER_KEY = 'pe-browser-user-id'; + +/** The form the API accepts for this user: a lowercase, hyphenated UUID. */ +const CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Used only when localStorage is unavailable: lasts for this page. */ +let pageOnlyUserId: string | null = null; + +/** + * A random (version 4) UUID. `crypto.randomUUID` needs a secure context + * and a recent browser; `crypto.getRandomValues` works everywhere, so it + * builds one when `randomUUID` is missing. + */ +function newUserId(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function storeUserId(userId: string): void { + try { + localStorage.setItem(BROWSER_USER_KEY, userId); + pageOnlyUserId = null; + } catch { + pageOnlyUserId = userId; + } +} + +/** + * This browser's unauthenticated user ID, made on first use. A stored value + * the API would refuse (damaged, or edited by hand) is replaced, rather than + * failing every signed-out request. + */ +export function getBrowserUserId(): string { + try { + const stored = localStorage.getItem(BROWSER_USER_KEY); + if (stored && CANONICAL_UUID.test(stored)) { + return stored; + } + } catch { + // Storage unavailable: fall back to an ID for this page. + } + if (pageOnlyUserId) { + return pageOnlyUserId; + } + const userId = newUserId(); + storeUserId(userId); + return userId; +} + +/** Starts this browser over with a new unauthenticated user (after an upgrade). */ +export function replaceBrowserUserId(): string { + const userId = newUserId(); + storeUserId(userId); + return userId; +} diff --git a/app/src/libs/auth/sessionData.ts b/app/src/libs/auth/sessionData.ts new file mode 100644 index 000000000..a0c92cecc --- /dev/null +++ b/app/src/libs/auth/sessionData.ts @@ -0,0 +1,103 @@ +import { + clearAllDraftReforms, + dropHeldDraftReforms, + holdAllDraftReforms, + restoreHeldDraftReforms, +} from '@/libs/draftReform'; +import { forgetAccountReportMeta } from '@/libs/flagship/runReport'; + +/** + * What a signed-in session leaves in this browser beyond its account, and + * when it is cleared, so the next person on a shared browser neither sees + * nor inherits it: + * + * - The report provenance stash from signed-in runs (kept in memory only). + * - The draft reform, which may hold an account reform loaded for editing, + * and any draft set aside for another country. A fingerprint of the + * account that last had them signed in marks whose drafts they are. + * + * Signed in, v3 writes nothing else to this browser: reforms and reports + * go to the account. + */ + +/** Marks the draft as belonging to a signed-in account (a fingerprint, never the subject). */ +const DRAFT_OWNER_KEY = 'pe-draft-reform-owner'; + +/** A short FNV-1a fingerprint of an Auth0 subject: enough to tell accounts apart. */ +function subjectFingerprint(subject: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < subject.length; index++) { + hash ^= subject.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16); +} + +function readDraftOwner(): string | null { + try { + return localStorage.getItem(DRAFT_OWNER_KEY); + } catch { + return null; + } +} + +function writeDraftOwner(owner: string | null): void { + try { + if (owner) { + localStorage.setItem(DRAFT_OWNER_KEY, owner); + } else { + localStorage.removeItem(DRAFT_OWNER_KEY); + } + } catch { + // Storage unavailable: nothing was kept either. + } +} + +/** Signing out: everything the session left goes, held drafts too. */ +export function clearSignedInSessionData(): void { + forgetAccountReportMeta(); + clearAllDraftReforms(); + dropHeldDraftReforms(); + writeDraftOwner(null); +} + +/** + * A session that ended in this page (its token could not be renewed): + * the same person is still here, so their draft stays for them to save + * or to keep once they sign in again. It is dropped when the page next + * loads signed out, or when another account signs in. + */ +export function endSignedInSession(): void { + forgetAccountReportMeta(); +} + +/** + * Settles whose draft this browser holds once the session is known: + * after it is restored on load, and whenever someone signs in. + * + * - Signed in: a draft left by another account is dropped; drafts held for + * this account come back; the draft, if any, is now this account's (a + * signed-out draft comes along with it). + * - Signed out on load: drafts left by a signed-in session are held out of + * sight for that account, not deleted. Its session may have expired + * between visits, or only failed to restore (a timeout, a lost + * connection), in which case the next load signs it back in. + */ +export function settleDraftOwner(subject: string | null): void { + const owner = readDraftOwner(); + if (!subject) { + if (owner) { + holdAllDraftReforms(owner); + writeDraftOwner(null); + } else { + dropHeldDraftReforms({ expiredOnly: true }); + } + return; + } + const fingerprint = subjectFingerprint(subject); + if (owner && owner !== fingerprint) { + clearAllDraftReforms(); + } + restoreHeldDraftReforms(fingerprint); + writeDraftOwner(fingerprint); +} diff --git a/app/src/libs/auth/useV2UserClient.ts b/app/src/libs/auth/useV2UserClient.ts new file mode 100644 index 000000000..cee829799 --- /dev/null +++ b/app/src/libs/auth/useV2UserClient.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { createV2UserClient, type V2UserClient } from '@/api/v2UserClient'; +import { useCountryContext } from '@/contexts/CountryContext'; +import { isAssociationCountry } from '@/types/v2User'; +import { useAuthStatus } from './AuthContext'; +import { getBrowserUserId } from './browserUser'; + +/** + * A PolicyEngine API client for this session: signed in, it sends the + * session's access token; signed out in the US or UK, this browser's + * unauthenticated user. With sign-in off, and outside the US and UK, + * nothing is saved to the API, so no browser user is made and the client + * sends no credential. + */ +export function useV2UserClient(): V2UserClient { + const { status, getAccessToken } = useAuthStatus(); + const countryId = useCountryContext(); + const browserUserId = + status === 'signed_out' && isAssociationCountry(countryId) ? getBrowserUserId() : null; + return useMemo( + () => + createV2UserClient({ + credentials: + status === 'signed_in' + ? { kind: 'session', getAccessToken } + : browserUserId + ? { kind: 'unauthenticated', userId: browserUserId } + : { kind: 'none' }, + }), + [status, browserUserId, getAccessToken] + ); +} diff --git a/app/src/libs/draftReform.ts b/app/src/libs/draftReform.ts index f286b784b..8106f1de5 100644 --- a/app/src/libs/draftReform.ts +++ b/app/src/libs/draftReform.ts @@ -2,6 +2,7 @@ import { useSyncExternalStore } from 'react'; import { CURRENT_YEAR, FOREVER } from '@/constants'; import { CountryId } from '@/libs/countries'; import { Reform, ReformSource } from '@/types/ingredients/Reform'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; import { getCurrentValue } from '@/utils/parameterValues'; @@ -285,6 +286,118 @@ export function clearDraftReform(): void { writeDraft(null); } +/** + * Every draft this browser holds: the one in hand and any set aside for + * another country. A signed-in session that ends takes them all, so the + * next person on a shared browser inherits none. + */ +export function clearAllDraftReforms(): void { + try { + const setAsideKeys = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index) + ).filter((key): key is string => !!key?.startsWith(`${STORAGE_KEY}:`)); + setAsideKeys.forEach((key) => localStorage.removeItem(key)); + } catch { + // Storage unavailable: nothing was set aside either. + } + writeDraft(null); +} + +/** + * Drafts held out of sight for the account that had them, when a page + * loads signed out and cannot tell whether that account's session ended or + * only failed to restore (a timeout, a lost connection). They return if + * that account signs in again, and go when another does, on sign-out, or + * after a refresh token's idle lifetime. + */ +const HELD_KEY = `${STORAGE_KEY}-held`; +const HELD_DRAFT_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; + +interface HeldDrafts { + /** The fingerprint of the account that had the drafts signed in. */ + owner: string; + heldAt: number; + /** Stored drafts by storage key: the one in hand and any set aside. */ + drafts: Record; +} + +/** Every stored draft key: the one in hand and any set aside for another country. */ +function storedDraftKeys(): string[] { + return Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index)).filter( + (key): key is string => key === STORAGE_KEY || !!key?.startsWith(`${STORAGE_KEY}:`) + ); +} + +/** The held drafts, when there are any and they have not expired. */ +function readHeldDrafts(): HeldDrafts | null { + try { + const held = JSON.parse(localStorage.getItem(HELD_KEY) ?? 'null') as HeldDrafts | null; + if (!held || typeof held.owner !== 'string' || typeof held.heldAt !== 'number') { + return null; + } + return Date.now() - held.heldAt > HELD_DRAFT_LIFETIME_MS ? null : held; + } catch { + return null; + } +} + +/** + * Moves every draft out of sight, held for `owner`. Drafts already held for + * the same account are kept alongside, the ones in hand winning. + */ +export function holdAllDraftReforms(owner: string): void { + try { + const earlier = readHeldDrafts(); + const drafts: Record = earlier?.owner === owner ? { ...earlier.drafts } : {}; + for (const key of storedDraftKeys()) { + drafts[key] = localStorage.getItem(key)!; + } + if (Object.keys(drafts).length > 0) { + const held: HeldDrafts = { owner, heldAt: Date.now(), drafts }; + localStorage.setItem(HELD_KEY, JSON.stringify(held)); + } + } catch { + // Storage unavailable: nothing was stored to hold. + } + clearAllDraftReforms(); +} + +/** + * Brings back the drafts held for `owner`, into any slot not taken since; + * drafts held for another account are dropped. Either way none stay held. + */ +export function restoreHeldDraftReforms(owner: string): void { + const held = readHeldDrafts(); + dropHeldDraftReforms(); + if (!held || held.owner !== owner) { + return; + } + try { + for (const [key, stored] of Object.entries(held.drafts)) { + if (localStorage.getItem(key) === null) { + localStorage.setItem(key, stored); + } + } + window.dispatchEvent(new Event(CHANGE_EVENT)); + } catch { + // Storage unavailable: the drafts cannot come back. + } +} + +/** Drops the held drafts: on sign-out, or (`expiredOnly`) once they have waited too long. */ +export function dropHeldDraftReforms({ + expiredOnly = false, +}: { expiredOnly?: boolean } = {}): void { + try { + if (expiredOnly && (readHeldDrafts() || localStorage.getItem(HELD_KEY) === null)) { + return; + } + localStorage.removeItem(HELD_KEY); + } catch { + // Storage unavailable: nothing was held. + } +} + /** * The report a run from the draft started. Running hands the draft over * to its report: the report's page clears it on opening — not the run @@ -306,7 +419,8 @@ export function clearDraftAfterRun(userReportId: string): void { /** * A report's reform back in the draft, to edit and run again — or a - * bill's, with the bill as its source. + * bill's, with the bill as its source. With the saved reform the report + * was run from, saving changes that reform. */ export function loadProvisionsIntoDraft( countryId: CountryId, @@ -315,6 +429,7 @@ export function loadProvisionsIntoDraft( label?: string; year?: number; region?: string; + reformId?: string | null; baselineReformId?: string | null; /** Years of budget to score with the run, e.g. 3. */ windowYears?: number; @@ -332,6 +447,7 @@ export function loadProvisionsIntoDraft( ...(options.sourceRef ? { sourceRef: options.sourceRef } : {}), startedAt: Date.now(), ...(options.year ? { year: options.year } : {}), + ...(options.reformId ? { editingReformId: options.reformId } : {}), ...(options.baselineReformId ? { baselineReformId: options.baselineReformId } : {}), ...(options.windowYears && options.windowYears > 1 ? { windowYears: options.windowYears } : {}), }); @@ -501,6 +617,29 @@ export function setDraftYear(year: number): void { }); } +/** + * What a report runs for a set of provisions in `year`: only those that + * change the law, each as it stands in that year. Build and a saved + * reform's run both use it, so a reform reports the same either way. + */ +export function provisionsToRun( + provisions: DraftProvision[], + year: number, + parameters: ParameterMetadataCollection | null | undefined +): DraftProvision[] { + const currentLawAt = (path: string, at: number) => { + const values = parameters?.[path]?.values; + return values ? new ValueIntervalCollection(values).getValueAtDate(`${at}-01-01`) : undefined; + }; + return provisionsForYear( + provisions.filter((provision) => + provisionAdjusts(provision, parameters?.[provision.path]?.values) + ), + year, + currentLawAt + ); +} + /** * The provisions as they stand in one year — current law then, and the * reform's value then — so a report's "current law → new value" lines @@ -594,31 +733,49 @@ export function setDraftPopulation(population: DraftPopulation): void { writeDraft({ ...draft, population }); } +/** Display details and current law for a parameter path. */ +export type ProvisionDetails = (path: string) => { + breadcrumb: string; + unit: string | null; + baselineValue: any; +}; + +/** A saved reform's parameters as provisions: one value from this year on, or its dated changes. */ +export function reformToProvisions(reform: Reform, resolve: ProvisionDetails): DraftProvision[] { + return reform.parameters.map((parameter) => { + const { breadcrumb, unit, baselineValue } = resolve(parameter.name); + const single = + parameter.values.length === 1 && + parameter.values[0].startDate === `${CURRENT_YEAR}-01-01` && + parameter.values[0].endDate === FOREVER; + return { + path: parameter.name, + breadcrumb, + unit, + baselineValue, + value: parameter.values[0]?.value ?? baselineValue, + // Anything but one value from this year on keeps its dates. + ...(!single && parameter.values.length > 0 && { intervals: parameter.values }), + }; + }); +} + +/** Marks the draft as the saved reform `reformId`, so the next save changes it. */ +export function setDraftEditingReform(reformId: string): void { + const draft = getSnapshot(); + if (!draft) { + return; + } + writeDraft({ ...draft, editingReformId: reformId }); +} + /** Loads a saved reform into the composer for editing. */ -export function loadReformIntoDraft( - reform: Reform, - resolve: (path: string) => { breadcrumb: string; unit: string | null; baselineValue: any } -): void { +export function loadReformIntoDraft(reform: Reform, resolve: ProvisionDetails): void { writeDraft({ countryId: reform.countryId, label: reform.label ?? '', population: { scope: 'national' }, - provisions: reform.parameters.map((parameter) => { - const { breadcrumb, unit, baselineValue } = resolve(parameter.name); - const single = - parameter.values.length === 1 && - parameter.values[0].startDate === `${CURRENT_YEAR}-01-01` && - parameter.values[0].endDate === FOREVER; - return { - path: parameter.name, - breadcrumb, - unit, - baselineValue, - value: parameter.values[0]?.value ?? baselineValue, - // Anything but one value from this year on keeps its dates. - ...(!single && parameter.values.length > 0 && { intervals: parameter.values }), - }; - }), + provisions: reformToProvisions(reform, resolve), source: reform.provenance.source, sourceRef: reform.provenance.ref, editingReformId: reform.id, diff --git a/app/src/libs/flagship/latestReport.ts b/app/src/libs/flagship/latestReport.ts index 92fb3904d..cb35440de 100644 --- a/app/src/libs/flagship/latestReport.ts +++ b/app/src/libs/flagship/latestReport.ts @@ -1,4 +1,5 @@ -import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { ranAgainstReform } from '@/libs/flagship/reportComparison'; import { readReportMeta } from '@/libs/flagship/runReport'; import type { Reform } from '@/types/ingredients/Reform'; @@ -14,24 +15,46 @@ function valueKey( )}`; } +/** A report with the policy its baseline simulation ran, when the API's record was read. */ +export type ReportWithBaseline = FlagshipReportRecord & { baselinePolicyId?: string }; + +/** + * Whether a report was compared with something other than current law: + * the run's details say so in the session that ran it (kept under the + * report's link), the record links the saved reform it was compared with, + * or its baseline simulation ran a policy other than current law. + */ +function comparedWithSavedReform( + report: ReportWithBaseline, + currentLawPolicyId: number | undefined +): boolean { + return ( + !!readReportMeta(reportLink(report))?.baseline || + !!report.baselineReformId || + ranAgainstReform(report.baselinePolicyId, currentLawPolicyId) === true + ); +} + /** * The report a saved reform's "View full impact report" opens: its newest * report for the year it runs, against current law, that ran the reform's * values as they are now. Null when there is none — then it runs one. * A report still computing is the one to open: its calculation goes on - * on the server, so a new run would only start the wait again. + * on the server, so a new run would only start the wait again. Records + * saved through the API keep no year, so the caller fills it in. */ export function latestMatchingReport( reform: Reform, - reports: FlagshipReportRecord[], - year: number + reports: ReportWithBaseline[], + year: number, + currentLawPolicyId?: number ): FlagshipReportRecord | null { const reformKey = reform.parameters .map((parameter) => valueKey(parameter.name, parameter.values)) .sort() .join('|'); const matching = reports.filter((report) => { - if (report.year !== String(year) || readReportMeta(report.apiReportId)?.baseline) { + if (report.year !== String(year) || comparedWithSavedReform(report, currentLawPolicyId)) { return false; } const reportKey = report.provisions diff --git a/app/src/libs/flagship/ownedItems.ts b/app/src/libs/flagship/ownedItems.ts deleted file mode 100644 index cfc08179c..000000000 --- a/app/src/libs/flagship/ownedItems.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * This browser's index of the flagship items it saved to the central - * store. There is no sign-in yet: the server keeps each reform and - * report record under an unguessable id with no user identifier, so - * only this index knows which items are "mine". Each entry carries the - * edit token the server issued once, at creation; the server checks it - * on every change, so only the browser that created an item can edit - * or delete it. - * - * Items saved to the localStorage fallback stores are not indexed here: - * they live in this browser already, and their stores list them. - * - * Every read and write tolerates unavailable or corrupt storage (private - * windows, blocked site data): reads come back empty, writes are lost. - */ - -/** Request header that carries an item's edit token to the server. */ -export const EDIT_TOKEN_HEADER = 'X-Edit-Token'; - -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** - * Central ids are UUIDs; the localStorage fallbacks use prefixed ids - * ("rf-…", "lfr-…") that the central store cannot hold. - */ -export function isUuid(value: string): boolean { - return UUID_PATTERN.test(value); -} - -export type OwnedItemKind = 'reforms' | 'reports'; - -export interface OwnedItem { - /** The item's id on the central store. */ - id: string; - countryId: string; - /** Issued by the server at creation; proves this browser created the item. */ - editToken: string; - /** Reports only: the api.policyengine.org report the record points at. */ - apiReportId?: string; - addedAt: string; -} - -const STORAGE_KEYS: Record = { - reforms: 'pe-flagship-owned-reforms', - reports: 'pe-flagship-owned-reports', -}; - -/** - * The central store did not issue an edit token for this browser, so - * the item is someone else's (or a legacy row, which is read-only). - */ -export class ItemNotOwnedError extends Error { - constructor() { - super('This browser cannot change this item'); - this.name = 'ItemNotOwnedError'; - } -} - -function isOwnedItem(value: unknown): value is OwnedItem { - const item = value as OwnedItem | null; - return ( - !!item && - typeof item.id === 'string' && - typeof item.countryId === 'string' && - typeof item.editToken === 'string' && - (item.apiReportId === undefined || typeof item.apiReportId === 'string') - ); -} - -/** - * Items whose write to localStorage failed (storage full or blocked) right - * after the server created them: kept for this session, so a reform just - * saved still lists and still edits until the tab closes, rather than - * vanishing with its edit token. - */ -const sessionItems: Record = { reforms: [], reports: [] }; - -function readStored(kind: OwnedItemKind): OwnedItem[] { - try { - const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS[kind]) ?? '[]'); - return Array.isArray(parsed) ? parsed.filter(isOwnedItem) : []; - } catch { - return []; - } -} - -function readAll(kind: OwnedItemKind): OwnedItem[] { - const stored = readStored(kind); - const held = sessionItems[kind].filter((item) => !stored.some((s) => s.id === item.id)); - return [...held, ...stored]; -} - -/** Whether the index reached localStorage. */ -function writeStored(kind: OwnedItemKind, items: OwnedItem[]): boolean { - try { - localStorage.setItem(STORAGE_KEYS[kind], JSON.stringify(items)); - return true; - } catch { - return false; - } -} - -/** This browser's items of a kind, newest first, optionally for one country. */ -export function readOwnedItems(kind: OwnedItemKind, countryId?: string): OwnedItem[] { - return readAll(kind).filter((item) => !countryId || item.countryId === countryId); -} - -export function findOwnedItem(kind: OwnedItemKind, id: string): OwnedItem | null { - return readAll(kind).find((item) => item.id === id) ?? null; -} - -/** The report record this browser saved for an API report, if any. */ -export function findOwnedReportByApiReportId(apiReportId: string): OwnedItem | null { - return readAll('reports').find((item) => item.apiReportId === apiReportId) ?? null; -} - -/** - * Adds an item to this browser's index. Returns false when localStorage - * refused it: the item is then held for this session only. - */ -export function addOwnedItem(kind: OwnedItemKind, item: Omit): boolean { - const entry = { ...item, addedAt: new Date().toISOString() }; - const others = readStored(kind).filter((existing) => existing.id !== item.id); - if (writeStored(kind, [entry, ...others])) { - sessionItems[kind] = sessionItems[kind].filter((held) => held.id !== item.id); - return true; - } - sessionItems[kind] = [entry, ...sessionItems[kind].filter((held) => held.id !== item.id)]; - return false; -} - -export function removeOwnedItem(kind: OwnedItemKind, id: string): void { - sessionItems[kind] = sessionItems[kind].filter((item) => item.id !== id); - writeStored( - kind, - readStored(kind).filter((item) => item.id !== id) - ); -} - -/** Test hook: forget the items held for the session. */ -export function resetSessionOwnedItemsForTesting(): void { - sessionItems.reforms = []; - sessionItems.reports = []; -} - -/** Headers for a change to an owned item; throws when this browser has no token for it. */ -export function editTokenHeaders(kind: OwnedItemKind, id: string): Record { - const owned = findOwnedItem(kind, id); - if (!owned) { - throw new ItemNotOwnedError(); - } - return { [EDIT_TOKEN_HEADER]: owned.editToken }; -} diff --git a/app/src/libs/flagship/reportComparison.ts b/app/src/libs/flagship/reportComparison.ts new file mode 100644 index 000000000..724458581 --- /dev/null +++ b/app/src/libs/flagship/reportComparison.ts @@ -0,0 +1,20 @@ +/** + * Whether a report was compared with something other than current law, by + * the policy its baseline simulation ran. That stays true when its record + * no longer names the saved reform it was compared with: the reform was + * deleted, or the report is someone else's, recomputed. Undefined while + * either policy is unknown, as current law is until the model's metadata + * loads (it reads 0 until then). + */ +export function ranAgainstReform( + baselinePolicyId: string | number | null | undefined, + currentLawPolicyId: string | number | null | undefined +): boolean | undefined { + if (!Number(baselinePolicyId) || !Number(currentLawPolicyId)) { + return undefined; + } + return String(baselinePolicyId) !== String(currentLawPolicyId); +} + +/** How a report compared with a reform it can no longer name reads: "Compared with a saved reform". */ +export const UNNAMED_COMPARISON = 'a saved reform'; diff --git a/app/src/libs/flagship/reportProvenance.ts b/app/src/libs/flagship/reportProvenance.ts index fd3ffb460..18ad104c2 100644 --- a/app/src/libs/flagship/reportProvenance.ts +++ b/app/src/libs/flagship/reportProvenance.ts @@ -1,10 +1,11 @@ /** * Where a report's provisions come from when the local stash is missing. * - * `runFlagshipReport` stashes the provisions in localStorage under the - * report id, which serves the browser that ran the report and nobody - * else: a shared link, another device, or a cleared profile opens the - * report with no provenance, and validation has nothing to match. The + * `runFlagshipReport` stashes the provisions under the report id (in + * localStorage signed out, in memory for a signed-in session), which + * serves the browser that ran the report and nobody else: a shared link, + * another device, or a cleared profile opens the report with no + * provenance, and validation has nothing to match. The * reform policy on the PolicyEngine API carries the same parameter * changes, so they are rebuilt from it here, read in the report's year * as the run that stashed them described them. diff --git a/app/src/libs/flagship/reportRerun.ts b/app/src/libs/flagship/reportRerun.ts index 4a255fc5b..f5615a869 100644 --- a/app/src/libs/flagship/reportRerun.ts +++ b/app/src/libs/flagship/reportRerun.ts @@ -1,46 +1,102 @@ -import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; import { fetchReportById } from '@/api/report'; import { fetchSimulationById } from '@/api/simulation'; import type { CountryId } from '@/libs/countries'; +import { ranAgainstReform, UNNAMED_COMPARISON } from '@/libs/flagship/reportComparison'; import { readReportMeta, type RunReportBaseline } from '@/libs/flagship/runReport'; +import type { Reform } from '@/types/ingredients/Reform'; -/** What a report ran with, beyond its reform and year, to run it again. */ +/** What a report ran with, beyond its reform, to run it again. */ export interface RerunSettings { + /** The year it simulated; unknown when neither its record nor the API's says. */ + year?: number; /** A state or district; absent for the whole country. */ region?: string; windowYears?: number; baseline?: RunReportBaseline; } +export interface RerunSettingsOptions { + /** This session's saved reform by its ID, to name the reform a report was compared with. */ + savedReform?: (reformId: string) => Reform | undefined; + /** The current-law policy, to tell a report compared with a reform from one against current law. */ + currentLawPolicyId?: number; +} + /** - * The settings a report ran with: its population from the API's record - * (the report's simulation), its budget window and comparison from the - * details this browser kept. A population that cannot be read runs - * nationwide, as the report would without one. + * The settings a report ran with. Its year (when its record keeps none) + * and population come from the API's record: the report and its baseline + * simulation. Its budget window and comparison come from the run's + * details, which this session keeps under the report's link; otherwise + * from the report's own record, which keeps its window and the saved + * reform it was compared with (an account report run elsewhere, or + * before a reload). That comparison is the baseline policy the report + * ran, named from the saved reform. A population that cannot be read + * runs nationwide, as the report would without one; a comparison whose + * policy cannot be read throws rather than run against current law. */ export async function rerunSettings( countryId: CountryId, - report: FlagshipReportRecord + report: FlagshipReportRecord, + { savedReform, currentLawPolicyId }: RerunSettingsOptions = {} ): Promise { - const meta = readReportMeta(report.apiReportId); + const meta = readReportMeta(reportLink(report)); + let year = report.year; let region: string | undefined; + let baselinePolicyId: number | undefined; try { const apiReport = await fetchReportById(countryId, report.apiReportId); + year = year || apiReport.year || undefined; const simulation = await fetchSimulationById(countryId, apiReport.simulation_1_id); const population = simulation.population_type === 'geography' ? simulation.population_id : ''; region = population && population !== countryId ? population : undefined; + baselinePolicyId = Number(simulation.policy_id) || undefined; } catch { region = undefined; } + const recordWindow = + report.budgetWindowYears && report.budgetWindowYears > 1 ? report.budgetWindowYears : undefined; return { + year: Number(year) || undefined, region, - windowYears: meta?.budgetWindow?.windowSize, + windowYears: meta?.budgetWindow?.windowSize ?? recordWindow, baseline: meta?.baseline ? { reformId: meta.baseline.reformId, label: meta.baseline.label, policyId: Number(meta.baseline.policyId), } - : undefined, + : savedBaseline(report, baselinePolicyId, savedReform, currentLawPolicyId), + }; +} + +/** + * The saved reform a report's record says it was compared with, as the + * baseline to run again: the policy the report ran as its baseline, or, + * when that can't be read, the saved reform's own policy. A record that no + * longer names one (the reform was deleted, or the report recomputed + * someone else's) keeps the comparison its baseline policy shows. None for + * a report compared with current law. + */ +function savedBaseline( + report: FlagshipReportRecord, + baselinePolicyId: number | undefined, + savedReform: RerunSettingsOptions['savedReform'], + currentLawPolicyId: number | undefined +): RunReportBaseline | undefined { + if (!report.baselineReformId) { + return baselinePolicyId && ranAgainstReform(baselinePolicyId, currentLawPolicyId) + ? { reformId: null, label: UNNAMED_COMPARISON, policyId: baselinePolicyId } + : undefined; + } + const reform = savedReform?.(report.baselineReformId); + const policyId = baselinePolicyId ?? (Number(reform?.policyId) || undefined); + if (!policyId) { + throw new Error('The report’s comparison could not be read'); + } + return { + reformId: report.baselineReformId, + label: reform?.label || 'Untitled reform', + policyId, }; } diff --git a/app/src/libs/flagship/reportSchema.ts b/app/src/libs/flagship/reportSchema.ts index 55e50ff77..aef54db90 100644 --- a/app/src/libs/flagship/reportSchema.ts +++ b/app/src/libs/flagship/reportSchema.ts @@ -7,8 +7,8 @@ * API report id; and * - its record (`FlagshipReportRecord`): the pointer to the API report * plus the same provenance, kept by the report store — the - * calculator-app routes (Postgres), or this browser's localStorage - * when they are unconfigured. + * `/v2/user-reports` row on the Public API (US and UK), or this + * browser's localStorage (accounts off, and the other countries). * * Fields were added over time without a version, so every read goes * through a parser here. It upgrades an older shape to the current one, @@ -23,7 +23,9 @@ * `budgetWindow` and `baseline`. * Record: id, userId, countryId, apiReportId, title, sourceNote, * provisions, year, createdAt; later `reformId` and `validation`, - * and then `userId` was dropped. + * and then `userId` was dropped. `budgetWindowYears` and + * `baselineReformId` came with the account rows; a record without + * them reads as having no window and current law as its baseline. * 2 — the same fields, stamped, with defaults filled and no `userId`. * * To add a field: add it to the type and give it a rule below (the rule @@ -33,18 +35,17 @@ * does not know is dropped on read, so a field is only safe to write * once its rule is here. * - * Central rows have no version column: their columns are versioned by - * the database migrations, and the routes always serve the current - * columns. The rows' JSON fields (provisions, validation) follow the - * same rules as the browser's records, on the way in and on the way out. + * Account rows have no version column: the Public API's migrations + * version their columns, and it always serves the current ones. The + * store reads each row through the record rule, so its JSON fields + * (provisions, validation) follow the same rules as the browser's. */ import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; import { CountryId, countryIds } from '@/libs/countries'; import type { CalibrationRing } from '@/libs/flagship/calibrationMatching'; -import { - validationFromWire, - type ReportValidationMatch, - type ReportValidationSnapshot, +import type { + ReportValidationMatch, + ReportValidationSnapshot, } from '@/libs/flagship/reportValidation'; import type { FlagshipReportMeta, RunReportProvision } from '@/libs/flagship/runReport'; import type { ValueInterval } from '@/types/subIngredients/valueInterval'; @@ -318,12 +319,22 @@ const recordRule = shape({ title: nullableText, sourceNote: nullableText, provisions: provisionsRule, - year: requiredText, + // Account rows leave the year to the API report itself. + year: optional(requiredText), createdAt: text(''), reformId: nullableText, // The pin is a cache of what validation matched: a damaged one is // dropped, and the report pins again, rather than the record failing. validation: orNull(validationRule), + // A damaged window is dropped, as the report can still be read without one. + budgetWindowYears: optional( + orNull((value, field) => + Number.isInteger(value) && (value as number) >= 2 + ? (value as number) + : fail(field, 'a whole number of years, at least 2') + ) + ), + baselineReformId: nullableText, }); /* Upgrades, keyed by the version they upgrade from. */ @@ -336,7 +347,7 @@ const META_UPGRADES: Record = { }; const RECORD_UPGRADES: Record = { - // Records saved before ownership moved to edit tokens carry the shared user id. + // Records saved before user ids were dropped from browser records carry one. 1: ({ userId: _userId, ...stored }) => stored, }; @@ -399,87 +410,6 @@ export function parseReportRecord(stored: unknown): ReportParseResult - isJsonObject(provision) - ? { - path: provision.path, - breadcrumb: provision.breadcrumb, - unit: provision.unit, - baselineValue: provision.baseline_value, - value: provision.value, - } - : provision - ); -} - -/** A validation pin from the wire; one too damaged to convert reads as none. */ -function validationFromWireOrNull(wire: unknown): ReportValidationSnapshot | null { - try { - return validationFromWire(wire); - } catch { - return null; - } -} - -/** Reads a central store row (the routes' snake_case wire form) as a record. */ -export function parseReportRow(row: unknown): ReportParseResult { - if (!isJsonObject(row)) { - return parseReportRecord(row); - } - return parseReportRecord({ - // The routes serve the current columns (see above). - schemaVersion: REPORT_SCHEMA_VERSION, - id: row.id, - countryId: row.country_id, - apiReportId: row.api_report_id, - title: row.title, - sourceNote: row.source_note, - provisions: provisionsFromWire(row.provisions), - year: row.year, - createdAt: row.created_at, - reformId: row.reform_id, - validation: validationFromWireOrNull(row.validation), - }); -} - -/** The rule's error message for this value, or null when it fits. */ -function wireError(rule: Rule, value: unknown, field: string): string | null { - try { - rule(value, field); - return null; - } catch (error) { - if (error instanceof FieldError) { - return error.message; - } - throw error; - } -} - -/** - * Why a create body's provisions (snake_case) break the report schema, - * or null when they fit: the same rules the client reads them back with. - */ -export function reportProvisionsWireError(provisions: unknown): string | null { - return wireError(provisionsRule, provisionsFromWire(provisions), 'provisions'); -} - -/** - * Why a validation pin (snake_case) breaks the report schema, or null - * when it fits: the same rules the client reads it back with. - */ -export function reportValidationWireError(validation: unknown): string | null { - const snapshot = validationFromWireOrNull(validation); - if (!snapshot) { - return 'validation must be a snapshot with matched_at'; - } - return wireError(validationRule, snapshot, 'validation'); -} - /** Provenance as it is written: stamped with the current version. */ export function stampReportMeta(meta: FlagshipReportMeta): StoredReportMeta { return { ...meta, schemaVersion: REPORT_SCHEMA_VERSION }; diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index 8256323b1..202feb012 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -1,9 +1,14 @@ import { fetchBudgetWindow } from '@/api/budgetWindow'; +import { + LocalStorageFlagshipReportStore, + type FlagshipReportRecord, + type FlagshipReportStore, +} from '@/api/flagshipReportStore'; import { createPolicy } from '@/api/policy'; -import { createReportAndAssociateWithUser } from '@/api/report'; +import { createReport } from '@/api/report'; import { createSimulation } from '@/api/simulation'; import { fetchSocietyWideCalculation } from '@/api/societyWideCalculation'; -import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; +import { CURRENT_YEAR, FOREVER } from '@/constants'; import { CountryId } from '@/libs/countries'; import { baselineProvisions, customBaseline } from '@/libs/flagship/reportBaseline'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; @@ -15,10 +20,11 @@ import { parseReportMeta, stampReportMeta, type ReportParseResult } from './repo /** * The run bridge: turns a set of provisions (from a draft reform or a * tracked bill) into a full society-wide report — policy → baseline + - * reform simulations → report record — and returns the API report id + * reform simulations → report record — and returns the report's link, * whose page auto-starts the calculation and renders the chart - * dashboard. The API id is the durable one: a link carrying it opens - * on any device, where the local association id would not. + * dashboard. With accounts the link is the report's user-report ID, + * which anyone can open with its title and provisions; otherwise it is + * the v1 report's ID. */ export interface RunReportProvision { path: string; @@ -62,12 +68,29 @@ export interface FlagshipReportMeta { baseline?: FlagshipReportBaseline; } -const META_KEY = 'pe-flagship-report-meta'; +export const REPORT_META_KEY = 'pe-flagship-report-meta'; + +/** + * Provenance from signed-in runs. It is the account's (its record carries + * it to every device), so it is kept for this page session only, never in + * localStorage where the next person on this browser would find it, and + * is dropped on sign-out. + */ +const accountReportMeta = new Map(); + +function rememberAccountReportMeta(userReportId: string, meta: FlagshipReportMeta): void { + accountReportMeta.set(userReportId, meta); +} + +/** Drops the signed-in session's report provenance (on sign-out). */ +export function forgetAccountReportMeta(): void { + accountReportMeta.clear(); +} /** Every report's provenance as stored, of any schema version (see reportSchema). */ function readAllMeta(): Record { try { - return JSON.parse(localStorage.getItem(META_KEY) ?? '{}'); + return JSON.parse(localStorage.getItem(REPORT_META_KEY) ?? '{}'); } catch { return {}; } @@ -76,7 +99,10 @@ function readAllMeta(): Record { export function saveReportMeta(userReportId: string, unstamped: FlagshipReportMeta): void { const meta = stampReportMeta(unstamped); try { - localStorage.setItem(META_KEY, JSON.stringify({ ...readAllMeta(), [userReportId]: meta })); + localStorage.setItem( + REPORT_META_KEY, + JSON.stringify({ ...readAllMeta(), [userReportId]: meta }) + ); } catch { // Provenance header simply won't show if storage is unavailable. } @@ -104,6 +130,10 @@ export function readReportMetaResult( /** The report's provenance in the current shape; null when none is stored or it can't be read. */ export function readReportMeta(userReportId: string): FlagshipReportMeta | null { + const accountMeta = accountReportMeta.get(userReportId); + if (accountMeta) { + return accountMeta; + } const result = readReportMetaResult(userReportId); return result?.ok ? result.value : null; } @@ -116,12 +146,40 @@ export function removeReportMeta(userReportId: string): void { } const { [userReportId]: _removed, ...rest } = all; try { - localStorage.setItem(META_KEY, JSON.stringify(rest)); + localStorage.setItem(REPORT_META_KEY, JSON.stringify(rest)); } catch { // It stays stored; the report still opens without it. } } +/** + * A report record's provenance, as the run's own details would give it: + * how a report opened on another device, or after a reload, still shows + * its title, source, changes, budget window and comparison. A record + * without provisions has nothing to show, so it gives none. The record + * keeps only the link to its baseline reform, so the caller names it. + */ +export function reportMetaFromRecord( + record: FlagshipReportRecord | null | undefined, + reportYear: string | undefined, + baseline?: FlagshipReportBaseline +): FlagshipReportMeta | null { + if (!record || record.provisions.length === 0) { + return null; + } + const startYear = Number(reportYear); + return { + title: record.title ?? '', + sourceNote: record.sourceNote ?? '', + provisions: record.provisions, + createdAt: record.createdAt, + ...(record.budgetWindowYears && record.budgetWindowYears > 1 && startYear + ? { budgetWindow: { startYear, windowSize: record.budgetWindowYears } } + : {}), + ...(baseline && { baseline }), + }; +} + /** * A saved reform to compare the reform with, in place of current law. * From Build it brings its parameters, made into the baseline policy @@ -160,6 +218,13 @@ export interface RunFlagshipReportArgs { windowYears?: number; /** What the reform is compared with; current law when unset. */ baseline?: RunReportBaseline; + /** + * With accounts (US/UK): the report store for this session's user, the + * account or this browser's unauthenticated user. The run then writes + * exactly one `/v2/user-reports` row — with the title, source note, + * provisions and reform links — instead of the browser record. + */ + accountReportStore?: FlagshipReportStore | null; /** The model's parameters, to check and type each value as v2 does. */ parameters?: ParameterMetadataCollection; } @@ -209,11 +274,12 @@ function coercePeriods( /** * Creates (or, since the API dedupes identical policies, finds) the reform * policy for a set of provisions — each with its dated changes, or its - * single value from the current year onward. + * single value from the current year onward. The policy carries only the + * changes, never a label: names and titles belong to the user's records, + * so identical changes always share one policy. */ export async function createReformPolicy( countryId: CountryId, - title: string, provisions: Array>, /** The model's parameters: each value is checked and typed against them, as v2's policies are. */ parameters?: ParameterMetadataCollection @@ -221,10 +287,14 @@ export async function createReformPolicy( const data = Object.fromEntries( provisions.map((p) => [p.path, coercePeriods(p.path, provisionPeriods(p), parameters)]) ); - const policyResponse = await createPolicy(countryId, { data, label: title || undefined }); + const policyResponse = await createPolicy(countryId, { data }); return Number(policyResponse.result.policy_id); } +/** + * Runs a report and records it. Answers the report's link: the account + * row's ID with accounts, otherwise the v1 report's (see reportLink). + */ export async function runFlagshipReport({ countryId, title, @@ -236,6 +306,7 @@ export async function runFlagshipReport({ region, windowYears, baseline: requestedBaseline, + accountReportStore, parameters, }: RunFlagshipReportArgs): Promise { if (provisions.length === 0) { @@ -243,17 +314,13 @@ export async function runFlagshipReport({ } // Compared with current law, unless a saved reform is chosen instead. + // Neither policy carries a name: names live on the user's records. const comparison = customBaseline(requestedBaseline, currentLawId); const [reformPolicyId, baselineId] = await Promise.all([ - createReformPolicy(countryId, title, provisions, parameters), + createReformPolicy(countryId, provisions, parameters), comparison?.policyId ?? (comparison - ? createReformPolicy( - countryId, - comparison.label, - baselineProvisions(comparison), - parameters - ) + ? createReformPolicy(countryId, baselineProvisions(comparison), parameters) : currentLawId), ]); @@ -270,21 +337,21 @@ export async function runFlagshipReport({ }), ]); - const { metadata } = await createReportAndAssociateWithUser({ - countryId, - userId: MOCK_USER_ID, - label: title || undefined, - payload: { - simulation_1_id: Number(baseline.result.simulation_id), - simulation_2_id: Number(reform.result.simulation_id), - year: String(year), - }, - }); + const payload = { + simulation_1_id: Number(baseline.result.simulation_id), + simulation_2_id: Number(reform.result.simulation_id), + year: String(year), + }; - const reportId = String(metadata.baseReportId); + // The v1 report comes first. Then exactly one record of it: through the + // API with accounts, whose ID is the report's link (anyone can open it), + // otherwise this browser's flagship record, with the v1 report's ID as + // the link (v3 never writes the legacy report keys). + const reportMetadata = await createReport(countryId, payload); + const reportId = String(reportMetadata.id); const budgetWindow = windowYears && windowYears > 1 ? { startYear: year, windowSize: windowYears } : undefined; - saveReportMeta(reportId, { + const meta: FlagshipReportMeta = { title, sourceNote, provisions, @@ -297,7 +364,35 @@ export async function runFlagshipReport({ label: comparison.label, }, }), - }); + }; + const record = { + countryId, + apiReportId: reportId, + title: title || null, + sourceNote: sourceNote || null, + provisions, + year: String(year), + reformId: reformId ?? null, + // Kept on the record so the report shows its window on any device. + budgetWindowYears: budgetWindow?.windowSize ?? null, + // The comparison's name is its saved reform's, read from the link. + baselineReformId: comparison?.reformId ?? null, + }; + + let link = reportId; + if (accountReportStore) { + // A failure fails the run, so a report that opens is one that was saved. + link = (await accountReportStore.create(record)).id; + rememberAccountReportMeta(link, meta); + } else { + saveReportMeta(reportId, meta); + // Best-effort: the run already succeeded, and the link opens without it. + try { + await new LocalStorageFlagshipReportStore().create(record); + } catch { + // The report still opens by its v1 report ID. + } + } // Start the report's own calculation now, not when its page opens: the // first request queues the job, so a tab closed right after "Run" still @@ -325,35 +420,21 @@ export async function runFlagshipReport({ }); } - // Record the report centrally (pointer + provenance; never outputs, - // never a user id). Best-effort: the run already succeeded, and the - // store falls back to localStorage when the database is unconfigured. - try { - const { getFlagshipReportStore } = await import('@/api/flagshipReportStore'); - await getFlagshipReportStore().create({ - countryId, - apiReportId: String(metadata.baseReportId), - title: title || null, - sourceNote: sourceNote || null, - provisions, - year: String(year), - reformId: reformId ?? null, - }); - } catch { - // The report still opens via the local association. - } - - return reportId; + return link; } /** - * Query keys for the reports this browser ran, as Your reforms lists - * them by reform, and for where each one stands on the API. + * Query keys for this session's reports, as Your reforms lists them by + * reform (scoped to whose saved work they are, see V3SavedWork), and for + * where each one stands on the API. */ export const flagshipReportQueryKeys = { all: ['flagship-reports'] as const, - mine: (countryId: string) => ['flagship-reports', countryId] as const, + mine: (scope: string, countryId: string) => ['flagship-reports', scope, countryId] as const, statuses: ['flagship-report-status'] as const, status: (countryId: string, apiReportId: string) => ['flagship-report-status', countryId, apiReportId] as const, + /** A report's baseline simulation, as the API's metadata (not the adapted Simulation). */ + baselineSimulation: (countryId: string, simulationId: string) => + ['flagship-report-baseline-simulation', countryId, simulationId] as const, }; diff --git a/app/src/libs/flagship/storeText.ts b/app/src/libs/flagship/storeText.ts index 6d63cb749..6fa2d5f3d 100644 --- a/app/src/libs/flagship/storeText.ts +++ b/app/src/libs/flagship/storeText.ts @@ -1,15 +1,28 @@ /** - * The central store takes free text (a reform label, a report title or - * source note) of at most this many characters, and refuses longer - * (calculator-app/src/db/limits.ts). A long bill title would fail the - * save outright, so the client shortens it first. + * The Public API's saved-work routes take a name (a reform's label, a + * report's title) of at most 255 characters and a note of at most 10,000, + * and refuse longer. A long bill title would fail the save outright, so + * the client shortens it first. */ -export const MAX_STORE_TEXT_LENGTH = 500; +export const MAX_STORE_NAME_LENGTH = 255; +export const MAX_STORE_NOTE_LENGTH = 10_000; -/** The text cut to the store's limit, with an ellipsis; null and undefined pass through. */ -export function clampStoreText(text: T): T { - if (typeof text !== 'string' || text.length <= MAX_STORE_TEXT_LENGTH) { +/** + * The text cut to `maxLength` characters, with an ellipsis; null and + * undefined pass through. Characters are code points, as the API counts + * them, so an emoji is one character and is never cut in half (half of one + * is not valid text, and the API refuses it). + */ +export function clampStoreText( + text: T, + maxLength: number = MAX_STORE_NAME_LENGTH +): T { + if (typeof text !== 'string') { + return text; + } + const characters = Array.from(text); + if (characters.length <= maxLength) { return text; } - return `${text.slice(0, MAX_STORE_TEXT_LENGTH - 1)}…` as T; + return `${characters.slice(0, maxLength - 1).join('')}…` as T; } diff --git a/app/src/libs/storeBackend.ts b/app/src/libs/storeBackend.ts deleted file mode 100644 index 48323f0e8..000000000 --- a/app/src/libs/storeBackend.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Store Backend Selection - * - * Centralized logic for determining whether association data should be - * read from the v2 API or localStorage. - * - * All user-ingredient associations (reports, simulations, policies, - * households) are stored in the v2 API. The only thing in localStorage - * is the anonymous user UUID. - * - * Failure mode: if the v2 API is unreachable, callers will receive - * unhandled fetch errors. There is currently no automatic fallback to - * localStorage. To temporarily switch backends during an incident, set - * the VITE_STORE_BACKEND environment variable to 'localStorage'. - */ - -export type StoreBackend = 'api' | 'localStorage'; - -export function getStoreBackend(): StoreBackend { - const envOverride = ( - typeof import.meta !== 'undefined' ? import.meta.env?.VITE_STORE_BACKEND : undefined - ) as string | undefined; - if (envOverride === 'api' || envOverride === 'localStorage') { - return envOverride; - } - return 'api'; -} diff --git a/app/src/pages/flagship/Account.page.tsx b/app/src/pages/flagship/Account.page.tsx new file mode 100644 index 000000000..b5ef4a914 --- /dev/null +++ b/app/src/pages/flagship/Account.page.tsx @@ -0,0 +1,303 @@ +import { useState } from 'react'; +import { IconDownload, IconTrash } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { deleteMe, exportMe, fetchMe } from '@/api/v2Me'; +import { AppLink } from '@/components/AppLink'; +import { accountDisplayName } from '@/components/auth/AuthMenu'; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Spinner, + Stack, + Text, + Title, +} from '@/components/ui'; +import { colors, spacing } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useAuthStatus } from '@/libs/auth/AuthContext'; +import { useV2UserClient } from '@/libs/auth/useV2UserClient'; +import { isAssociationCountry, type AssociationCountryId } from '@/types/v2User'; +import { getCountryLabel } from '@/utils/geographyUtils'; + +const EXPORT_FILE_NAME = 'policyengine-account-data.json'; + +function downloadJson(data: unknown, fileName: string): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +function formatDate(iso: string | undefined | null): string | null { + if (!iso) { + return null; + } + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? null + : date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function PageShell({ children }: { children: React.ReactNode }) { + return ( + + + Your account + + {children} + + ); +} + +function DetailRow({ label, value }: { label: string; value: string | null }) { + if (!value) { + return null; + } + return ( +
+ + {label} + + + {value} + +
+ ); +} + +/** + * The v3 account page: who the signed-in user is, downloading everything + * saved to the account, and deleting the account. Reads the PolicyEngine + * user from `GET /v2/me` (the user menu uses the Auth0 ID token instead). + */ +export default function AccountPage() { + const auth = useAuthStatus(); + const countryId = useCurrentCountry(); + + if (auth.status === 'disabled') { + return ( + + Signing in is not available here yet. + + ); + } + + // Accounts are for the US and UK: elsewhere there is nothing to sign in to. + if (!isAssociationCountry(countryId)) { + return ( + + + Accounts store work for the US and UK calculators. Work in the{' '} + {getCountryLabel(countryId)} calculator stays on this device. Manage your account from{' '} + the US calculator or{' '} + the UK calculator. + + {auth.status === 'signed_in' && ( +
+ +
+ )} +
+ ); + } + + if (auth.status === 'signed_out') { + return ( + + + + Sign in to keep your work + + The reforms and reports you save now are kept for this browser. Sign in to keep them + in your account and use them on any device. + + + + + + + + ); + } + + return ; +} + +function SignedInAccount({ countryId }: { countryId: AssociationCountryId }) { + const auth = useAuthStatus(); + const client = useV2UserClient(); + const me = useQuery({ + queryKey: ['account', auth.subject, countryId], + queryFn: () => fetchMe(client, countryId), + }); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + if (me.isPending) { + return ( + + + + Loading your account… + + + ); + } + + if (me.isError) { + return ( + + + We could not load your PolicyEngine account: {me.error.message} + +
+ + +
+
+ ); + } + + const user = me.data; + + const handleExport = async () => { + setExporting(true); + setExportError(null); + try { + downloadJson(await exportMe(client, countryId), EXPORT_FILE_NAME); + } catch (error) { + setExportError(error instanceof Error ? error.message : 'The download did not complete.'); + } finally { + setExporting(false); + } + }; + + const handleDelete = async () => { + setDeleting(true); + setDeleteError(null); + try { + await deleteMe(client, countryId); + await auth.signOut(); + } catch (error) { + setDeleteError(error instanceof Error ? error.message : 'The account was not deleted.'); + setDeleting(false); + } + }; + + return ( + + + + {accountDisplayName(auth)} + Signed in with your social account. + + + + + + + + + + + + + + Download my data + + A JSON file with your account and everything saved to it, in every country. + + + + +
+ +
+ {exportError && ( + + {exportError} + + )} +
+
+
+ + + + Delete my account + + Permanently deletes your account and everything saved to it. Work saved on this browser + while signed out stays on this browser. + + + + + + + + !open && !deleting && setConfirmingDelete(false)} + > + + + Delete your account? + + This permanently deletes your PolicyEngine account and every reform and report saved + to it. It cannot be undone. + + + {deleteError && ( + + {deleteError} + + )} + + + + + + +
+ ); +} diff --git a/app/src/pages/flagship/AuthCallback.page.tsx b/app/src/pages/flagship/AuthCallback.page.tsx new file mode 100644 index 000000000..d1d67454f --- /dev/null +++ b/app/src/pages/flagship/AuthCallback.page.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react'; +import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { colors, spacing } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { runAccountUpgrade } from '@/libs/auth/accountUpgrade'; +import { useAuthStatus } from '@/libs/auth/AuthContext'; +import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; +import { useV2UserClient } from '@/libs/auth/useV2UserClient'; +import { isAssociationCountry } from '@/types/v2User'; + +/** + * Where Auth0 sends the browser after sign-in. The AuthProvider finishes + * the code exchange (showing its loading state meanwhile); this page + * upgrades this browser's unauthenticated user into the account (see + * runAccountUpgrade), which also creates the account on a first sign-in, + * then returns to where sign-in started. It shows the error when sign-in + * did not complete. + */ +export default function AuthCallbackPage() { + const countryId = useCurrentCountry(); + const nav = useAppNavigate(); + const { status, error } = useAuthStatus(); + const client = useV2UserClient(); + const started = useRef(false); + + useEffect(() => { + if (started.current || (status === 'signed_out' && error)) { + return; + } + started.current = true; + const home = `/${countryId}`; + if (status !== 'signed_in') { + nav.replace(home); + return; + } + const returnTo = takeSignInReturnTo() ?? home; + void (async () => { + if (isAssociationCountry(countryId)) { + await runAccountUpgrade({ client, countryId }); + } + nav.replace(returnTo); + })(); + }, [status, error, countryId, nav, client]); + + if (status === 'signed_out' && error) { + return ( + + + Sign-in did not complete + + {error.message} + + + ); + } + + return ( + + + Signing you in… + + ); +} diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index bd20bdc78..c83ce82c4 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -12,8 +12,9 @@ import { import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { billRunYear, TrackedBill } from '@/api/billFeed'; -import { getFlagshipReportStore } from '@/api/flagshipReportStore'; -import { getReformStore } from '@/api/reformStore'; +import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { fetchReportById } from '@/api/report'; +import { fetchSimulationById } from '@/api/simulation'; import BillFeedFilters from '@/components/flagship/bills/BillFeedFilters'; import BillStageBadge from '@/components/flagship/bills/BillStageBadge'; import DeleteReformDialog from '@/components/flagship/DeleteReformDialog'; @@ -36,9 +37,15 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { billRegion } from '@/hooks/useBillEconomy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useFlagshipReportStore, useReformStore, useV3SavedWork } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; import { useTrackedBills } from '@/hooks/useTrackedBills'; -import { loadProvisionsIntoDraft } from '@/libs/draftReform'; +import { + loadProvisionsIntoDraft, + provisionsToRun, + reformToProvisions, + type ProvisionDetails, +} from '@/libs/draftReform'; import { BillFilters, billPlace, filterBills, NO_BILL_FILTERS } from '@/libs/flagship/billFilters'; import { billAlreadyCurrentLaw, @@ -51,7 +58,7 @@ import { rerunSettings } from '@/libs/flagship/reportRerun'; import { flagshipReportQueryKeys } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; import { Reform, ReformSource } from '@/types/ingredients/Reform'; -import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; +import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; import { formatBudgetaryImpact } from '@/utils/formatPowers'; import { formatLabelParts, getHierarchicalLabels } from '@/utils/parameterLabels'; import { formatValue, getCurrentValue } from '@/utils/parameterValues'; @@ -87,15 +94,6 @@ const filterControl: React.CSSProperties = { color: colors.text.secondary, }; -interface ProvisionView { - path: string; - breadcrumb: string; - unit: string | null; - baselineValue: any; - value: any; - intervals?: ValueInterval[]; -} - /** The year a saved reform's report runs: its first change, or this year once that is past. */ const reformRunYear = (reform: Reform) => billRunYear(reform.parameters.map((parameter) => ({ intervals: parameter.values }))); @@ -300,6 +298,8 @@ export default function ReformsPage() { const countryId = useCurrentCountry(); const queryClient = useQueryClient(); const parameters = useSelector((state: RootState) => state.metadata.parameters); + const currentLawPolicyId = + Number(useSelector((state: RootState) => state.metadata.currentLawId)) || undefined; const appLocation = useAppLocation(); const urlParams = useMemo(() => new URLSearchParams(appLocation.search), [appLocation.search]); @@ -312,6 +312,11 @@ export default function ReformsPage() { const [selectedId, setSelectedId] = useState(() => urlParams.get('bill')); const runReport = useRunFlagshipReport(); + const reformStore = useReformStore(); + const { store: reportStore } = useFlagshipReportStore(); + const { scope } = useV3SavedWork(); + // A re-run whose settings could not be read; the run's own errors are runReport's. + const [rerunError, setRerunError] = useState(null); const { bills, isConfigured: billsConfigured, @@ -325,17 +330,17 @@ export default function ReformsPage() { isPending, isError, } = useQuery({ - queryKey: ['reforms', countryId], - queryFn: () => getReformStore().findMine(countryId), + queryKey: ['reforms', scope, countryId], + queryFn: () => reformStore.findMine(countryId), }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['reforms'] }); - // The reports this browser ran, by the saved reform each came from: a - // reform is where its reports are found again. + // This session's reports, by the saved reform each came from: a reform + // is where its reports are found again. const { data: reports } = useQuery({ - queryKey: flagshipReportQueryKeys.mine(countryId), - queryFn: () => getFlagshipReportStore().findMine(countryId), + queryKey: flagshipReportQueryKeys.mine(scope, countryId), + queryFn: () => reportStore.findMine(countryId), }); const reportsFor = (reformId: string | undefined) => reformId ? (reports ?? []).filter((report) => report.reformId === reformId) : []; @@ -355,7 +360,7 @@ export default function ReformsPage() { }; const deleteMutation = useMutation({ - mutationFn: (reformId: string) => getReformStore().delete(reformId), + mutationFn: (reformId: string) => reformStore.delete(reformId), onSuccess: () => { setSelectedId(null); invalidate(); @@ -376,6 +381,57 @@ export default function ReformsPage() { }); }; + const provisionDetails: ProvisionDetails = (path) => ({ + breadcrumb: resolveBreadcrumb(path), + unit: parameters?.[path]?.unit ?? null, + baselineValue: getCurrentValue(parameters?.[path]?.values), + }); + + /** A saved reform of this session's, by its ID. */ + const savedReform = (reformId: string) => reforms?.find((reform) => reform.id === reformId); + + /** + * The year a report scored. A record saved through the API keeps no year + * (the report itself carries it), so it is read from the API's record, + * as the report rows read it; unknown when that fails. + */ + const reportYear = async (report: FlagshipReportRecord): Promise => { + if (report.year) { + return report.year; + } + try { + const apiReport = await queryClient.fetchQuery({ + queryKey: flagshipReportQueryKeys.status(countryId, report.apiReportId), + queryFn: () => fetchReportById(countryId, report.apiReportId), + staleTime: 60_000, + }); + return apiReport.year ?? undefined; + } catch { + return undefined; + } + }; + // The policy a report's baseline simulation ran: current law, or the + // reform it was compared with, even when its record no longer names it. + const reportBaselinePolicyId = async ( + report: FlagshipReportRecord + ): Promise => { + try { + const apiReport = await queryClient.fetchQuery({ + queryKey: flagshipReportQueryKeys.status(countryId, report.apiReportId), + queryFn: () => fetchReportById(countryId, report.apiReportId), + staleTime: 60_000, + }); + const simulation = await queryClient.fetchQuery({ + queryKey: flagshipReportQueryKeys.baselineSimulation(countryId, apiReport.simulation_1_id), + queryFn: () => fetchSimulationById(countryId, apiReport.simulation_1_id), + staleTime: Infinity, + }); + return simulation.policy_id ? String(simulation.policy_id) : undefined; + } catch { + return undefined; + } + }; + // As on the bill's report: stored parameters that contradict the bill's // write-up (UT SB60's reform and baseline are swapped) would score // another change, so neither a report nor a draft starts from them. @@ -392,23 +448,6 @@ export default function ReformsPage() { : undefined; }; - /** The reform as the report runs it: every dated value, described in the run year. */ - const reformProvisions = (reform: Reform): ProvisionView[] => { - const year = reformRunYear(reform); - return reform.parameters.map((parameter) => { - const baselineValue = currentLawAt(parameter.name, year); - const inYear = new ValueIntervalCollection(parameter.values).getValueAtDate(`${year}-01-01`); - return { - path: parameter.name, - breadcrumb: resolveBreadcrumb(parameter.name), - unit: parameters?.[parameter.name]?.unit ?? null, - baselineValue, - value: inYear ?? baselineValue, - ...(parameter.values.length > 0 && { intervals: parameter.values }), - }; - }); - }; - const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const matches = (haystack: string) => tokens.every((token) => haystack.toLowerCase().includes(token)); @@ -462,8 +501,9 @@ export default function ReformsPage() { : null; const openReform = (reform: Reform) => { - // A failed delete belongs to the reform it was tried on. + // A failed delete or re-run belongs to the reform it was tried on. deleteMutation.reset(); + setRerunError(null); setSelectedId(reform.id!); }; @@ -679,6 +719,16 @@ export default function ReformsPage() { const count = selectedReform.parameters.length; // The store starts with no parameters; names and current law need them. const metadataReady = !!parameters && Object.keys(parameters).length > 0; + // What the report would run, as Build runs it (dated changes + // included); a reform that changes nothing has no report to run. + // Run in the reform's own year: its first change, or this year once that is past. + const runYear = reformRunYear(selectedReform); + const runProvisions = provisionsToRun( + reformToProvisions(selectedReform, provisionDetails), + runYear, + parameters + ); + const changesNothing = !selectedReform.parametersUnavailable && runProvisions.length === 0; return ( @@ -690,12 +740,25 @@ export default function ReformsPage() { - {count} {count === 1 ? 'provision' : 'provisions'} + {selectedReform.parametersUnavailable + ? 'Changes could not be loaded' + : `${count} ${count === 1 ? 'provision' : 'provisions'}`} {selectedReform.createdAt ? ` · saved ${dayjs(selectedReform.createdAt).format('MMM D, YYYY')}` : ''} + {selectedReform.parametersUnavailable && ( + + + This reform's changes could not be loaded, so it cannot be run right now. Nothing + about it has changed. + + + + )}
@@ -828,28 +891,44 @@ export default function ReformsPage() { }} >
- {runReport.error && ( + {changesNothing && ( + + Nothing in this reform differs from current law. Adjust at least one value to run the + report. + + )} + {(runReport.error || rerunError) && ( - {runReport.error} + {runReport.error || rerunError} )} {deleteMutation.isError && ( @@ -876,17 +963,31 @@ export default function ReformsPage() { countryId={countryId} reports={reportsFor(selectedReform.id)} rerunning={runReport.isRunning} + reformName={(reformId) => { + const reform = savedReform(reformId); + return reform ? reform.label || 'Untitled reform' : undefined; + }} onRerun={async (report) => { + setRerunError(null); // The same report on today's model: its reform as it ran, its - // year, population, budget window and comparison. - const settings = await rerunSettings(countryId, report); + // year, population, budget window and comparison, linked to the + // same saved reform. + const settings = await rerunSettings(countryId, report, { + savedReform, + currentLawPolicyId, + }).catch(() => null); + if (!settings) { + setRerunError('Could not re-run the report. Try again.'); + return; + } + const { year, ...runSettings } = settings; await runReport.run( report.title || selectedReform.label || 'Untitled reform', report.sourceNote || SOURCE_LABELS[selectedReform.provenance.source], report.provisions, - selectedReform.id, - Number(report.year) || undefined, - settings + report.reformId ?? selectedReform.id, + year, + runSettings ); }} /> @@ -1087,7 +1188,11 @@ export default function ReformsPage() { visibleReforms.map((reform) => ( { const latest = latestReport(reform.id); return latest?.apiReportId ? ( diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index cf343fb71..5b37a7f54 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/societyWideCalculation'; +import { isAccountId } from '@/api/v2Associations'; import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; import { populationLabel } from '@/components/flagship/reform/PopulationPicker'; import AllYearsView from '@/components/flagship/report/AllYearsView'; @@ -24,6 +25,11 @@ import { colors, spacing } from '@/designTokens'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; import { isApiReportId, useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { + useFlagshipReportStore, + useReportRecord, + useSharedReport, +} from '@/hooks/useFlagshipStores'; import { useHydrateCalculationCache } from '@/hooks/useHydrateCalculationCache'; import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot'; import { useReportYearEconomies } from '@/hooks/useReportYearEconomy'; @@ -38,7 +44,7 @@ import { UNKNOWN_BASELINE_LABEL, } from '@/libs/flagship/reportBaseline'; import { provenanceFromPolicy } from '@/libs/flagship/reportProvenance'; -import { readReportMeta } from '@/libs/flagship/runReport'; +import { readReportMeta, reportMetaFromRecord } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; import type { CalcStartConfig } from '@/types/calculation'; import { allSimulationsLoaded } from '@/utils/reportSimulations'; @@ -58,6 +64,14 @@ function LeadError({ children }: { children: React.ReactNode }) { ); } +/** + * The budget-window years asked for on reports opened by a saved report's + * link, kept for this page session only: the link opens the report for + * anyone, so it is never left in this browser's storage for the next + * person here to find. + */ +const sharedLinkRequestedYears = new Map(); + interface FlagshipReportPageProps { /** Passed by the Next.js route bridge; react-router falls back to params. */ userReportId?: string; @@ -84,28 +98,61 @@ function runStartedAt(...stamps: Array): number | und export default function FlagshipReportPage({ userReportId: propId }: FlagshipReportPageProps) { const params = useParams<{ userReportId: string }>(); const userReportId = propId ?? params.userReportId ?? ''; + // A report's link is its saved report's ID, which anyone can open with its + // owner's details. Without accounts (sign-in off, or outside the US and + // UK) nothing is shared, so the link is the v1 report's number; where + // accounts exist, such a link is an old one and no longer opens, nor does + // an old browser-only "sur-" link. + const { isAccount } = useFlagshipReportStore(); + const sharedLink = isAccountId(userReportId); + const browserLink = !isAccount && isApiReportId(userReportId); + const unresolvable = !!userReportId && !sharedLink && !browserLink; + const sharedQuery = useSharedReport(sharedLink ? userReportId : undefined); + const shared = sharedQuery.data ?? null; const { report, simulations, policies, isLoading, - error: loadError, - notFound, + error: reportLoadError, + notFound: reportNotFound, failedPolicyIds, - retry, - } = useFlagshipReport(userReportId); - // A local association id from another browser resolves to nothing. - const unresolvable = !isLoading && !report && !!userReportId && !isApiReportId(userReportId); + retry: retryReport, + } = useFlagshipReport( + (sharedLink ? shared?.record.apiReportId : browserLink ? userReportId : undefined) ?? '' + ); + // No saved report has the link's ID (deleted, or a bad link). + const sharedNotFound = sharedLink && sharedQuery.isSuccess && !shared; + const sharedLoadError = sharedLink ? sharedQuery.error : null; + const loadError = sharedLoadError ?? reportLoadError; + const notFound = sharedNotFound || reportNotFound; + const retry = sharedLoadError ? sharedQuery.refetch : retryReport; const parameters = useSelector((state: RootState) => state.metadata.parameters); const economyRegions = useSelector((state: RootState) => state.metadata.economyOptions.region); const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); - // Provenance: the local stash from the run, else rebuilt from the reform - // policy so a shared link validates like the original — read in the + // This session's own record, when it ran the report: it names the saved + // reform the report was run from, and holds the validation pin. + const reportRecord = useReportRecord(unresolvable ? undefined : userReportId); + // Provenance: the run's details, else the saved report's (the same for + // anyone holding the link), else rebuilt from the reform policy so a + // link without accounts validates like the original — read in the // report's year, with every dated value the run had. const reformPolicyId = simulations?.[1]?.policyId; const reformPolicy = policies.find((policy) => policy.id === reformPolicyId); + const baselinePolicyId = simulations?.[0]?.policyId; + // The saved report names the reform it was compared with; only its owner + // links to that reform. + const sharedBaseline = + shared?.baselineReform && baselinePolicyId + ? { + reformId: reportRecord?.baselineReformId ?? null, + policyId: String(baselinePolicyId), + label: shared.baselineReform.name || 'Untitled reform', + } + : undefined; const meta = readReportMeta(userReportId) ?? + reportMetaFromRecord(shared?.record, report?.year, sharedBaseline) ?? provenanceFromPolicy(reformPolicy, parameters, report?.label, report?.year || undefined); const calcStatus = useCalculationStatus(report?.id || '', 'report'); @@ -119,7 +166,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // to adjust. A policy or metadata that failed lets it show without them. const awaitingProvisions = !meta && - isApiReportId(userReportId) && + !unresolvable && !!reformPolicyId && !failedPolicyIds.includes(reformPolicyId) && metadata.status !== 'error'; @@ -187,7 +234,6 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const output = calcStatus.isComplete ? (calcStatus.result as SocietyWideOutput) : null; - const baselinePolicyId = simulations?.[0]?.policyId; const baselinePolicy = policies.find((policy) => policy.id === baselinePolicyId); // A baseline policy that failed to load is unknown, not current law. const baselineUnknown = reportBaselineUnknown(meta, baselinePolicyId, { @@ -219,9 +265,13 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const [view, setView] = useState(null); // The years asked for keep running on the server after the page closes, // so the browser remembers them: back on the report, they show where they - // stand rather than as never run. + // stand rather than as never run. A saved report's link keeps them for + // this page session only (see sharedLinkRequestedYears). const yearsKey = `pe-flagship-report-years-${userReportId}`; const [requestedYears, setRequestedYearsState] = useState(() => { + if (sharedLink) { + return sharedLinkRequestedYears.get(userReportId) ?? []; + } try { const stored = JSON.parse(localStorage.getItem(yearsKey) ?? '[]'); return Array.isArray(stored) ? stored.filter((year) => typeof year === 'string') : []; @@ -232,6 +282,10 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const setRequestedYears = (update: (current: string[]) => string[]) => setRequestedYearsState((current) => { const next = update(current); + if (sharedLink) { + sharedLinkRequestedYears.set(userReportId, next); + return next; + } try { localStorage.setItem(yearsKey, JSON.stringify(next)); } catch { @@ -249,10 +303,13 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const provisionPaths = meta?.provisions.map((p) => p.path) ?? []; const trackRecord = useModelTrackRecord(provisionPaths); const calibration = useCalibrationMatches(provisionPaths, region); - // The store keys records by the API report id as a string. const pin = useReportValidationSnapshot( - report?.id !== undefined && report?.id !== null ? String(report.id) : undefined, - { calibration, programs: trackRecordPrograms(trackRecord) } + unresolvable ? undefined : userReportId, + { + calibration, + programs: trackRecordPrograms(trackRecord), + }, + shared?.record.validation ?? null ); const title = meta?.title || report?.label || 'Impact report'; @@ -309,7 +366,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep ); } // A report that failed to load would otherwise wait here for good. - if (loadError) { + if (loadError || notFound) { return layout( @@ -336,6 +393,8 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep year: Number(report.year) || undefined, // Back in Build on the population it ran on. region: region && region !== report.countryId ? region : undefined, + // Saving the edit changes the reform it was run from. + reformId: reportRecord?.reformId, baselineReformId: meta.baseline?.reformId, }) : undefined diff --git a/app/src/tests/fixtures/api/flagshipStoreMocks.ts b/app/src/tests/fixtures/api/flagshipStoreMocks.ts deleted file mode 100644 index 8ea060902..000000000 --- a/app/src/tests/fixtures/api/flagshipStoreMocks.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { vi } from 'vitest'; -import type { NewFlagshipReport } from '@/api/flagshipReportStore'; -import type { NewReform } from '@/api/reformStore'; -import { - API_REPORT_ID, - CENTRAL_REFORM_ID, - CENTRAL_REPORT_ID, - EDIT_TOKEN, -} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; -import type { ReformCreatedMetadata, ReformMetadata } from '@/types/metadata/reformMetadata'; - -export const LEGACY_REFORMS_KEY = 'reforms'; -export const LEGACY_REPORTS_KEY = 'pe-flagship-reports'; - -export const mockNewReform: NewReform = { - countryId: 'us', - label: 'CTC to $3,600 for children under 6', - parameters: [ - { - name: 'gov.irs.credits.ctc.amount.base[0]', - values: [{ startDate: '2026-01-01', endDate: '2026-12-31', value: 3600 }], - }, - ], - baseline: 'current-law', - provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, -}; - -export const mockReformMetadata: ReformMetadata = { - id: CENTRAL_REFORM_ID, - country_id: 'us', - label: 'CTC to $3,600 for children under 6', - policy_id: null, - parameters: [ - { - name: 'gov.irs.credits.ctc.amount.base[0]', - values: [{ start_date: '2026-01-01', end_date: '2026-12-31', value: 3600 }], - }, - ], - baseline: 'current-law', - provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, - created_at: '2026-08-01T00:00:00Z', - updated_at: '2026-08-01T00:00:00Z', -}; - -export const mockReformCreatedMetadata: ReformCreatedMetadata = { - ...mockReformMetadata, - edit_token: EDIT_TOKEN, -}; - -/** - * A reform the localStorage fallback saved before user ids were dropped: - * it still carries the shared mock user id. - */ -export const legacyLocalReform = { - id: 'rf-mfz1abcd', - userId: 'anonymous', - countryId: 'us', - label: 'Saved while the database was off', - parameters: [ - { - name: 'gov.irs.credits.eitc.max[0]', - values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 700 }], - }, - ], - baseline: 'current-law', - provenance: { source: 'manual' }, - createdAt: '2026-09-01T00:00:00.000Z', - updatedAt: '2026-09-02T00:00:00.000Z', -}; - -export const mockNewReport: NewFlagshipReport = { - countryId: 'us', - apiReportId: API_REPORT_ID, - title: 'CTC to $2,500', - sourceNote: 'Hand-built draft', - provisions: [ - { - path: 'gov.irs.credits.ctc.amount.base[0].amount', - breadcrumb: 'IRS → Credits → CTC → Amount', - unit: 'currency-USD', - baselineValue: 2200, - value: 2500, - }, - ], - year: '2026', - reformId: null, -}; - -export const mockReportRow = { - id: CENTRAL_REPORT_ID, - country_id: 'us', - api_report_id: API_REPORT_ID, - title: 'CTC to $2,500', - source_note: 'Hand-built draft', - provisions: [], - reform_id: null, - year: '2026', - validation: null, - created_at: '2026-09-07T00:00:00.000Z', -}; - -/** A report record the localStorage fallback saved before user ids were dropped. */ -export const legacyLocalReport = { - id: 'lfr-mfz1abcdef', - userId: 'anonymous', - countryId: 'us', - apiReportId: '3301', - title: 'EITC expansion', - sourceNote: 'Hand-built draft', - provisions: [], - year: '2026', - createdAt: '2026-09-03T00:00:00.000Z', - reformId: null, -}; - -export const HTTP_STATUS = { - OK: 200, - CREATED: 201, - FORBIDDEN: 403, - NOT_FOUND: 404, - TOO_MANY_REQUESTS: 429, - INTERNAL_SERVER_ERROR: 500, - SERVICE_UNAVAILABLE: 503, -} as const; - -export const mockJsonResponse = (body: unknown, status: number = HTTP_STATUS.OK) => ({ - ok: status >= 200 && status < 300, - status, - json: vi.fn().mockResolvedValue(body), -}); - -export const mockErrorResponse = (status: number) => ({ ok: false, status }); diff --git a/app/src/tests/fixtures/api/v2UserMocks.ts b/app/src/tests/fixtures/api/v2UserMocks.ts new file mode 100644 index 000000000..7dd29e702 --- /dev/null +++ b/app/src/tests/fixtures/api/v2UserMocks.ts @@ -0,0 +1,168 @@ +import { vi } from 'vitest'; +import type { ApiStoreContext } from '@/api/v2Associations'; +import { createV2UserClient } from '@/api/v2UserClient'; +import type { MeItem, SharedReportItem, UserPolicyItem, UserReportItem } from '@/types/v2User'; + +export const TEST_API_BASE = 'https://api.test.policyengine.org'; +export const TEST_ACCESS_TOKEN = 'test-access-token'; +/** This browser's unauthenticated user in tests. */ +export const BROWSER_USER_ID = '6f1c2a9e-3b4d-4e5f-8a7b-9c0d1e2f3a4b'; +export const PE_USER_ID = '7c1e5a8e-2b1f-4c55-9a3e-1f2d3c4b5a69'; + +export const ASSOCIATION_IDS = { + POLICY: '11111111-1111-4111-8111-111111111111', + REPORT: '44444444-4444-4444-8444-444444444444', + REFORM: '55555555-5555-4555-8555-555555555555', +} as const; + +export const LEGACY_IDS = { + POLICY: 95762, + REPORT: 615, +} as const; + +export const TIMESTAMPS = { + CREATED: '2026-10-12T10:00:00+00:00', + UPDATED: '2026-10-12T11:00:00+00:00', +} as const; + +export const mockMeItem: MeItem = { + id: PE_USER_ID, + user_type: 'standard', + primary_country: 'us', + first_name: 'Ada', + last_name: 'Lovelace', + email: 'ada@example.org', + created_at: TIMESTAMPS.CREATED, +}; + +const base = { + country_id: 'us' as const, + user_id: PE_USER_ID, + name: null, + description: null, + created_at: TIMESTAMPS.CREATED, + updated_at: TIMESTAMPS.UPDATED, +}; + +export function policyItem(overrides: Partial<UserPolicyItem> = {}): UserPolicyItem { + return { + ...base, + id: ASSOCIATION_IDS.POLICY, + policy_id: null, + legacy_policy_id: LEGACY_IDS.POLICY, + baseline: null, + provenance: null, + ...overrides, + }; +} + +export function reportItem(overrides: Partial<UserReportItem> = {}): UserReportItem { + return { + ...base, + id: ASSOCIATION_IDS.REPORT, + report_id: null, + legacy_report_id: LEGACY_IDS.REPORT, + user_policy_id: null, + baseline_user_policy_id: null, + source_note: null, + provisions: null, + validation: null, + budget_window_years: null, + last_run_at: null, + ...overrides, + }; +} + +/** A saved report as anyone holding its link reads it. */ +export function sharedReportItem(overrides: Partial<SharedReportItem> = {}): SharedReportItem { + return { + id: ASSOCIATION_IDS.REPORT, + country_id: 'us', + legacy_report_id: LEGACY_IDS.REPORT, + name: 'CTC to $3,600', + source_note: 'From chat', + provisions: [ + { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'Child Tax Credit → Base amount', + unit: 'currency-USD', + baseline_value: 2000, + value: 3600, + intervals: null, + }, + ], + validation: null, + budget_window_years: null, + baseline_reform: null, + last_run_at: null, + created_at: TIMESTAMPS.CREATED, + updated_at: TIMESTAMPS.UPDATED, + ...overrides, + }; +} + +/** A client that calls the credential-free routes, and its fetch mock. */ +export function createTestPublicClient() { + const fetchMock = vi.fn(); + const client = createV2UserClient({ + credentials: { kind: 'none' }, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { fetchMock, client }; +} + +/** `{"status": "ok", "message": null, "result": …}` as a fetch Response. */ +export function okResponse(result: unknown, status = 200): Response { + return new Response(JSON.stringify({ status: 'ok', message: null, result }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export function itemResponse(item: unknown, status = 200): Response { + return okResponse({ item }, status); +} + +export function pageResponse(items: unknown[], { offset = 0, hasMore = false } = {}): Response { + return okResponse({ items, offset, limit: 500, has_more: hasMore }); +} + +export function errorResponse(status: number, message: string): Response { + return new Response(JSON.stringify({ status: 'error', message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export function noContentResponse(): Response { + return new Response(null, { status: 204 }); +} + +/** A fetch mock plus an API store context that uses it. */ +export function createTestApiContext(countryId: 'us' | 'uk' = 'us'): { + fetchMock: ReturnType<typeof vi.fn>; + context: ApiStoreContext; +} { + const fetchMock = vi.fn(); + const client = createV2UserClient({ + credentials: { kind: 'session', getAccessToken: async () => TEST_ACCESS_TOKEN }, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { fetchMock, context: { client, countryId } }; +} + +/** The (url, init) of the nth fetch call, with the parsed URL and JSON body. */ +export function fetchCall(fetchMock: ReturnType<typeof vi.fn>, index = 0) { + const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit]; + const parsed = new URL(url); + return { + url: parsed, + path: parsed.pathname, + query: Object.fromEntries(parsed.searchParams.entries()), + method: init.method, + headers: init.headers as Record<string, string>, + body: init.body ? JSON.parse(String(init.body)) : undefined, + }; +} diff --git a/app/src/tests/fixtures/integration/flagshipDbFixtures.ts b/app/src/tests/fixtures/integration/flagshipDbFixtures.ts deleted file mode 100644 index f4a93767c..000000000 --- a/app/src/tests/fixtures/integration/flagshipDbFixtures.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import type { PGlite } from '@electric-sql/pglite'; -import { EDIT_TOKEN_HEADER } from '@/libs/flagship/ownedItems'; - -export const MIGRATIONS_DIR = path.resolve( - __dirname, - '../../../../../calculator-app/src/db/migrations' -); - -/** The migration that dropped user ids for edit tokens; rows before it are legacy. */ -export const OWNERSHIP_MIGRATION_TAG = '0003_anonymous-ownership'; - -/** The checked-in migrations' tags, in journal order. */ -export function migrationTags(): string[] { - const journal = JSON.parse(readFileSync(path.join(MIGRATIONS_DIR, 'meta/_journal.json'), 'utf8')); - return journal.entries.map((entry: { tag: string }) => entry.tag); -} - -/** The tags before the ownership migration: the schema legacy rows were written under. */ -export function legacyMigrationTags(): string[] { - const tags = migrationTags(); - return tags.slice(0, tags.indexOf(OWNERSHIP_MIGRATION_TAG)); -} - -/** Applies migrations exactly as deployed (all of them by default). */ -export async function applyMigrations(client: PGlite, tags = migrationTags()): Promise<void> { - for (const tag of tags) { - await client.exec(readFileSync(path.join(MIGRATIONS_DIR, `${tag}.sql`), 'utf8')); - } -} - -export function sha256Hex(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} - -export function editTokenHeader(token: string): Record<string, string> { - return { [EDIT_TOKEN_HEADER]: token }; -} - -/** Request metadata the routes must never store. */ -export const CLIENT_IP = '203.0.113.7'; -export const CLIENT_USER_AGENT = 'Mozilla/5.0 (PrivacyProbe)'; - -export const IDENTIFYING_HEADERS = { - 'x-forwarded-for': CLIENT_IP, - 'x-real-ip': CLIENT_IP, - 'user-agent': CLIENT_USER_AGENT, -}; - -export const UNKNOWN_UUID = '00000000-0000-4000-8000-000000000000'; - -const MINIMAL_ROWS = { - reforms: { - columns: 'country_id, parameters, provenance', - values: `'us', '[]', '{"source":"manual"}'`, - }, - reports: { - columns: 'country_id, api_report_id, provisions, year', - values: `'us', '1', '[]', '2026'`, - }, -} as const; - -/** Inserts `count` minimal rows created `minutesAgo` minutes ago, as other visitors would. */ -export async function insertRowsCreatedAgo( - client: PGlite, - table: keyof typeof MINIMAL_ROWS, - count: number, - minutesAgo: number -): Promise<void> { - const { columns, values } = MINIMAL_ROWS[table]; - await client.exec( - `INSERT INTO ${table} (${columns}, created_at) - SELECT ${values}, now() - interval '${minutesAgo} minutes' FROM generate_series(1, ${count})` - ); -} - -/** A string of `length` characters, for free-text limit checks. */ -export function textOfLength(length: number): string { - return 'x'.repeat(length); -} diff --git a/app/src/tests/fixtures/libs/auth/authMocks.tsx b/app/src/tests/fixtures/libs/auth/authMocks.tsx new file mode 100644 index 000000000..6f4130546 --- /dev/null +++ b/app/src/tests/fixtures/libs/auth/authMocks.tsx @@ -0,0 +1,64 @@ +import { vi } from 'vitest'; +import { AuthContext, type AuthContextValue } from '@/libs/auth/AuthContext'; +import { TEST_ACCESS_TOKEN } from '@/tests/fixtures/api/v2UserMocks'; + +export const TEST_AUTH0_CONFIG = { + domain: 'auth.test.policyengine.org', + clientId: 'test-client-id', + audience: 'https://api.test.policyengine.org/', +} as const; + +export const TEST_AUTH0_SUBJECT = 'google-oauth2|1234'; + +export const TEST_PROFILE = { + name: 'Ada Lovelace', + email: 'ada@example.org', + picture: null, +} as const; + +/** Stubs the build-time sign-in environment (undo with vi.unstubAllEnvs()). */ +export function stubAuthEnv( + enabled: boolean, + config: Partial<typeof TEST_AUTH0_CONFIG> = TEST_AUTH0_CONFIG +): void { + vi.stubEnv('VITE_AUTH_ENABLED', enabled ? 'true' : ''); + vi.stubEnv('VITE_AUTH0_DOMAIN', config.domain ?? ''); + vi.stubEnv('VITE_AUTH0_CLIENT_ID', config.clientId ?? ''); + vi.stubEnv('VITE_AUTH0_AUDIENCE', config.audience ?? ''); +} + +/** A signed-out session. */ +export function makeAuthValue(overrides: Partial<AuthContextValue> = {}): AuthContextValue { + return { + status: 'signed_out', + profile: null, + subject: null, + sessionEnded: false, + error: null, + signIn: vi.fn().mockResolvedValue(undefined), + signOut: vi.fn().mockResolvedValue(undefined), + getAccessToken: vi.fn().mockResolvedValue(null), + ...overrides, + }; +} + +/** A signed-in session with a working access token. */ +export function signedInAuthValue(overrides: Partial<AuthContextValue> = {}): AuthContextValue { + return makeAuthValue({ + status: 'signed_in', + profile: { ...TEST_PROFILE }, + subject: TEST_AUTH0_SUBJECT, + getAccessToken: vi.fn().mockResolvedValue(TEST_ACCESS_TOKEN), + ...overrides, + }); +} + +export function AuthTestProvider({ + value, + children, +}: { + value: AuthContextValue; + children: React.ReactNode; +}) { + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; +} diff --git a/app/src/tests/fixtures/libs/flagship/ownedItemsMocks.ts b/app/src/tests/fixtures/libs/flagship/ownedItemsMocks.ts deleted file mode 100644 index 47892db58..000000000 --- a/app/src/tests/fixtures/libs/flagship/ownedItemsMocks.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { OwnedItem } from '@/libs/flagship/ownedItems'; - -export const OWNED_REFORMS_KEY = 'pe-flagship-owned-reforms'; -export const OWNED_REPORTS_KEY = 'pe-flagship-owned-reports'; - -/** Central ids are UUIDs the server picks. */ -export const CENTRAL_REFORM_ID = '0b6f0c1e-5d0a-4c38-9a51-6c7f2f3e8a01'; -export const OTHER_CENTRAL_REFORM_ID = '7d2c9e4a-1b3f-4e6d-8a2b-9c0d1e2f3a4b'; -export const UK_CENTRAL_REFORM_ID = 'c3a1b2d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'; -export const CENTRAL_REPORT_ID = '5e8f7a6b-4c3d-4e2f-9a1b-0c9d8e7f6a5b'; - -/** 64 hex characters, as the server issues them. */ -export const EDIT_TOKEN = 'a'.repeat(64); -export const OTHER_EDIT_TOKEN = 'b'.repeat(64); - -export const API_REPORT_ID = '4512'; -export const OTHER_API_REPORT_ID = '9999'; - -export const ADDED_AT = '2026-10-01T00:00:00.000Z'; - -export const ownedUsReform: OwnedItem = { - id: CENTRAL_REFORM_ID, - countryId: 'us', - editToken: EDIT_TOKEN, - addedAt: ADDED_AT, -}; - -export const ownedUkReform: OwnedItem = { - id: UK_CENTRAL_REFORM_ID, - countryId: 'uk', - editToken: OTHER_EDIT_TOKEN, - addedAt: ADDED_AT, -}; - -export const ownedReport: OwnedItem = { - id: CENTRAL_REPORT_ID, - countryId: 'us', - editToken: EDIT_TOKEN, - apiReportId: API_REPORT_ID, - addedAt: ADDED_AT, -}; - -/** Seeds this browser's index as an earlier session would have left it. */ -export function seedOwnedItems(key: string, items: OwnedItem[]): void { - localStorage.setItem(key, JSON.stringify(items)); -} diff --git a/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts b/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts index 46d7012e8..80314f02d 100644 --- a/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts +++ b/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts @@ -1,19 +1,18 @@ /** - * Report records and provenance in every shape the app has stored, from - * git history (`git log -p` on runReport.ts, flagshipReportStore.ts and - * calculator-app/src/db/serialize.ts), with what each reads as today. + * Report records and provenance in every shape the app has stored in the + * browser, from git history (`git log -p` on runReport.ts and + * flagshipReportStore.ts), with what each reads as today. */ import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; import type { ReportMetaFields } from '@/libs/flagship/reportSchema'; -import { validationToWire } from '@/libs/flagship/reportValidation'; import type { FlagshipReportMeta, RunReportProvision } from '@/libs/flagship/runReport'; -import { - API_REPORT_ID, - CENTRAL_REFORM_ID, - CENTRAL_REPORT_ID, -} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; +/** The report on the PolicyEngine API that the records point at. */ +export const API_REPORT_ID = '4512'; +/** A saved reform a record links to. */ +export const SAVED_REFORM_ID = '0b6f0c1e-5d0a-4c38-9a51-6c7f2f3e8a01'; + export const REPORT_META_KEY = 'pe-flagship-report-meta'; export const REPORTS_KEY = 'pe-flagship-reports'; @@ -163,7 +162,7 @@ export const recordFromStorageBeat = { }; /** b2a09635 "Adjust panel: … reform reconciliation": the saved reform it ran from. */ -export const recordWithReformLink = { ...recordFromStorageBeat, reformId: CENTRAL_REFORM_ID }; +export const recordWithReformLink = { ...recordFromStorageBeat, reformId: SAVED_REFORM_ID }; /** f8294f90 "Pin what each report's validation was matched against". */ export const recordWithValidation = { @@ -186,72 +185,23 @@ const recordBase: FlagshipReportRecord = { createdAt: CREATED_AT, reformId: null, validation: null, + // Records saved before comparisons could be chosen read as current law. + baselineReformId: null, }; export const currentRecord: FlagshipReportRecord = { ...recordBase, - reformId: CENTRAL_REFORM_ID, + reformId: SAVED_REFORM_ID, validation: mockReportValidationSnapshot, }; export const historicRecords: Array<[string, unknown, FlagshipReportRecord]> = [ ['the first record, with the shared user id', recordFromStorageBeat, recordBase], - ['a reform link', recordWithReformLink, { ...recordBase, reformId: CENTRAL_REFORM_ID }], + ['a reform link', recordWithReformLink, { ...recordBase, reformId: SAVED_REFORM_ID }], ['a validation pin', recordWithValidation, currentRecord], ['no user id', recordWithoutUserId, currentRecord], ]; -/* Central rows, as the calculator-app routes serve them (snake_case). */ - -const CTC_PROVISION_WIRE = { - path: CTC_PROVISION.path, - breadcrumb: CTC_PROVISION.breadcrumb, - unit: CTC_PROVISION.unit, - baseline_value: CTC_PROVISION.baselineValue, - value: CTC_PROVISION.value, -}; - -/** 6c23805e: user_id, and no validation column yet. */ -export const rowFromStorageBeat = { - id: CENTRAL_REPORT_ID, - user_id: 'anonymous', - country_id: 'us', - api_report_id: API_REPORT_ID, - title: 'CTC to $2,500', - source_note: 'Hand-built draft', - provisions: [CTC_PROVISION_WIRE], - reform_id: null, - year: '2026', - created_at: CREATED_AT, -}; - -/** f8294f90: the validation column. */ -export const rowWithValidation = { - ...rowFromStorageBeat, - reform_id: CENTRAL_REFORM_ID, - validation: validationToWire(mockReportValidationSnapshot), -}; - -/** ec399555: no user_id; today's row. */ -const { user_id: _userIdColumn, ...rowWithoutUserIdFields } = rowWithValidation; -export const rowWithoutUserId = rowWithoutUserIdFields; - -const rowRecordBase: FlagshipReportRecord = { ...recordBase, id: CENTRAL_REPORT_ID }; - -export const historicRows: Array<[string, unknown, FlagshipReportRecord]> = [ - ['the first row, without a validation column', rowFromStorageBeat, rowRecordBase], - [ - 'a validation column', - rowWithValidation, - { ...rowRecordBase, reformId: CENTRAL_REFORM_ID, validation: mockReportValidationSnapshot }, - ], - [ - 'no user_id', - rowWithoutUserId, - { ...rowRecordBase, reformId: CENTRAL_REFORM_ID, validation: mockReportValidationSnapshot }, - ], -]; - /* Unreadable: [name, stored, error code, offending field]. */ export const malformedMeta: Array<[string, unknown, string, string | undefined]> = [ diff --git a/app/src/tests/integration/api/reformRoutes.test.ts b/app/src/tests/integration/api/reformRoutes.test.ts deleted file mode 100644 index 9a91bc738..000000000 --- a/app/src/tests/integration/api/reformRoutes.test.ts +++ /dev/null @@ -1,685 +0,0 @@ -// @vitest-environment node -// -// Integration tests for the /api/reforms route handlers, executed against a -// real in-memory Postgres (PGlite) with the checked-in Drizzle migrations -// applied — so schema, SQL, wire format, and handler logic are all exercised -// exactly as deployed. -import { PGlite } from '@electric-sql/pglite'; -import { drizzle } from 'drizzle-orm/pglite'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { ReformAdapter } from '@/adapters/ReformAdapter'; -import { - applyMigrations, - CLIENT_IP, - CLIENT_USER_AGENT, - editTokenHeader, - IDENTIFYING_HEADERS, - insertRowsCreatedAgo, - legacyMigrationTags, - OWNERSHIP_MIGRATION_TAG, - sha256Hex, - textOfLength, - UNKNOWN_UUID, -} from '@/tests/fixtures/integration/flagshipDbFixtures'; -import type { Reform } from '@/types/ingredients/Reform'; -import type { ReformCreatedMetadata, ReformMetadata } from '@/types/metadata/reformMetadata'; -// Route handlers + db module live in calculator-app; imported relatively -// since they are outside the @/ alias root. -import { - DELETE as deleteReform, - GET as getReform, - PATCH as patchReform, -} from '../../../../../calculator-app/src/app/api/reforms/[reformId]/route'; -import { - POST as createReform, - GET as listReforms, -} from '../../../../../calculator-app/src/app/api/reforms/route'; -import { setDbForTesting } from '../../../../../calculator-app/src/db'; -import { - MAX_BODY_BYTES, - MAX_TEXT_LENGTH, - NEW_ROWS_PER_MINUTE, -} from '../../../../../calculator-app/src/db/limits'; -import * as schema from '../../../../../calculator-app/src/db/schema'; - -let client: PGlite; - -beforeAll(async () => { - // The routes 404 on flag-off deployments; these tests exercise the - // flag-on behavior. - process.env.NEXT_PUBLIC_FLAGSHIP_SHELL = 'true'; - client = new PGlite(); - await applyMigrations(client); - setDbForTesting(drizzle(client, { schema }) as any); -}); - -afterAll(async () => { - setDbForTesting(null); - await client.close(); -}); - -beforeEach(async () => { - await client.exec('DELETE FROM reforms;'); -}); - -const VALID_PAYLOAD = { - country_id: 'us', - label: 'CTC to $3,600 for children under 6', - policy_id: null, - parameters: [ - { - name: 'gov.irs.credits.ctc.amount.base[0]', - values: [{ start_date: '2026-01-01', end_date: '2026-12-31', value: 3600 }], - }, - ], - baseline: 'current-law', - provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, -}; - -function postRequest(body: unknown, headers: Record<string, string> = {}): Request { - return new Request('http://test/api/reforms', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: typeof body === 'string' ? body : JSON.stringify(body), - }); -} - -function listRequest(ids: string[]): Request { - return new Request(`http://test/api/reforms?ids=${ids.join(',')}`); -} - -function itemContext(reformId: string) { - return { params: Promise.resolve({ reformId }) }; -} - -function patchRequest(body: unknown, token?: string): Request { - return new Request('http://test/api/reforms/x', { - method: 'PATCH', - headers: token ? editTokenHeader(token) : {}, - body: JSON.stringify(body), - }); -} - -function deleteRequest(token?: string): Request { - return new Request('http://test/api/reforms/x', { - method: 'DELETE', - headers: token ? editTokenHeader(token) : {}, - }); -} - -async function createAndParse(payload: unknown = VALID_PAYLOAD): Promise<ReformCreatedMetadata> { - const response = await createReform(postRequest(payload)); - expect(response.status).toBe(201); - return response.json(); -} - -async function list(ids: string[]): Promise<ReformMetadata[]> { - const response = await listReforms(listRequest(ids)); - expect(response.status).toBe(200); - return response.json(); -} - -/** A row as the pre-ownership schema wrote it: a client user id, no edit token. */ -async function insertLegacyRow(db: PGlite = client): Promise<string> { - const result = await db.query<{ id: string }>( - `INSERT INTO reforms (user_id, country_id, label, parameters, provenance) - VALUES ('anonymous', 'us', 'Saved before edit tokens', '[]', '{"source":"manual"}') - RETURNING id` - ); - return result.rows[0].id; -} - -describe('POST /api/reforms', () => { - it('given a valid payload then creates and returns the reform in wire format with an edit token', async () => { - const created = await createAndParse(); - - expect(created.id).toMatch(/^[0-9a-f-]{36}$/); - expect(created.edit_token).toMatch(/^[0-9a-f]{64}$/); - expect(created).not.toHaveProperty('user_id'); - expect(created).not.toHaveProperty('edit_token_hash'); - expect(created.country_id).toBe('us'); - expect(created.parameters).toEqual(VALID_PAYLOAD.parameters); - expect(created.provenance).toEqual(VALID_PAYLOAD.provenance); - expect(new Date(created.created_at!).getTime()).not.toBeNaN(); - }); - - it('given a created reform then the row keeps only the token hash and no user id', async () => { - const created = await createAndParse(); - - const { rows } = await client.query<{ user_id: string | null; edit_token_hash: string }>( - 'SELECT user_id, edit_token_hash FROM reforms WHERE id = $1', - [created.id] - ); - - expect(rows[0].user_id).toBeNull(); - expect(rows[0].edit_token_hash).toBe(sha256Hex(created.edit_token)); - }); - - it('given two reforms then each gets its own edit token', async () => { - const first = await createAndParse(); - const second = await createAndParse(); - - expect(first.edit_token).not.toBe(second.edit_token); - }); - - it('given an older client that still sends user_id then it is not stored', async () => { - const created = await createAndParse({ ...VALID_PAYLOAD, user_id: 'anonymous' }); - - const { rows } = await client.query<{ user_id: string | null }>( - 'SELECT user_id FROM reforms WHERE id = $1', - [created.id] - ); - expect(rows[0].user_id).toBeNull(); - }); - - it('given a request with an IP address and user agent then the row stores neither', async () => { - const response = await createReform(postRequest(VALID_PAYLOAD, IDENTIFYING_HEADERS)); - expect(response.status).toBe(201); - - const { rows } = await client.query<{ row: string }>( - 'SELECT row_to_json(reforms)::text AS row FROM reforms' - ); - expect(rows[0].row).not.toContain(CLIENT_IP); - expect(rows[0].row).not.toContain(CLIENT_USER_AGENT); - }); - - it('given the created response then it round-trips through ReformAdapter unchanged', async () => { - const created = await createAndParse(); - - const reform: Reform = ReformAdapter.fromApiResponse(created); - const backToWire = ReformAdapter.toCreationPayload(reform); - - expect(backToWire.parameters).toEqual(created.parameters); - expect(backToWire.provenance).toEqual(created.provenance); - expect(backToWire.baseline).toBe(created.baseline); - }); - - it.each([ - ['invalid country', { ...VALID_PAYLOAD, country_id: 'fr' }], - ['parameters not an array', { ...VALID_PAYLOAD, parameters: 'oops' }], - ['parameter without name', { ...VALID_PAYLOAD, parameters: [{ values: [] }] }], - ['parameter without values', { ...VALID_PAYLOAD, parameters: [{ name: 'x' }] }], - ['invalid baseline', { ...VALID_PAYLOAD, baseline: 'utopia' }], - ['missing provenance', { ...VALID_PAYLOAD, provenance: undefined }], - ['invalid provenance source', { ...VALID_PAYLOAD, provenance: { source: 'dream' } }], - ])('given %s then returns 400', async (_name, payload) => { - const response = await createReform(postRequest(payload)); - expect(response.status).toBe(400); - }); - - it('given malformed JSON then returns 400', async () => { - const response = await createReform(postRequest('{not json')); - expect(response.status).toBe(400); - }); - - it('given a SQL-injection-shaped label then it is stored literally', async () => { - const label = `'; DROP TABLE reforms; --`; - const created = await createAndParse({ ...VALID_PAYLOAD, label }); - - expect(created.label).toBe(label); - - // Table still exists and holds the row - expect(await list([created.id])).toHaveLength(1); - }); - - it('given a unicode label with emoji then it round-trips intact', async () => { - const label = 'Crédit d’impôt 子育て支援 🎯 £/€'; - const created = await createAndParse({ ...VALID_PAYLOAD, label }); - expect(created.label).toBe(label); - }); - - it('given an empty parameters array (a "current law" reform) then it is accepted', async () => { - const created = await createAndParse({ ...VALID_PAYLOAD, parameters: [] }); - expect(created.parameters).toEqual([]); - }); - - it('given 500 parameters with mixed value types then all round-trip', async () => { - const parameters = Array.from({ length: 500 }, (_, i) => ({ - name: `gov.test.parameter_${i}`, - values: [ - { - start_date: '2026-01-01', - end_date: '2100-12-31', - value: i % 3 === 0 ? i * 1.5 : i % 3 === 1 ? String(i) : i % 2 === 0, - }, - ], - })); - - const created = await createAndParse({ ...VALID_PAYLOAD, parameters }); - expect(created.parameters).toHaveLength(500); - expect(created.parameters[0].values[0].value).toBe(0); - expect(created.parameters[1].values[0].value).toBe('1'); - }); -}); - -describe('GET /api/reforms', () => { - it('given no ids then returns 400 rather than listing every reform', async () => { - await createAndParse(); - - const response = await listReforms(new Request('http://test/api/reforms')); - - expect(response.status).toBe(400); - }); - - it('given a user_id query from an older client then returns 400', async () => { - await createAndParse(); - - const response = await listReforms(new Request('http://test/api/reforms?user_id=anonymous')); - - expect(response.status).toBe(400); - }); - - it('given ids then only those reforms are returned, without tokens or user ids', async () => { - const mine = await createAndParse({ ...VALID_PAYLOAD, label: 'mine' }); - await createAndParse({ ...VALID_PAYLOAD, label: 'someone else’s' }); - - const reforms = await list([mine.id]); - - expect(reforms.map((reform) => reform.label)).toEqual(['mine']); - expect(reforms[0]).not.toHaveProperty('edit_token'); - expect(reforms[0]).not.toHaveProperty('user_id'); - }); - - it('given unknown and malformed ids then they are skipped', async () => { - const mine = await createAndParse(); - - const reforms = await list([mine.id, UNKNOWN_UUID, 'not-a-uuid', "'; DROP TABLE reforms; --"]); - - expect(reforms.map((reform) => reform.id)).toEqual([mine.id]); - }); - - it('given more than 100 ids then returns 400', async () => { - const ids = Array.from( - { length: 101 }, - (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` - ); - - const response = await listReforms(listRequest(ids)); - - expect(response.status).toBe(400); - }); - - it('given several reforms then they are ordered most recently updated first', async () => { - const first = await createAndParse({ ...VALID_PAYLOAD, label: 'first' }); - const second = await createAndParse({ ...VALID_PAYLOAD, label: 'second' }); - await patchReform( - patchRequest({ label: 'first-updated' }, first.edit_token), - itemContext(first.id) - ); - - const reforms = await list([first.id, second.id]); - - expect(reforms.map((r) => r.label)).toEqual(['first-updated', 'second']); - }); -}); - -describe('GET/PATCH/DELETE /api/reforms/:id', () => { - it('given a created reform then GET returns it by id with no token', async () => { - const created = await createAndParse(); - - const response = await getReform(new Request('http://test'), itemContext(created.id)); - - expect(response.status).toBe(200); - const body = (await response.json()) as ReformMetadata; - expect(body.id).toBe(created.id); - expect(body).not.toHaveProperty('edit_token'); - }); - - it.each([ - ['a random UUID', UNKNOWN_UUID], - ['a non-UUID id', 'not-a-uuid'], - ['a path-traversal id', '../reforms'], - ])('given %s then GET returns 404', async (_name, reformId) => { - const response = await getReform(new Request('http://test'), itemContext(reformId)); - expect(response.status).toBe(404); - }); - - it('given label, parameters, baseline, and provenance updates then PATCH applies them all', async () => { - const created = await createAndParse(); - - const response = await patchReform( - patchRequest( - { - label: 'Bill-derived version', - parameters: [], - baseline: 'current-policy', - provenance: { source: 'bill', ref: 'ut-hb-106', confidence: 'approximated' }, - }, - created.edit_token - ), - itemContext(created.id) - ); - - expect(response.status).toBe(200); - const updated = (await response.json()) as ReformMetadata; - expect(updated.label).toBe('Bill-derived version'); - expect(updated.parameters).toEqual([]); - expect(updated.baseline).toBe('current-policy'); - expect(updated.provenance.source).toBe('bill'); - expect(new Date(updated.updated_at!).getTime()).toBeGreaterThanOrEqual( - new Date(created.updated_at!).getTime() - ); - }); - - it('given a policy_id update then PATCH links the materialized policy', async () => { - const created = await createAndParse(); - - const response = await patchReform( - patchRequest({ policy_id: 'pol-999' }, created.edit_token), - itemContext(created.id) - ); - - expect(((await response.json()) as ReformMetadata).policy_id).toBe('pol-999'); - }); - - it.each([ - ['an empty body', {}], - ['an invalid baseline', { baseline: 'utopia' }], - ['an invalid provenance source', { provenance: { source: 'dream' } }], - ['non-array parameters', { parameters: 'oops' }], - ])('given %s then PATCH returns 400', async (_name, body) => { - const created = await createAndParse(); - const response = await patchReform( - patchRequest(body, created.edit_token), - itemContext(created.id) - ); - expect(response.status).toBe(400); - }); - - it('given unknown fields only then PATCH returns 400 rather than silently succeeding', async () => { - const created = await createAndParse(); - const response = await patchReform( - patchRequest({ user_id: 'mallory', country_id: 'uk' }, created.edit_token), - itemContext(created.id) - ); - expect(response.status).toBe(400); - }); - - it('given no edit token then PATCH returns 403 and the reform is unchanged', async () => { - const created = await createAndParse(); - - const response = await patchReform( - patchRequest({ label: 'Hijacked' }), - itemContext(created.id) - ); - - expect(response.status).toBe(403); - expect((await list([created.id]))[0].label).toBe(VALID_PAYLOAD.label); - }); - - it('given another reform’s edit token then PATCH returns 403', async () => { - const target = await createAndParse(); - const other = await createAndParse(); - - const response = await patchReform( - patchRequest({ label: 'Hijacked' }, other.edit_token), - itemContext(target.id) - ); - - expect(response.status).toBe(403); - }); - - it('given the stored hash sent as the token then PATCH returns 403', async () => { - const created = await createAndParse(); - - const response = await patchReform( - patchRequest({ label: 'Hijacked' }, sha256Hex(created.edit_token)), - itemContext(created.id) - ); - - expect(response.status).toBe(403); - }); - - it('given a deleted reform then it 404s afterwards', async () => { - const created = await createAndParse(); - - const deleteResponse = await deleteReform( - deleteRequest(created.edit_token), - itemContext(created.id) - ); - expect(deleteResponse.status).toBe(200); - - const getResponse = await getReform(new Request('http://test'), itemContext(created.id)); - expect(getResponse.status).toBe(404); - }); - - it('given no edit token then DELETE returns 403 and the reform stays', async () => { - const created = await createAndParse(); - - const response = await deleteReform(deleteRequest(), itemContext(created.id)); - - expect(response.status).toBe(403); - expect(await list([created.id])).toHaveLength(1); - }); - - it('given a nonexistent reform then DELETE returns 404', async () => { - const response = await deleteReform(deleteRequest('a'.repeat(64)), itemContext(UNKNOWN_UUID)); - expect(response.status).toBe(404); - }); -}); - -describe('abuse limits', () => { - const oversizedParameters = [ - { - name: 'gov.test.oversized', - values: [ - { start_date: '2026-01-01', end_date: '2100-12-31', value: textOfLength(MAX_BODY_BYTES) }, - ], - }, - ]; - - it('given a body over the size limit then POST returns 413 and stores nothing', async () => { - const response = await createReform( - postRequest({ ...VALID_PAYLOAD, parameters: oversizedParameters }) - ); - - expect(response.status).toBe(413); - const { rows } = await client.query<{ n: number }>('SELECT count(*)::int AS n FROM reforms'); - expect(rows[0].n).toBe(0); - }); - - it('given a declared length over the limit then POST returns 413 before reading the body', async () => { - const response = await createReform( - postRequest(VALID_PAYLOAD, { 'content-length': String(MAX_BODY_BYTES + 1) }) - ); - - expect(response.status).toBe(413); - }); - - it('given a label at the length limit then POST accepts it', async () => { - const created = await createAndParse({ - ...VALID_PAYLOAD, - label: textOfLength(MAX_TEXT_LENGTH), - }); - - expect(created.label).toHaveLength(MAX_TEXT_LENGTH); - }); - - it.each([ - ['a label over the length limit', textOfLength(MAX_TEXT_LENGTH + 1)], - ['a non-string label', 42], - ])('given %s then POST returns 400', async (_name, label) => { - const response = await createReform(postRequest({ ...VALID_PAYLOAD, label })); - - expect(response.status).toBe(400); - }); - - it('given a body over the size limit then PATCH returns 413 and the reform is unchanged', async () => { - const created = await createAndParse(); - - const response = await patchReform( - patchRequest({ parameters: oversizedParameters }, created.edit_token), - itemContext(created.id) - ); - - expect(response.status).toBe(413); - expect((await list([created.id]))[0].parameters).toEqual(VALID_PAYLOAD.parameters); - }); - - it('given a label over the length limit then PATCH returns 400', async () => { - const created = await createAndParse(); - - const response = await patchReform( - patchRequest({ label: textOfLength(MAX_TEXT_LENGTH + 1) }, created.edit_token), - itemContext(created.id) - ); - - expect(response.status).toBe(400); - }); - - it('given the new-row budget is used up this minute then POST returns 429 with Retry-After', async () => { - await insertRowsCreatedAgo(client, 'reforms', NEW_ROWS_PER_MINUTE, 0); - - const response = await createReform(postRequest(VALID_PAYLOAD)); - - expect(response.status).toBe(429); - expect(response.headers.get('Retry-After')).toBe('60'); - const { rows } = await client.query<{ n: number }>('SELECT count(*)::int AS n FROM reforms'); - expect(rows[0].n).toBe(NEW_ROWS_PER_MINUTE); - }); - - it('given one row short of the budget then POST still creates the reform', async () => { - await insertRowsCreatedAgo(client, 'reforms', NEW_ROWS_PER_MINUTE - 1, 0); - - const response = await createReform(postRequest(VALID_PAYLOAD)); - - expect(response.status).toBe(201); - }); - - it('given rows older than a minute then they do not count against the budget', async () => { - await insertRowsCreatedAgo(client, 'reforms', NEW_ROWS_PER_MINUTE, 2); - - const response = await createReform(postRequest(VALID_PAYLOAD)); - - expect(response.status).toBe(201); - }); - - it('given creates at the same time then the budget still holds: the count and insert are locked together', async () => { - await insertRowsCreatedAgo(client, 'reforms', NEW_ROWS_PER_MINUTE - 5, 0); - - // Counted apart from the insert, all twenty would read a count under the limit. - const responses = await Promise.all( - Array.from({ length: 20 }, () => createReform(postRequest(VALID_PAYLOAD))) - ); - - const statuses = responses.map((response) => response.status); - expect(statuses.filter((status) => status === 201)).toHaveLength(5); - expect(statuses.filter((status) => status === 429)).toHaveLength(15); - const { rows } = await client.query<{ n: number }>('SELECT count(*)::int AS n FROM reforms'); - expect(rows[0].n).toBe(NEW_ROWS_PER_MINUTE); - }); - - it('given the budget is used up then edits to existing reforms still work', async () => { - const created = await createAndParse(); - await insertRowsCreatedAgo(client, 'reforms', NEW_ROWS_PER_MINUTE, 0); - - const response = await patchReform( - patchRequest({ label: 'Still editable' }, created.edit_token), - itemContext(created.id) - ); - - expect(response.status).toBe(200); - }); -}); - -describe('a database without the latest migration', () => { - it('then the store answers 503, so the client falls back to localStorage, not 500', async () => { - // A database migrated only up to before the ownership migration. - const staleDb = new PGlite(); - try { - await applyMigrations(staleDb, legacyMigrationTags()); - setDbForTesting(drizzle(staleDb, { schema }) as any); - - const created = await createReform(postRequest(VALID_PAYLOAD)); - expect(created.status).toBe(503); - const listed = await listReforms(listRequest([UNKNOWN_UUID])); - expect(listed.status).toBe(503); - } finally { - setDbForTesting(drizzle(client, { schema }) as any); - await staleDb.close(); - } - }); -}); - -describe('legacy rows (written before edit tokens)', () => { - it('given a legacy row then GET reads it without exposing its user id', async () => { - const legacyId = await insertLegacyRow(); - - const response = await getReform(new Request('http://test'), itemContext(legacyId)); - - expect(response.status).toBe(200); - const body = (await response.json()) as ReformMetadata; - expect(body.label).toBe('Saved before edit tokens'); - expect(body).not.toHaveProperty('user_id'); - }); - - it('given a legacy row then PATCH and DELETE return 403 whatever token is sent', async () => { - const legacyId = await insertLegacyRow(); - - const patch = await patchReform( - patchRequest({ label: 'Hijacked' }, 'a'.repeat(64)), - itemContext(legacyId) - ); - const remove = await deleteReform(deleteRequest(), itemContext(legacyId)); - - expect(patch.status).toBe(403); - expect(remove.status).toBe(403); - expect(await list([legacyId])).toHaveLength(1); - }); - - it('given a row written under the old schema then it stays readable after the ownership migration', async () => { - const legacyDb = new PGlite(); - try { - await applyMigrations(legacyDb, legacyMigrationTags()); - const legacyId = await insertLegacyRow(legacyDb); - await applyMigrations(legacyDb, [OWNERSHIP_MIGRATION_TAG]); - setDbForTesting(drizzle(legacyDb, { schema }) as any); - - const response = await getReform(new Request('http://test'), itemContext(legacyId)); - - expect(response.status).toBe(200); - expect(((await response.json()) as ReformMetadata).label).toBe('Saved before edit tokens'); - } finally { - setDbForTesting(drizzle(client, { schema }) as any); - await legacyDb.close(); - } - }); -}); - -describe('degraded mode (no database configured)', () => { - it('given no injected db and no DATABASE_URL then all handlers return 503', async () => { - setDbForTesting(null); - const previousUrl = process.env.DATABASE_URL; - delete process.env.DATABASE_URL; - - try { - expect((await createReform(postRequest(VALID_PAYLOAD))).status).toBe(503); - expect((await listReforms(listRequest([UNKNOWN_UUID]))).status).toBe(503); - expect((await getReform(new Request('http://test'), itemContext('x'))).status).toBe(503); - expect((await patchReform(patchRequest({ label: 'x' }), itemContext('x'))).status).toBe(503); - expect((await deleteReform(deleteRequest(), itemContext('x'))).status).toBe(503); - } finally { - if (previousUrl !== undefined) { - process.env.DATABASE_URL = previousUrl; - } - setDbForTesting(drizzle(client, { schema }) as any); - } - }); -}); - -describe('flag-off deployments (production leak gate)', () => { - it('given the flagship flag is off then every handler 404s before touching the db', async () => { - const previous = process.env.NEXT_PUBLIC_FLAGSHIP_SHELL; - process.env.NEXT_PUBLIC_FLAGSHIP_SHELL = ''; - - try { - expect((await createReform(postRequest(VALID_PAYLOAD))).status).toBe(404); - expect((await listReforms(listRequest([UNKNOWN_UUID]))).status).toBe(404); - expect((await getReform(new Request('http://test'), itemContext('x'))).status).toBe(404); - expect((await patchReform(patchRequest({ label: 'x' }), itemContext('x'))).status).toBe(404); - expect((await deleteReform(deleteRequest(), itemContext('x'))).status).toBe(404); - } finally { - process.env.NEXT_PUBLIC_FLAGSHIP_SHELL = previous; - } - }); -}); diff --git a/app/src/tests/integration/api/reportRoutes.test.ts b/app/src/tests/integration/api/reportRoutes.test.ts deleted file mode 100644 index 64bdcd46e..000000000 --- a/app/src/tests/integration/api/reportRoutes.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -// @vitest-environment node -// -// Integration tests for the /api/reports route handlers against PGlite with -// the checked-in migrations applied, as for /api/reforms. -import { PGlite } from '@electric-sql/pglite'; -import { drizzle } from 'drizzle-orm/pglite'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { validationToWire } from '@/libs/flagship/reportValidation'; -import { - applyMigrations, - CLIENT_IP, - CLIENT_USER_AGENT, - editTokenHeader, - IDENTIFYING_HEADERS, - insertRowsCreatedAgo, - sha256Hex, - textOfLength, - UNKNOWN_UUID, -} from '@/tests/fixtures/integration/flagshipDbFixtures'; -import { API_REPORT_ID, CENTRAL_REFORM_ID } from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; -import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; -import { - GET as getReport, - PATCH as patchReport, -} from '../../../../../calculator-app/src/app/api/reports/[reportId]/route'; -import { - POST as createReport, - GET as listReports, -} from '../../../../../calculator-app/src/app/api/reports/route'; -import { setDbForTesting } from '../../../../../calculator-app/src/db'; -import { - MAX_BODY_BYTES, - MAX_TEXT_LENGTH, - NEW_ROWS_PER_MINUTE, -} from '../../../../../calculator-app/src/db/limits'; -import * as schema from '../../../../../calculator-app/src/db/schema'; - -let client: PGlite; - -beforeAll(async () => { - process.env.NEXT_PUBLIC_FLAGSHIP_SHELL = 'true'; - client = new PGlite(); - await applyMigrations(client); - setDbForTesting(drizzle(client, { schema }) as any); -}); - -afterAll(async () => { - setDbForTesting(null); - await client.close(); -}); - -beforeEach(async () => { - await client.exec('DELETE FROM reports;'); -}); - -const VALID_PAYLOAD = { - country_id: 'us', - api_report_id: API_REPORT_ID, - title: 'CTC to $2,500', - source_note: 'Hand-built draft', - provisions: [ - { - path: 'gov.irs.credits.ctc.amount.base[0].amount', - breadcrumb: 'IRS → Credits → CTC → Amount', - unit: 'currency-USD', - baseline_value: 2200, - value: 2500, - }, - ], - year: '2026', - reform_id: null, -}; - -const VALIDATION_BODY = { validation: validationToWire(mockReportValidationSnapshot) }; - -interface CreatedReport { - id: string; - edit_token: string; - [key: string]: unknown; -} - -function postRequest(body: unknown, headers: Record<string, string> = {}): Request { - return new Request('http://test/api/reports', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify(body), - }); -} - -function itemContext(reportId: string) { - return { params: Promise.resolve({ reportId }) }; -} - -function patchRequest(body: unknown, token?: string): Request { - return new Request('http://test/api/reports/x', { - method: 'PATCH', - headers: token ? editTokenHeader(token) : {}, - body: JSON.stringify(body), - }); -} - -async function createAndParse(payload: unknown = VALID_PAYLOAD): Promise<CreatedReport> { - const response = await createReport(postRequest(payload)); - expect(response.status).toBe(201); - return response.json(); -} - -describe('POST /api/reports', () => { - it('given a valid payload then it returns the record with an edit token and no user id', async () => { - const created = await createAndParse(); - - expect(created.edit_token).toMatch(/^[0-9a-f]{64}$/); - expect(created).not.toHaveProperty('user_id'); - expect(created.api_report_id).toBe(API_REPORT_ID); - - const { rows } = await client.query<{ user_id: string | null; edit_token_hash: string }>( - 'SELECT user_id, edit_token_hash FROM reports WHERE id = $1', - [created.id] - ); - expect(rows[0].user_id).toBeNull(); - expect(rows[0].edit_token_hash).toBe(sha256Hex(created.edit_token)); - }); - - it('given a request with an IP address and user agent then the row stores neither', async () => { - const response = await createReport(postRequest(VALID_PAYLOAD, IDENTIFYING_HEADERS)); - expect(response.status).toBe(201); - - const { rows } = await client.query<{ row: string }>( - 'SELECT row_to_json(reports)::text AS row FROM reports' - ); - expect(rows[0].row).not.toContain(CLIENT_IP); - expect(rows[0].row).not.toContain(CLIENT_USER_AGENT); - }); - - it.each([ - ['invalid country', { ...VALID_PAYLOAD, country_id: 'fr' }], - ['missing api_report_id', { ...VALID_PAYLOAD, api_report_id: undefined }], - ['provisions not an array', { ...VALID_PAYLOAD, provisions: 'oops' }], - ['a provision that is not an object', { ...VALID_PAYLOAD, provisions: ['oops'] }], - ['a provision without a path', { ...VALID_PAYLOAD, provisions: [{ breadcrumb: 'CTC' }] }], - [ - 'a provision with a numeric unit', - { ...VALID_PAYLOAD, provisions: [{ ...VALID_PAYLOAD.provisions[0], unit: 7 }] }, - ], - ['missing year', { ...VALID_PAYLOAD, year: undefined }], - ['a local fallback reform_id', { ...VALID_PAYLOAD, reform_id: 'rf-mfz1abcd' }], - ['a non-string reform_id', { ...VALID_PAYLOAD, reform_id: 42 }], - ])('given %s then returns 400', async (_name, payload) => { - const response = await createReport(postRequest(payload)); - expect(response.status).toBe(400); - }); - - it('given a provision without a path then the error names the field', async () => { - const response = await createReport( - postRequest({ ...VALID_PAYLOAD, provisions: [{ breadcrumb: 'CTC' }] }) - ); - - expect(await response.json()).toEqual({ - error: 'provisions[0].path must be a non-empty string', - }); - }); - - it('given provisions without units or baselines then the record is stored', async () => { - const { path, breadcrumb, value } = VALID_PAYLOAD.provisions[0]; - - const created = await createAndParse({ - ...VALID_PAYLOAD, - provisions: [{ path, breadcrumb, value }], - }); - - expect(created.provisions).toEqual([{ path, breadcrumb, value }]); - }); - - it('given a central reform_id then it is stored on the record', async () => { - const created = await createAndParse({ ...VALID_PAYLOAD, reform_id: CENTRAL_REFORM_ID }); - - expect(created.reform_id).toBe(CENTRAL_REFORM_ID); - }); -}); - -describe('abuse limits', () => { - it('given a body over the size limit then POST returns 413', async () => { - const response = await createReport( - postRequest({ - ...VALID_PAYLOAD, - provisions: [{ ...VALID_PAYLOAD.provisions[0], breadcrumb: textOfLength(MAX_BODY_BYTES) }], - }) - ); - - expect(response.status).toBe(413); - }); - - it.each([ - [ - 'a title over the length limit', - { ...VALID_PAYLOAD, title: textOfLength(MAX_TEXT_LENGTH + 1) }, - ], - [ - 'a source note over the length limit', - { ...VALID_PAYLOAD, source_note: textOfLength(MAX_TEXT_LENGTH + 1) }, - ], - ['a non-string title', { ...VALID_PAYLOAD, title: { text: 'x' } }], - ])('given %s then POST returns 400', async (_name, payload) => { - const response = await createReport(postRequest(payload)); - - expect(response.status).toBe(400); - }); - - it('given the new-row budget is used up this minute then POST returns 429', async () => { - await insertRowsCreatedAgo(client, 'reports', NEW_ROWS_PER_MINUTE, 0); - - const response = await createReport(postRequest(VALID_PAYLOAD)); - - expect(response.status).toBe(429); - expect(response.headers.get('Retry-After')).toBe('60'); - }); - - it('given a body over the size limit then PATCH returns 413 and nothing is pinned', async () => { - const created = await createAndParse(); - - const response = await patchReport( - patchRequest( - { validation: { ...VALIDATION_BODY.validation, padding: textOfLength(MAX_BODY_BYTES) } }, - created.edit_token - ), - itemContext(created.id) - ); - - expect(response.status).toBe(413); - const reread = await getReport(new Request('http://test'), itemContext(created.id)); - expect((await reread.json()).validation).toBeNull(); - }); -}); - -describe('GET /api/reports', () => { - it('given no ids then returns 400 rather than listing every record', async () => { - await createAndParse(); - - const response = await listReports(new Request('http://test/api/reports')); - - expect(response.status).toBe(400); - }); - - it('given an api_report_id lookup then returns 400, since sequential ids would be enumerable', async () => { - await createAndParse(); - - const response = await listReports( - new Request(`http://test/api/reports?api_report_id=${API_REPORT_ID}`) - ); - - expect(response.status).toBe(400); - }); - - it('given ids then only those records are returned', async () => { - const mine = await createAndParse(); - await createAndParse({ ...VALID_PAYLOAD, title: 'Someone else’s' }); - - const response = await listReports( - new Request(`http://test/api/reports?ids=${mine.id},${UNKNOWN_UUID}`) - ); - - const records = (await response.json()) as CreatedReport[]; - expect(records.map((record) => record.id)).toEqual([mine.id]); - expect(records[0]).not.toHaveProperty('edit_token'); - }); -}); - -describe('GET/PATCH /api/reports/:id', () => { - it('given a created record then GET returns it by id', async () => { - const created = await createAndParse(); - - const response = await getReport(new Request('http://test'), itemContext(created.id)); - - expect(response.status).toBe(200); - }); - - it('given a non-UUID id then GET returns 404 rather than a database error', async () => { - const response = await getReport(new Request('http://test'), itemContext('not-a-uuid')); - - expect(response.status).toBe(404); - }); - - it('given the edit token then PATCH pins the validation snapshot', async () => { - const created = await createAndParse(); - - const response = await patchReport( - patchRequest(VALIDATION_BODY, created.edit_token), - itemContext(created.id) - ); - - expect(response.status).toBe(200); - expect((await response.json()).validation).toEqual(VALIDATION_BODY.validation); - }); - - it('given no edit token then PATCH returns 403 and nothing is pinned', async () => { - const created = await createAndParse(); - - const response = await patchReport(patchRequest(VALIDATION_BODY), itemContext(created.id)); - - expect(response.status).toBe(403); - const reread = await getReport(new Request('http://test'), itemContext(created.id)); - expect((await reread.json()).validation).toBeNull(); - }); - - it('given a pin the report schema can not read then PATCH returns 400 and nothing is pinned', async () => { - const created = await createAndParse(); - const damaged = { ...VALIDATION_BODY.validation, scorecard: { programs: 'ctc_refund' } }; - - const response = await patchReport( - patchRequest({ validation: damaged }, created.edit_token), - itemContext(created.id) - ); - - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ - error: 'validation.scorecard.programs must be an array', - }); - const reread = await getReport(new Request('http://test'), itemContext(created.id)); - expect((await reread.json()).validation).toBeNull(); - }); - - it('given an unknown record then PATCH returns 404', async () => { - const response = await patchReport( - patchRequest(VALIDATION_BODY, 'a'.repeat(64)), - itemContext(UNKNOWN_UUID) - ); - - expect(response.status).toBe(404); - }); - - it('given a legacy record without a token hash then GET works and PATCH returns 403', async () => { - const { rows } = await client.query<{ id: string }>( - `INSERT INTO reports (user_id, country_id, api_report_id, provisions, year) - VALUES ('anonymous', 'us', '3301', '[]', '2026') RETURNING id` - ); - const legacyId = rows[0].id; - - const read = await getReport(new Request('http://test'), itemContext(legacyId)); - const pin = await patchReport( - patchRequest(VALIDATION_BODY, 'a'.repeat(64)), - itemContext(legacyId) - ); - - expect(read.status).toBe(200); - expect(await read.json()).not.toHaveProperty('user_id'); - expect(pin.status).toBe(403); - }); -}); diff --git a/app/src/tests/unit/api/flagshipReportStore.test.ts b/app/src/tests/unit/api/flagshipReportStore.test.ts index 8d8172dab..13d36e8e8 100644 --- a/app/src/tests/unit/api/flagshipReportStore.test.ts +++ b/app/src/tests/unit/api/flagshipReportStore.test.ts @@ -1,39 +1,44 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; import { ApiFlagshipReportStore, getFlagshipReportStore, LocalStorageFlagshipReportStore, - resetFlagshipReportStoreAvailability, + NewFlagshipReport, + provisionsFromWire, + provisionsToWire, } from '@/api/flagshipReportStore'; -import { - EDIT_TOKEN_HEADER, - findOwnedReportByApiReportId, - ItemNotOwnedError, -} from '@/libs/flagship/ownedItems'; +import { BrowserOnlyIdError } from '@/api/v2Associations'; import { validationToWire } from '@/libs/flagship/reportValidation'; import { - HTTP_STATUS, - LEGACY_REPORTS_KEY, - legacyLocalReform, - legacyLocalReport, - mockErrorResponse, - mockJsonResponse, - mockNewReport, - mockReportRow, -} from '@/tests/fixtures/api/flagshipStoreMocks'; -import { - API_REPORT_ID, - CENTRAL_REFORM_ID, - CENTRAL_REPORT_ID, - EDIT_TOKEN, - OTHER_API_REPORT_ID, - OWNED_REPORTS_KEY, - ownedReport, - seedOwnedItems, -} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; + ASSOCIATION_IDS, + createTestApiContext, + fetchCall, + itemResponse, + pageResponse, + reportItem, +} from '@/tests/fixtures/api/v2UserMocks'; import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; -const fetchMock = vi.fn(); +const API_REPORT_ID = '4512'; +const OTHER_API_REPORT_ID = '9999'; + +const newReport: NewFlagshipReport = { + countryId: 'us', + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [ + { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Amount', + unit: 'currency-USD', + baselineValue: 2200, + value: 2500, + }, + ], + year: '2026', + reformId: null, +}; describe('LocalStorageFlagshipReportStore', () => { beforeEach(() => { @@ -42,23 +47,27 @@ describe('LocalStorageFlagshipReportStore', () => { test('given a created report then it is found by its API report id', async () => { const store = new LocalStorageFlagshipReportStore(); - const created = await store.create(mockNewReport); + const created = await store.create(newReport); await expect(store.findByApiReportId(API_REPORT_ID)).resolves.toEqual(created); await expect(store.findByApiReportId(OTHER_API_REPORT_ID)).resolves.toBeNull(); }); - test('given a record saved under the old shared user id then it is listed as mine', async () => { - localStorage.setItem(LEGACY_REPORTS_KEY, JSON.stringify([legacyLocalReport])); + test('given reports in two countries then findMine lists one country, newest first', async () => { + const store = new LocalStorageFlagshipReportStore(); + const older = await store.create(newReport); + await new Promise((resolve) => setTimeout(resolve, 5)); + const newer = await store.create({ ...newReport, apiReportId: OTHER_API_REPORT_ID }); + await store.create({ ...newReport, countryId: 'uk' }); - const mine = await new LocalStorageFlagshipReportStore().findMine('us'); + const usRecords = await store.findMine('us'); - expect(mine.map((record) => record.id)).toEqual([legacyLocalReport.id]); + expect(usRecords.map((record) => record.id)).toEqual([newer.id, older.id]); }); test('given a saved validation then it persists on the record', async () => { const store = new LocalStorageFlagshipReportStore(); - const created = await store.create(mockNewReport); + const created = await store.create(newReport); const updated = await store.saveValidation(created.id, mockReportValidationSnapshot); @@ -76,161 +85,264 @@ describe('LocalStorageFlagshipReportStore', () => { }); }); -describe('ApiFlagshipReportStore', () => { - beforeEach(() => { - vi.stubGlobal('fetch', fetchMock); - fetchMock.mockReset(); - localStorage.clear(); +const DATED_PROVISION = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Amount', + unit: 'currency-USD', + baselineValue: 2200, + value: 2500, + intervals: [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 2500 }, + { startDate: '2027-01-01', endDate: '2100-12-31', value: 3000 }, + ], +}; + +const DATED_PROVISION_WIRE = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Amount', + unit: 'currency-USD', + baseline_value: 2200, + value: 2500, + intervals: [ + { start_date: '2026-01-01', end_date: '2026-12-31', value: 2500 }, + { start_date: '2027-01-01', end_date: '2100-12-31', value: 3000 }, + ], +}; + +describe('provisions wire mapping', () => { + test('given a growing provision then its growth settings round-trip in snake_case', () => { + const growing = { + ...DATED_PROVISION, + growth: 'gov.bls.cpi.cpi_u', + growthStart: 2027, + growthBase: 2026, + growthRoundTo: 50, + growthRound: 'down' as const, + }; + + const [wire] = provisionsToWire([growing]); + + expect(wire).toMatchObject({ + growth: 'gov.bls.cpi.cpi_u', + growth_start: 2027, + growth_base: 2026, + growth_round_to: 50, + growth_round: 'down', + }); + expect(provisionsFromWire([wire])).toEqual([growing]); }); - afterEach(() => { - vi.unstubAllGlobals(); + test('given a fixed provision then the wire carries no growth keys', () => { + const [wire] = provisionsToWire([DATED_PROVISION]); + + expect(Object.keys(wire).filter((key) => key.startsWith('growth'))).toEqual([]); }); - test('given a new report then it is POSTed without a user id and indexed with its edit token', async () => { - fetchMock.mockResolvedValue( - mockJsonResponse({ ...mockReportRow, edit_token: EDIT_TOKEN }, HTTP_STATUS.CREATED) - ); + test('given dated provisions then they map to snake_case with start_date and end_date', () => { + expect(provisionsToWire([DATED_PROVISION])).toEqual([DATED_PROVISION_WIRE]); + }); - const record = await new ApiFlagshipReportStore().create(mockNewReport); + test('given a single-value provision then intervals are null on the wire', () => { + expect(provisionsToWire(newReport.provisions)[0].intervals).toBeNull(); + }); - expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty('user_id'); - expect(record.id).toBe(CENTRAL_REPORT_ID); - expect(findOwnedReportByApiReportId(API_REPORT_ID)).toMatchObject({ - id: CENTRAL_REPORT_ID, - editToken: EDIT_TOKEN, - }); + test('given wire provisions then they map back to camelCase', () => { + expect(provisionsFromWire([DATED_PROVISION_WIRE])).toEqual([DATED_PROVISION]); + expect(provisionsFromWire(null)).toEqual([]); }); +}); - test('given a report from a reform still in the localStorage fallback then no reform_id is sent', async () => { +describe('ApiFlagshipReportStore', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given a run then one /v2/user-reports row carries the v3 fields', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); fetchMock.mockResolvedValue( - mockJsonResponse({ ...mockReportRow, edit_token: EDIT_TOKEN }, HTTP_STATUS.CREATED) + itemResponse( + reportItem({ + legacy_report_id: Number(API_REPORT_ID), + name: 'CTC to $2,500', + source_note: 'Hand-built draft', + provisions: provisionsToWire(newReport.provisions), + user_policy_id: ASSOCIATION_IDS.REFORM, + }), + 201 + ) ); + const store = new ApiFlagshipReportStore(context); + + // When + const record = await store.create({ ...newReport, reformId: ASSOCIATION_IDS.REFORM }); + + // Then + const call = fetchCall(fetchMock); + expect(call.path).toBe('/v2/user-reports'); + expect(call.body).toEqual({ + country_id: 'us', + legacy_report_id: Number(API_REPORT_ID), + name: 'CTC to $2,500', + source_note: 'Hand-built draft', + provisions: provisionsToWire(newReport.provisions), + user_policy_id: ASSOCIATION_IDS.REFORM, + last_run_at: expect.any(String), + }); + expect(call.body).not.toHaveProperty('user_id'); + expect(call.body).not.toHaveProperty('year'); + expect(record).toMatchObject({ + id: ASSOCIATION_IDS.REPORT, + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + reformId: ASSOCIATION_IDS.REFORM, + provisions: newReport.provisions, + }); + }); + + test('given a budget window then the row carries its years and reads them back', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(reportItem({ budget_window_years: 10 }), 201)); + const store = new ApiFlagshipReportStore(context); - await new ApiFlagshipReportStore().create({ ...mockNewReport, reformId: legacyLocalReform.id }); + // When + const record = await store.create({ ...newReport, budgetWindowYears: 10 }); - expect(JSON.parse(fetchMock.mock.calls[0][1].body).reform_id).toBeNull(); + // Then + expect(fetchCall(fetchMock).body.budget_window_years).toBe(10); + expect(record.budgetWindowYears).toBe(10); }); - test('given a report from a central reform then its reform_id is sent', async () => { - fetchMock.mockResolvedValue( - mockJsonResponse({ ...mockReportRow, edit_token: EDIT_TOKEN }, HTTP_STATUS.CREATED) - ); + test('given a one-year run then the row has no budget window', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(async () => itemResponse(reportItem(), 201)); + const store = new ApiFlagshipReportStore(context); - await new ApiFlagshipReportStore().create({ ...mockNewReport, reformId: CENTRAL_REFORM_ID }); + // When + await store.create({ ...newReport, budgetWindowYears: 1 }); + const record = await store.create({ ...newReport, budgetWindowYears: null }); - expect(JSON.parse(fetchMock.mock.calls[0][1].body).reform_id).toBe(CENTRAL_REFORM_ID); + // Then + expect(fetchCall(fetchMock, 0).body).not.toHaveProperty('budget_window_years'); + expect(fetchCall(fetchMock, 1).body).not.toHaveProperty('budget_window_years'); + expect(record).not.toHaveProperty('budgetWindowYears'); }); - test('given an indexed API report then its record is read by id, never searched by API report id', async () => { - seedOwnedItems(OWNED_REPORTS_KEY, [ownedReport]); - fetchMock.mockResolvedValue( - mockJsonResponse({ - ...mockReportRow, - validation: validationToWire(mockReportValidationSnapshot), - }) - ); + test('given a comparison with a saved reform then the row links it, unless browser-only', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(async () => itemResponse(reportItem(), 201)); + const store = new ApiFlagshipReportStore(context); - const record = await new ApiFlagshipReportStore().findByApiReportId(API_REPORT_ID); + // When + await store.create({ ...newReport, baselineReformId: ASSOCIATION_IDS.POLICY }); + await store.create({ ...newReport, baselineReformId: 'rf-browser1' }); - expect(fetchMock).toHaveBeenCalledWith(`/api/reports/${CENTRAL_REPORT_ID}`); - expect(record?.validation).toEqual(mockReportValidationSnapshot); + // Then + expect(fetchCall(fetchMock, 0).body.baseline_user_policy_id).toBe(ASSOCIATION_IDS.POLICY); + expect(fetchCall(fetchMock, 1).body).not.toHaveProperty('baseline_user_policy_id'); }); - test('given an API report this browser did not run then no record is looked up', async () => { - const record = await new ApiFlagshipReportStore().findByApiReportId(OTHER_API_REPORT_ID); + test('given a reform saved in this browser then the account row is not linked to it', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(async () => itemResponse(reportItem(), 201)); + const store = new ApiFlagshipReportStore(context); - expect(record).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); + // When + await store.create({ ...newReport, reformId: ASSOCIATION_IDS.REFORM }); + await store.create({ ...newReport, reformId: 'rf-browser1' }); + + // Then + expect(fetchCall(fetchMock, 0).body.user_policy_id).toBe(ASSOCIATION_IDS.REFORM); + expect(fetchCall(fetchMock, 1).body).not.toHaveProperty('user_policy_id'); }); - test('given indexed records then finding mine reads them by id', async () => { - seedOwnedItems(OWNED_REPORTS_KEY, [ownedReport]); - fetchMock.mockResolvedValue(mockJsonResponse([mockReportRow])); + test('given a UK run while the store is in the US then the row is written in the UK', async () => { + // Given + const { fetchMock, context } = createTestApiContext('us'); + fetchMock.mockResolvedValue(itemResponse(reportItem({ country_id: 'uk' }), 201)); + const store = new ApiFlagshipReportStore(context); - const records = await new ApiFlagshipReportStore().findMine('us'); + // When + await store.create({ ...newReport, countryId: 'uk' }); - expect(fetchMock).toHaveBeenCalledWith(`/api/reports?ids=${CENTRAL_REPORT_ID}`); - expect(records.map((record) => record.id)).toEqual([CENTRAL_REPORT_ID]); + // Then + const call = fetchCall(fetchMock); + expect(call.query).toEqual({ country_id: 'uk' }); + expect(call.body.country_id).toBe('uk'); }); - test('given an owned record then the snapshot is PATCHed in wire form with its edit token', async () => { - seedOwnedItems(OWNED_REPORTS_KEY, [ownedReport]); - fetchMock.mockResolvedValue(mockJsonResponse(mockReportRow)); - - await new ApiFlagshipReportStore().saveValidation( - CENTRAL_REPORT_ID, - mockReportValidationSnapshot + test('given an API report id then findByApiReportId filters by legacy_report_id', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue( + pageResponse([reportItem({ legacy_report_id: Number(API_REPORT_ID) })]) ); + const store = new ApiFlagshipReportStore(context); - expect(fetchMock).toHaveBeenCalledWith(`/api/reports/${CENTRAL_REPORT_ID}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', [EDIT_TOKEN_HEADER]: EDIT_TOKEN }, - body: JSON.stringify({ validation: validationToWire(mockReportValidationSnapshot) }), + // When + const record = await store.findByApiReportId(API_REPORT_ID); + + // Then + expect(fetchCall(fetchMock).query).toEqual({ + country_id: 'us', + legacy_report_id: API_REPORT_ID, + limit: '1', }); + expect(record?.apiReportId).toBe(API_REPORT_ID); }); - test('given a record this browser did not create then pinning throws without a request', async () => { - await expect( - new ApiFlagshipReportStore().saveValidation(CENTRAL_REPORT_ID, mockReportValidationSnapshot) - ).rejects.toThrow(ItemNotOwnedError); - expect(fetchMock).not.toHaveBeenCalled(); + test('given a snapshot then saveValidation patches the validation wire format', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const wire = validationToWire(mockReportValidationSnapshot); + fetchMock.mockResolvedValue(itemResponse(reportItem({ validation: wire }))); + const store = new ApiFlagshipReportStore(context); + + // When + const record = await store.saveValidation(ASSOCIATION_IDS.REPORT, mockReportValidationSnapshot); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('PATCH'); + expect(call.path).toBe(`/v2/user-reports/${ASSOCIATION_IDS.REPORT}`); + expect(call.body).toEqual({ validation: wire }); + expect(record.validation).toEqual(mockReportValidationSnapshot); }); }); -describe('getFlagshipReportStore', () => { +describe('ApiFlagshipReportStore with records saved in this browser', () => { beforeEach(() => { - vi.stubGlobal('fetch', fetchMock); - fetchMock.mockReset(); localStorage.clear(); - resetFlagshipReportStoreAvailability(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - test('given the central store is unconfigured then a new record is saved locally', async () => { - fetchMock.mockResolvedValue(mockErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE)); - - const record = await getFlagshipReportStore().create(mockNewReport); - - expect(record.id).toMatch(/^lfr-/); - expect(findOwnedReportByApiReportId(API_REPORT_ID)).toBeNull(); }); - test('given the central store is busy (429) then that record is saved locally and the next create tries the API again', async () => { - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.TOO_MANY_REQUESTS)); - const busyRecord = await getFlagshipReportStore().create(mockNewReport); - - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ ...mockReportRow, edit_token: EDIT_TOKEN }, HTTP_STATUS.CREATED) - ); - const nextRecord = await getFlagshipReportStore().create(mockNewReport); - - expect(busyRecord.id).toMatch(/^lfr-/); - expect(nextRecord.id).toBe(CENTRAL_REPORT_ID); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test('given a pre-existing local record then it is found and pinned locally without a request', async () => { - localStorage.setItem(LEGACY_REPORTS_KEY, JSON.stringify([legacyLocalReport])); - const store = getFlagshipReportStore(); - - const record = await store.findByApiReportId(legacyLocalReport.apiReportId); - const pinned = await store.saveValidation(record!.id, mockReportValidationSnapshot); + test('given an lfr- record saved while signed out then signed in it is refused, not read locally', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const localRecord = await new LocalStorageFlagshipReportStore().create(newReport); + const store = new ApiFlagshipReportStore(context); - expect(pinned.validation).toEqual(mockReportValidationSnapshot); + // When / Then + await expect(store.findById(localRecord.id)).rejects.toBeInstanceOf(BrowserOnlyIdError); + await expect( + store.saveValidation(localRecord.id, mockReportValidationSnapshot) + ).rejects.toBeInstanceOf(BrowserOnlyIdError); expect(fetchMock).not.toHaveBeenCalled(); }); +}); - test('given central and pre-existing local records then both are listed, newest first', async () => { - seedOwnedItems(OWNED_REPORTS_KEY, [ownedReport]); - localStorage.setItem(LEGACY_REPORTS_KEY, JSON.stringify([legacyLocalReport])); - fetchMock.mockResolvedValue(mockJsonResponse([mockReportRow])); - - const records = await getFlagshipReportStore().findMine('us'); +describe('getFlagshipReportStore', () => { + test('given no account then records stay in localStorage', () => { + expect(getFlagshipReportStore(null)).toBeInstanceOf(LocalStorageFlagshipReportStore); + }); - expect(records.map((record) => record.id)).toEqual([CENTRAL_REPORT_ID, legacyLocalReport.id]); + test('given an account then records go to the account', () => { + expect(getFlagshipReportStore(createTestApiContext().context)).toBeInstanceOf( + ApiFlagshipReportStore + ); }); }); diff --git a/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts b/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts index 24f30cf9c..36ef04566 100644 --- a/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts +++ b/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts @@ -1,25 +1,44 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { ApiFlagshipReportStore, LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { + ApiFlagshipReportStore, + LocalStorageFlagshipReportStore, + provisionsToWire, + type NewFlagshipReport, +} from '@/api/flagshipReportStore'; import { ReportSchemaError } from '@/libs/flagship/reportSchema'; -import { mockJsonResponse, mockNewReport } from '@/tests/fixtures/api/flagshipStoreMocks'; +import { validationToWire } from '@/libs/flagship/reportValidation'; import { - CENTRAL_REPORT_ID, - OWNED_REPORTS_KEY, - ownedReport, - seedOwnedItems, -} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; + ASSOCIATION_IDS, + createTestApiContext, + itemResponse, + pageResponse, + reportItem, + TIMESTAMPS, +} from '@/tests/fixtures/api/v2UserMocks'; import { + API_REPORT_ID, + CTC_PROVISION, CURRENT_SCHEMA_VERSION, currentRecord, + GROWING_PROVISION, historicRecords, LOCAL_REPORT_ID, recordFromNewerVersion, recordWithoutUserId, REPORTS_KEY, - rowWithoutUserId, } from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; +const mockNewReport: NewFlagshipReport = { + countryId: 'us', + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [CTC_PROVISION], + year: '2026', + reformId: null, +}; + const UNREADABLE_ID = 'lfr-unreadable'; const unreadableEntry = { ...recordWithoutUserId, id: UNREADABLE_ID, provisions: 'oops' }; @@ -113,42 +132,76 @@ describe('LocalStorageFlagshipReportStore and the report schema', () => { }); describe('ApiFlagshipReportStore and the report schema', () => { - const fetchMock = vi.fn(); + /** The account's row for the report, as /v2/user-reports serves it. */ + const accountRow = reportItem({ + legacy_report_id: Number(API_REPORT_ID), + name: 'CTC to $2,500', + source_note: 'Hand-built draft', + provisions: provisionsToWire([CTC_PROVISION, GROWING_PROVISION]), + user_policy_id: ASSOCIATION_IDS.REFORM, + baseline_user_policy_id: ASSOCIATION_IDS.POLICY, + validation: validationToWire(mockReportValidationSnapshot), + budget_window_years: 10, + }); beforeEach(() => { - vi.stubGlobal('fetch', fetchMock); - fetchMock.mockReset(); localStorage.clear(); - seedOwnedItems(OWNED_REPORTS_KEY, [ownedReport]); - }); - - afterEach(() => { - vi.unstubAllGlobals(); }); - test('given a central row then it reads in the current shape', async () => { - fetchMock.mockResolvedValue(mockJsonResponse(rowWithoutUserId)); - - const record = await new ApiFlagshipReportStore().findById(CENTRAL_REPORT_ID); - - expect(record).toEqual({ ...currentRecord, id: CENTRAL_REPORT_ID }); + test('given an account row then it reads in the current shape, growth and links included', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(accountRow)); + + // When + const record = await new ApiFlagshipReportStore(context).findById(ASSOCIATION_IDS.REPORT); + + // Then + expect(record).toEqual({ + id: ASSOCIATION_IDS.REPORT, + countryId: 'us', + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [CTC_PROVISION, GROWING_PROVISION], + createdAt: TIMESTAMPS.CREATED, + reformId: ASSOCIATION_IDS.REFORM, + validation: mockReportValidationSnapshot, + budgetWindowYears: 10, + baselineReformId: ASSOCIATION_IDS.POLICY, + }); }); test('given an unreadable row then the list leaves it out', async () => { - fetchMock.mockResolvedValue( - mockJsonResponse([rowWithoutUserId, { ...rowWithoutUserId, country_id: 'fr' }]) - ); - - const mine = await new ApiFlagshipReportStore().findMine(); - - expect(mine.map((record) => record.id)).toEqual([CENTRAL_REPORT_ID]); + // Given + const { fetchMock, context } = createTestApiContext(); + const unreadableRow = { + ...accountRow, + id: 'unreadable-row', + provisions: [{ ...provisionsToWire([CTC_PROVISION])[0], path: '' }], + }; + fetchMock.mockResolvedValue(pageResponse([accountRow, unreadableRow])); + + // When + const mine = await new ApiFlagshipReportStore(context).findMine('us'); + + // Then + expect(mine.map((record) => record.id)).toEqual([ASSOCIATION_IDS.REPORT]); }); test('given an unreadable row then reading it throws a ReportSchemaError', async () => { - fetchMock.mockResolvedValue(mockJsonResponse({ ...rowWithoutUserId, provisions: 'oops' })); - - await expect(new ApiFlagshipReportStore().findById(CENTRAL_REPORT_ID)).rejects.toThrow( - ReportSchemaError + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue( + itemResponse({ + ...accountRow, + provisions: [{ ...provisionsToWire([GROWING_PROVISION])[0], growth_round: 'sideways' }], + }) ); + + // When / Then + await expect( + new ApiFlagshipReportStore(context).findById(ASSOCIATION_IDS.REPORT) + ).rejects.toThrow(ReportSchemaError); }); }); diff --git a/app/src/tests/unit/api/reformStore.test.ts b/app/src/tests/unit/api/reformStore.test.ts index 28ddd79d6..641eb600c 100644 --- a/app/src/tests/unit/api/reformStore.test.ts +++ b/app/src/tests/unit/api/reformStore.test.ts @@ -1,402 +1,439 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ReformAdapter } from '@/adapters/ReformAdapter'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiReformStore, + getReformStore, LocalStorageReformStore, - ReformStoreBusyError, - resetReformStoreAvailabilityForTesting, - ResilientReformStore, + parametersFromPolicyJson, + ReformNotFoundError, } from '@/api/reformStore'; +import { MAX_STORE_NAME_LENGTH } from '@/libs/flagship/storeText'; import { - EDIT_TOKEN_HEADER, - findOwnedItem, - ItemNotOwnedError, - readOwnedItems, -} from '@/libs/flagship/ownedItems'; -import { - HTTP_STATUS, - LEGACY_REFORMS_KEY, - legacyLocalReform, - mockErrorResponse, - mockJsonResponse, - mockNewReform, - mockReformCreatedMetadata, - mockReformMetadata, -} from '@/tests/fixtures/api/flagshipStoreMocks'; -import { - CENTRAL_REFORM_ID, - EDIT_TOKEN, - OTHER_CENTRAL_REFORM_ID, - OWNED_REFORMS_KEY, - ownedUkReform, - ownedUsReform, - seedOwnedItems, -} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; - -const fetchMock = vi.fn(); - -function requestHeaders(callIndex = 0): Record<string, string> { - return fetchMock.mock.calls[callIndex][1].headers; + ASSOCIATION_IDS, + createTestApiContext, + fetchCall, + itemResponse, + noContentResponse, + pageResponse, + policyItem, +} from '@/tests/fixtures/api/v2UserMocks'; +import type { Reform } from '@/types/ingredients/Reform'; + +const mockReformInput: Omit<Reform, 'id' | 'createdAt' | 'updatedAt'> = { + countryId: 'us', + label: 'CTC to $3,600 for children under 6', + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base[0]', + values: [{ startDate: '2026-01-01', endDate: '2026-12-31', value: 3600 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, +}; + +const MATERIALIZED_POLICY_ID = 95762; +const REPLACEMENT_POLICY_ID = 95800; + +const POLICY_JSON = { + 'gov.irs.credits.ctc.amount.base[0]': { '2026-01-01.2026-12-31': 3600 }, +}; + +function apiReformStore(countryId: 'us' | 'uk' = 'us') { + const { fetchMock, context } = createTestApiContext(countryId); + const materializePolicy = vi.fn().mockResolvedValue(MATERIALIZED_POLICY_ID); + const fetchPolicyJson = vi.fn().mockResolvedValue(POLICY_JSON); + const store = new ApiReformStore(context, { materializePolicy, fetchPolicyJson }); + return { store, fetchMock, materializePolicy, fetchPolicyJson }; } -describe('ReformAdapter', () => { - it('given a reform, when converted to a creation payload, then keys are snake_case and no user id is sent', () => { - const payload = ReformAdapter.toCreationPayload(mockNewReform); - - expect(payload).not.toHaveProperty('user_id'); - expect(payload.country_id).toBe('us'); - expect(payload.parameters[0].values[0]).toEqual({ - start_date: '2026-01-01', - end_date: '2026-12-31', - value: 3600, - }); - expect(payload.provenance).toEqual({ source: 'chat', ref: 'session-1', confidence: 'exact' }); - }); - - it('given an API response, when converted to a Reform, then keys are camelCase and ids are strings', () => { - const reform = ReformAdapter.fromApiResponse(mockReformMetadata); - - expect(reform.id).toBe(CENTRAL_REFORM_ID); - expect(reform.policyId).toBeUndefined(); - expect(reform.parameters[0].values[0]).toEqual({ - startDate: '2026-01-01', - endDate: '2026-12-31', - value: 3600, - }); - expect(reform.provenance.source).toBe('chat'); - }); - - it('given partial updates, when converted to an update payload, then only provided fields are included', () => { - const payload = ReformAdapter.toUpdatePayload({ label: 'Renamed' }); - - expect(payload).toEqual({ label: 'Renamed' }); - }); +const reformRow = policyItem({ + id: ASSOCIATION_IDS.REFORM, + legacy_policy_id: MATERIALIZED_POLICY_ID, + name: 'CTC to $3,600 for children under 6', + baseline: 'current-law', + provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, }); describe('ApiReformStore', () => { - let store: ApiReformStore; - beforeEach(() => { - vi.stubGlobal('fetch', fetchMock); - fetchMock.mockReset(); localStorage.clear(); - store = new ApiReformStore(); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('given a reform, when created, then it POSTs without a user id and indexes the id with its edit token', async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse(mockReformCreatedMetadata, HTTP_STATUS.CREATED) - ); - - const result = await store.create(mockNewReform); - - expect(fetchMock).toHaveBeenCalledWith( - '/api/reforms', - expect.objectContaining({ method: 'POST' }) - ); - expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty('user_id'); - expect(result.id).toBe(CENTRAL_REFORM_ID); - expect(result).not.toHaveProperty('editToken'); - expect(findOwnedItem('reforms', CENTRAL_REFORM_ID)).toMatchObject({ - countryId: 'us', - editToken: EDIT_TOKEN, + it('given a reform, when created, then its parameters become a v1 policy and the row links to it', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse(reformRow, 201)); + + // When + const created = await store.create(mockReformInput); + + // Then + expect(materializePolicy).toHaveBeenCalledWith('us', mockReformInput.parameters); + const call = fetchCall(fetchMock); + expect(call.path).toBe('/v2/user-policies'); + expect(call.body).toEqual({ + country_id: 'us', + legacy_policy_id: MATERIALIZED_POLICY_ID, + name: 'CTC to $3,600 for children under 6', + baseline: 'current-law', + provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, + }); + expect(created).toMatchObject({ + id: ASSOCIATION_IDS.REFORM, + policyId: String(MATERIALIZED_POLICY_ID), + parameters: mockReformInput.parameters, }); }); - it('given indexed reforms, when finding mine, then only this country’s ids are requested in one batch', async () => { - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform, ownedUkReform]); - fetchMock.mockResolvedValueOnce(mockJsonResponse([mockReformMetadata])); - - const result = await store.findMine('us'); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0][0]).toBe( - `/api/reforms?ids=${encodeURIComponent(CENTRAL_REFORM_ID)}` - ); - expect(result.map((reform) => reform.id)).toEqual([CENTRAL_REFORM_ID]); - }); - - it('given an empty index, when finding mine, then no request is made', async () => { - const result = await store.findMine('us'); - - expect(result).toEqual([]); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('given more indexed reforms than one batch holds, when finding mine, then the ids are split across requests', async () => { - const ids = Array.from( - { length: 51 }, - (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` - ); - seedOwnedItems( - OWNED_REFORMS_KEY, - ids.map((id) => ({ ...ownedUsReform, id })) - ); - fetchMock.mockResolvedValue(mockJsonResponse([])); + it('given a long name, when created, then it is shortened to the account limit', async () => { + // Given: a bill title longer than the Public API's 255-character name + const { store, fetchMock } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse(reformRow, 201)); - await store.findMine('us'); + // When + await store.create({ ...mockReformInput, label: 'A'.repeat(300) }); - expect(fetchMock).toHaveBeenCalledTimes(2); - const secondBatch = new URL(fetchMock.mock.calls[1][0], 'http://test').searchParams.get('ids'); - expect(secondBatch).toBe(ids[50]); + // Then + expect(fetchCall(fetchMock).body.name).toHaveLength(MAX_STORE_NAME_LENGTH); }); - it('given a missing reform, when finding by id, then returns null on 404', async () => { - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.NOT_FOUND)); + it('given a reform already materialized, when created, then no new policy is made', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse(reformRow, 201)); - const result = await store.findById(OTHER_CENTRAL_REFORM_ID); + // When + await store.create({ ...mockReformInput, policyId: '42' }); - expect(result).toBeNull(); + // Then + expect(materializePolicy).not.toHaveBeenCalled(); + expect(fetchCall(fetchMock).body.legacy_policy_id).toBe(42); }); - it('given the new-reform budget is used up (429), when creating, then throws ReformStoreBusyError', async () => { - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.TOO_MANY_REQUESTS)); - - await expect(store.create(mockNewReform)).rejects.toThrow(ReformStoreBusyError); - expect(readOwnedItems('reforms')).toEqual([]); - }); + it('given saved rows, when listed, then parameters are rebuilt from each v1 policy', async () => { + // Given + const { store, fetchMock, fetchPolicyJson } = apiReformStore(); + fetchMock.mockResolvedValue( + pageResponse([ + reformRow, + policyItem({ id: 'v2-only', legacy_policy_id: null }), + policyItem({ id: 'legacy-builder-policy', provenance: null }), + ]) + ); - it('given a failing API, when creating, then throws and indexes nothing', async () => { - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.INTERNAL_SERVER_ERROR)); + // When + const reforms = await store.findMine('us'); - await expect(store.create(mockNewReform)).rejects.toThrow('Failed to create reform'); - expect(readOwnedItems('reforms')).toEqual([]); + // Then + expect(fetchPolicyJson).toHaveBeenCalledWith('us', String(MATERIALIZED_POLICY_ID)); + expect(reforms).toHaveLength(1); + expect(reforms[0].parameters).toEqual(mockReformInput.parameters); + expect(reforms[0].provenance).toEqual({ + source: 'chat', + ref: 'session-1', + confidence: 'exact', + }); }); - it('given an owned reform, when updating, then PATCHes with its edit token', async () => { - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform]); - fetchMock.mockResolvedValueOnce(mockJsonResponse({ ...mockReformMetadata, label: 'Renamed' })); + it('given a policy saved from the legacy builder (no provenance), when read, then it is not a reform', async () => { + // Given + const { store, fetchMock } = apiReformStore(); + fetchMock.mockResolvedValue( + itemResponse(policyItem({ id: ASSOCIATION_IDS.REFORM, provenance: null })) + ); - const result = await store.update(CENTRAL_REFORM_ID, { label: 'Renamed' }); + // When + const reform = await store.findById(ASSOCIATION_IDS.REFORM); - expect(fetchMock).toHaveBeenCalledWith( - `/api/reforms/${CENTRAL_REFORM_ID}`, - expect.objectContaining({ method: 'PATCH' }) - ); - expect(requestHeaders()[EDIT_TOKEN_HEADER]).toBe(EDIT_TOKEN); - expect(result.label).toBe('Renamed'); + // Then + expect(reform).toBeNull(); }); - it('given a reform this browser did not create, when updating, then it throws without a request', async () => { - await expect(store.update(OTHER_CENTRAL_REFORM_ID, { label: 'Hijacked' })).rejects.toThrow( - ItemNotOwnedError + it('given a reform row without a baseline, when read, then it reads as a current-law reform', async () => { + // Given + const { store, fetchMock } = apiReformStore(); + fetchMock.mockResolvedValue( + itemResponse( + policyItem({ + id: ASSOCIATION_IDS.REFORM, + baseline: null, + provenance: { source: 'manual', ref: null, confidence: null }, + }) + ) ); - expect(fetchMock).not.toHaveBeenCalled(); - }); - it('given the server rejects the token, when updating, then it throws ItemNotOwnedError', async () => { - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform]); - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.FORBIDDEN)); + // When + const reform = await store.findById(ASSOCIATION_IDS.REFORM); - await expect(store.update(CENTRAL_REFORM_ID, { label: 'Renamed' })).rejects.toThrow( - ItemNotOwnedError - ); + // Then + expect(reform?.baseline).toBe('current-law'); + expect(reform?.provenance).toEqual({ source: 'manual' }); }); - it('given an owned reform, when deleting, then DELETEs with its token and drops it from the index', async () => { - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform]); - fetchMock.mockResolvedValueOnce(mockJsonResponse({ deleted: true })); - - await store.delete(CENTRAL_REFORM_ID); - - expect(fetchMock).toHaveBeenCalledWith( - `/api/reforms/${CENTRAL_REFORM_ID}`, - expect.objectContaining({ method: 'DELETE' }) + it('given parameter edits, when updated, then a new v1 policy is made and the row repointed', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore(); + materializePolicy.mockResolvedValue(REPLACEMENT_POLICY_ID); + fetchMock.mockResolvedValue( + itemResponse({ ...reformRow, name: 'Renamed', legacy_policy_id: REPLACEMENT_POLICY_ID }) ); - expect(requestHeaders()[EDIT_TOKEN_HEADER]).toBe(EDIT_TOKEN); - expect(findOwnedItem('reforms', CENTRAL_REFORM_ID)).toBeNull(); - }); + const parameters = [ + { + name: 'gov.irs.credits.ctc.amount.base[0]', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 4000 }], + }, + ]; + + // When + const updated = await store.update(ASSOCIATION_IDS.REFORM, { label: 'Renamed', parameters }); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('PATCH'); + expect(call.path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.REFORM}`); + expect(call.body).toEqual({ name: 'Renamed', legacy_policy_id: REPLACEMENT_POLICY_ID }); + expect(updated.parameters).toEqual(parameters); + expect(updated.policyId).toBe(String(REPLACEMENT_POLICY_ID)); + }); + + it('given only a rename, when updated, then no policy is made and only the name is patched', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse({ ...reformRow, name: 'Renamed' })); + + // When + await store.update(ASSOCIATION_IDS.REFORM, { label: 'Renamed' }); + + // Then + expect(materializePolicy).not.toHaveBeenCalled(); + expect(fetchCall(fetchMock).body).toEqual({ name: 'Renamed' }); + }); + + it.each([ + ['read', (store: ApiReformStore, id: string) => store.findById(id)], + ['renamed', (store: ApiReformStore, id: string) => store.update(id, { label: 'Renamed' })], + [ + 'edited', + (store: ApiReformStore, id: string) => + store.update(id, { parameters: mockReformInput.parameters }), + ], + ['deleted', (store: ApiReformStore, id: string) => store.delete(id)], + ])( + 'given a reform saved only in this browser, when %s through the API, then it is refused', + async (_, operate) => { + // Given + const local = await new LocalStorageReformStore({ + materializePolicy: async () => MATERIALIZED_POLICY_ID, + }).create(mockReformInput); + const { store, fetchMock, materializePolicy } = apiReformStore(); + + // When / Then: refused before any v1 policy is made for it + await expect(operate(store, local.id!)).rejects.toThrow( + 'This was saved only in this browser, so it cannot be opened here.' + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(materializePolicy).not.toHaveBeenCalled(); + expect(await new LocalStorageReformStore().findById(local.id!)).not.toBeNull(); + } + ); + + it('given one reform whose v1 policy fails to load, when listed, then the others still list', async () => { + // Given + const { store, fetchMock, fetchPolicyJson } = apiReformStore(); + const unloadable = policyItem({ + ...reformRow, + id: '66666666-6666-4666-8666-666666666666', + legacy_policy_id: REPLACEMENT_POLICY_ID, + name: 'Unloadable', + }); + fetchMock.mockResolvedValue(pageResponse([reformRow, unloadable])); + fetchPolicyJson.mockImplementation(async (_countryId: string, policyId: string) => { + if (policyId === String(REPLACEMENT_POLICY_ID)) { + throw new Error('Failed to fetch policy 95800'); + } + return POLICY_JSON; + }); - it('given a failed delete, when deleting, then the reform stays in the index', async () => { - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform]); - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.INTERNAL_SERVER_ERROR)); + // When + const reforms = await store.findMine('us'); - await expect(store.delete(CENTRAL_REFORM_ID)).rejects.toThrow('Failed to delete reform'); - expect(findOwnedItem('reforms', CENTRAL_REFORM_ID)).not.toBeNull(); + // Then + expect(reforms).toHaveLength(2); + expect(reforms[0].parameters).toHaveLength(1); + expect(reforms[0].parametersUnavailable).toBeUndefined(); + expect(reforms[1]).toMatchObject({ + label: 'Unloadable', + policyId: String(REPLACEMENT_POLICY_ID), + parameters: [], + parametersUnavailable: true, + }); }); -}); - -describe('LocalStorageReformStore', () => { - let store: LocalStorageReformStore; - beforeEach(() => { - store = new LocalStorageReformStore(); - localStorage.clear(); - }); + it('given a UK reform while the page is in the US, when created, then it is written in the UK', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore('us'); + fetchMock.mockResolvedValue(itemResponse({ ...reformRow, country_id: 'uk' }, 201)); - it('given a reform, when created, then it is listed as mine and retrievable by id', async () => { - const created = await store.create(mockNewReform); + // When + await store.create({ ...mockReformInput, countryId: 'uk' }); - expect(created.id).toMatch(/^rf-/); - expect(await store.findMine('us')).toHaveLength(1); - expect((await store.findById(created.id!))?.label).toBe(mockNewReform.label); + // Then + expect(materializePolicy).toHaveBeenCalledWith('uk', mockReformInput.parameters); + const call = fetchCall(fetchMock); + expect(call.query).toEqual({ country_id: 'uk' }); + expect(call.body.country_id).toBe('uk'); }); - it('given reforms from two countries, when filtering by country, then only matches are returned', async () => { - await store.create(mockNewReform); - await store.create({ ...mockNewReform, countryId: 'uk' }); + it('given a reform from a country without accounts, when created, then it is written in the page country', async () => { + // Given + const { store, fetchMock } = apiReformStore('uk'); + fetchMock.mockResolvedValue(itemResponse({ ...reformRow, country_id: 'uk' }, 201)); - const usOnly = await store.findMine('us'); + // When + await store.create({ ...mockReformInput, countryId: 'ca' }); - expect(usOnly).toHaveLength(1); - expect(usOnly[0].countryId).toBe('us'); + // Then + expect(fetchCall(fetchMock).body.country_id).toBe('uk'); }); - it('given a reform saved under the old shared user id, when finding mine, then it is listed', async () => { - localStorage.setItem(LEGACY_REFORMS_KEY, JSON.stringify([legacyLocalReform])); + it('given a reform, when deleted, then its row is deleted', async () => { + // Given + const { store, fetchMock } = apiReformStore(); + fetchMock.mockResolvedValue(noContentResponse()); - const mine = await store.findMine('us'); + // When + await store.delete(ASSOCIATION_IDS.REFORM); - expect(mine.map((reform) => reform.id)).toEqual([legacyLocalReform.id]); + // Then + expect(fetchCall(fetchMock).method).toBe('DELETE'); }); +}); - it('given an existing reform, when updated, then fields change', async () => { - const created = await store.create(mockNewReform); - - const updated = await store.update(created.id!, { label: 'Renamed' }); - - expect(updated.label).toBe('Renamed'); +describe('parametersFromPolicyJson', () => { + it('given no metadata, then the stored values are used as-is', () => { + expect(parametersFromPolicyJson({ 'gov.example': { '2026-01-01.2100-12-31': true } })).toEqual([ + { + name: 'gov.example', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: true }], + }, + ]); }); +}); - it('given a missing reform, when updated, then throws', async () => { - await expect(store.update('rf-missing', { label: 'x' })).rejects.toThrow( - 'Failed to update reform' - ); +describe('getReformStore', () => { + it('given no account, then reforms stay in localStorage', () => { + expect(getReformStore(null)).toBeInstanceOf(LocalStorageReformStore); }); - it('given an existing reform, when deleted, then it is no longer retrievable', async () => { - const created = await store.create(mockNewReform); - - await store.delete(created.id!); - - expect(await store.findById(created.id!)).toBeNull(); + it('given an account, then reforms go to the account', () => { + expect(getReformStore(createTestApiContext().context)).toBeInstanceOf(ApiReformStore); }); }); -describe('ResilientReformStore', () => { - let store: ResilientReformStore; +describe('LocalStorageReformStore', () => { + let store: LocalStorageReformStore; + let materializePolicy: ReturnType<typeof vi.fn>; beforeEach(() => { - vi.stubGlobal('fetch', fetchMock); - fetchMock.mockReset(); + materializePolicy = vi.fn().mockResolvedValue(MATERIALIZED_POLICY_ID); + store = new LocalStorageReformStore({ materializePolicy }); localStorage.clear(); - resetReformStoreAvailabilityForTesting(); - store = new ResilientReformStore(); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); + it('given a reform, when created, then it is retrievable by user and by id', async () => { + const created = await store.create(mockReformInput); - it('given the API is healthy, when creating, then the central store is used', async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse(mockReformCreatedMetadata, HTTP_STATUS.CREATED) - ); + expect(created.id).toBeDefined(); + expect(created.createdAt).toBeDefined(); - const result = await store.create(mockNewReform); + const mine = await store.findMine('us'); + expect(mine).toHaveLength(1); - expect(result.id).toBe(CENTRAL_REFORM_ID); - expect(await new LocalStorageReformStore().findMine()).toHaveLength(0); + const byId = await store.findById(created.id!); + expect(byId?.label).toBe('CTC to $3,600 for children under 6'); }); - it('given the API returns 503, when creating, then it falls back to localStorage', async () => { - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE)); - - const result = await store.create(mockNewReform); + it('given a reform, when created, then its v1 policy is created and its ID kept in this browser', async () => { + const created = await store.create(mockReformInput); - expect(result.id).toMatch(/^rf-/); - expect(await new LocalStorageReformStore().findMine()).toHaveLength(1); - expect(readOwnedItems('reforms')).toEqual([]); + expect(materializePolicy).toHaveBeenCalledWith('us', mockReformInput.parameters); + expect(created.policyId).toBe(String(MATERIALIZED_POLICY_ID)); + expect((await store.findById(created.id!))?.policyId).toBe(String(MATERIALIZED_POLICY_ID)); }); - it('given a prior 503, when listing later, then localStorage is used without retrying the API', async () => { - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform]); - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE)); - await store.create(mockNewReform); - fetchMock.mockClear(); - - const reforms = await store.findMine('us'); + it('given a reform that already has a v1 policy, when created, then no new policy is made', async () => { + const created = await store.create({ ...mockReformInput, policyId: '501' }); - expect(reforms).toHaveLength(1); - expect(fetchMock).not.toHaveBeenCalled(); + expect(materializePolicy).not.toHaveBeenCalled(); + expect(created.policyId).toBe('501'); }); - it('given the collection endpoint is absent (404), when listing, then the local reforms are still listed', async () => { - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform]); - localStorage.setItem(LEGACY_REFORMS_KEY, JSON.stringify([legacyLocalReform])); - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.NOT_FOUND)); - - const reforms = await store.findMine('us'); + it('given the v1 policy cannot be created, when created, then the save fails and nothing is stored', async () => { + materializePolicy.mockRejectedValue(new Error('Failed to create policy')); - expect(reforms.map((reform) => reform.id)).toEqual([legacyLocalReform.id]); + await expect(store.create(mockReformInput)).rejects.toThrow('Failed to create policy'); + expect(await store.findMine()).toEqual([]); }); - it('given central and pre-existing local reforms, when listing, then both appear, most recently updated first', async () => { - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform]); - localStorage.setItem(LEGACY_REFORMS_KEY, JSON.stringify([legacyLocalReform])); - fetchMock.mockResolvedValueOnce( - mockJsonResponse([{ ...mockReformMetadata, updated_at: '2026-10-01T00:00:00.000Z' }]) - ); - - const reforms = await store.findMine('us'); + it('given reforms from two countries, when filtering by country, then only matches are returned', async () => { + await store.create(mockReformInput); + await store.create({ ...mockReformInput, countryId: 'uk' }); - expect(reforms.map((reform) => reform.id)).toEqual([CENTRAL_REFORM_ID, legacyLocalReform.id]); + const usOnly = await store.findMine('us'); + expect(usOnly).toHaveLength(1); + expect(usOnly[0].countryId).toBe('us'); }); - it('given a pre-existing local reform, when updating while the API is healthy, then the edit stays local', async () => { - localStorage.setItem(LEGACY_REFORMS_KEY, JSON.stringify([legacyLocalReform])); + it('given an existing reform, when renamed, then the label changes and the v1 policy stays', async () => { + const created = await store.create(mockReformInput); + materializePolicy.mockClear(); - const updated = await store.update(legacyLocalReform.id, { label: 'Renamed' }); + const updated = await store.update(created.id!, { label: 'Renamed' }); expect(updated.label).toBe('Renamed'); - expect(fetchMock).not.toHaveBeenCalled(); + expect(updated.policyId).toBe(String(MATERIALIZED_POLICY_ID)); + expect(materializePolicy).not.toHaveBeenCalled(); }); - it('given a pre-existing local reform, when deleting, then it is removed locally without a request', async () => { - localStorage.setItem(LEGACY_REFORMS_KEY, JSON.stringify([legacyLocalReform])); + it('given an existing reform, when its parameters change, then it points at a new v1 policy', async () => { + const created = await store.create(mockReformInput); + materializePolicy.mockResolvedValue(REPLACEMENT_POLICY_ID); + const parameters = [ + { + name: 'gov.irs.credits.ctc.amount.base[0]', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 4000 }], + }, + ]; - await store.delete(legacyLocalReform.id); + const updated = await store.update(created.id!, { parameters }); - expect(await new LocalStorageReformStore().findById(legacyLocalReform.id)).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); + expect(materializePolicy).toHaveBeenLastCalledWith('us', parameters); + expect(updated.policyId).toBe(String(REPLACEMENT_POLICY_ID)); + expect((await store.findById(created.id!))?.parameters).toEqual(parameters); }); - it('given the API is busy (429), when creating, then that reform is saved locally and the next create tries the API again', async () => { - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.TOO_MANY_REQUESTS)); - - const busySave = await store.create(mockNewReform); + it('given the new v1 policy cannot be created, when updated, then the stored reform is unchanged', async () => { + const created = await store.create(mockReformInput); + materializePolicy.mockRejectedValue(new Error('Failed to create policy')); - expect(busySave.id).toMatch(/^rf-/); - expect(await new LocalStorageReformStore().findMine()).toHaveLength(1); - - fetchMock.mockResolvedValueOnce( - mockJsonResponse(mockReformCreatedMetadata, HTTP_STATUS.CREATED) + await expect(store.update(created.id!, { parameters: [] })).rejects.toThrow( + 'Failed to create policy' ); - const nextSave = await store.create(mockNewReform); - - expect(nextSave.id).toBe(CENTRAL_REFORM_ID); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(await store.findById(created.id!)).toEqual(created); }); - it('given a real server error (500), when creating, then it surfaces instead of falling back', async () => { - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.INTERNAL_SERVER_ERROR)); - - await expect(store.create(mockNewReform)).rejects.toThrow('Failed to create reform'); - expect(await new LocalStorageReformStore().findMine()).toHaveLength(0); + it('given a missing reform, when updated, then it says the reform is not here', async () => { + await expect(store.update('rf-missing', { label: 'x' })).rejects.toBeInstanceOf( + ReformNotFoundError + ); + expect(materializePolicy).not.toHaveBeenCalled(); }); - it('given a fallback save then update and delete keep working against localStorage', async () => { - fetchMock.mockResolvedValueOnce(mockErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE)); - const created = await store.create(mockNewReform); - - const updated = await store.update(created.id!, { label: 'Renamed' }); - expect(updated.label).toBe('Renamed'); + it('given an existing reform, when deleted, then it is no longer retrievable', async () => { + const created = await store.create(mockReformInput); await store.delete(created.id!); + expect(await store.findById(created.id!)).toBeNull(); }); }); diff --git a/app/src/tests/unit/api/sharedReport.test.ts b/app/src/tests/unit/api/sharedReport.test.ts new file mode 100644 index 000000000..6721fd996 --- /dev/null +++ b/app/src/tests/unit/api/sharedReport.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest'; +import { fetchSharedReport } from '@/api/sharedReport'; +import { isV2ApiError } from '@/api/v2UserClient'; +import { + ASSOCIATION_IDS, + createTestPublicClient, + errorResponse, + fetchCall, + itemResponse, + sharedReportItem, +} from '@/tests/fixtures/api/v2UserMocks'; + +describe('fetchSharedReport', () => { + test('given a report link then it reads the saved report with no credential', async () => { + // Given + const { fetchMock, client } = createTestPublicClient(); + fetchMock.mockResolvedValue(itemResponse(sharedReportItem())); + + // When + const item = await fetchSharedReport('us', ASSOCIATION_IDS.REPORT, client); + + // Then + expect(item?.name).toBe('CTC to $3,600'); + const call = fetchCall(fetchMock); + expect(call.method).toBe('GET'); + expect(call.path).toBe(`/v2/shared-reports/${ASSOCIATION_IDS.REPORT}`); + expect(call.query).toEqual({ country_id: 'us' }); + expect(call.headers).toEqual({ Accept: 'application/json' }); + }); + + test('given no saved report has the ID then it answers null', async () => { + // Given + const { fetchMock, client } = createTestPublicClient(); + fetchMock.mockResolvedValue(errorResponse(404, 'not found')); + + // When / Then + await expect(fetchSharedReport('uk', ASSOCIATION_IDS.REPORT, client)).resolves.toBeNull(); + }); + + test('given the API is unavailable then the read fails', async () => { + // Given + const { fetchMock, client } = createTestPublicClient(); + fetchMock.mockResolvedValue(errorResponse(503, 'V2 user persistence is unavailable')); + + // When + const failure = await fetchSharedReport('us', ASSOCIATION_IDS.REPORT, client).catch( + (error: unknown) => error + ); + + // Then + expect(isV2ApiError(failure, 503)).toBe(true); + }); + + test('given a country without saved reports then it answers null without asking', async () => { + // Given + const { fetchMock, client } = createTestPublicClient(); + + // When / Then + await expect(fetchSharedReport('ca', ASSOCIATION_IDS.REPORT, client)).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/api/v2UserClient.test.ts b/app/src/tests/unit/api/v2UserClient.test.ts new file mode 100644 index 000000000..4d3d02dc1 --- /dev/null +++ b/app/src/tests/unit/api/v2UserClient.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test, vi } from 'vitest'; +import { fetchMe } from '@/api/v2Me'; +import { + createV2UserClient, + isV2ApiError, + UNAUTHENTICATED_USER_HEADER, + V2ApiError, +} from '@/api/v2UserClient'; +import { + BROWSER_USER_ID, + createTestApiContext, + errorResponse, + fetchCall, + itemResponse, + mockMeItem, + noContentResponse, + okResponse, + pageResponse, + policyItem, + TEST_ACCESS_TOKEN, + TEST_API_BASE, +} from '@/tests/fixtures/api/v2UserMocks'; + +describe('v2UserClient', () => { + test('given a request then it carries the bearer token to /v2 on the API base', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(mockMeItem)); + + // When + const me = await fetchMe(context.client, 'us'); + + // Then + const call = fetchCall(fetchMock); + expect(call.url.origin + call.path).toBe(`${TEST_API_BASE}/v2/me`); + expect(call.query).toEqual({ country_id: 'us' }); + expect(call.method).toBe('GET'); + expect(call.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(me).toEqual(mockMeItem); + }); + + test('given a body then it is sent as JSON', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(policyItem(), 201)); + + // When + await context.client.request('POST', 'user-policies', { + query: { country_id: 'us' }, + body: { country_id: 'us', legacy_policy_id: 1 }, + }); + + // Then + const call = fetchCall(fetchMock); + expect(call.headers['Content-Type']).toBe('application/json'); + expect(call.body).toEqual({ country_id: 'us', legacy_policy_id: 1 }); + }); + + test('given an error envelope then it throws a typed error with status and message', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(errorResponse(422, 'legacy_policy_id 9 does not exist')); + + // When + const failure = context.client + .request('GET', 'user-policies') + .catch((caught: unknown) => caught); + + // Then + const error = (await failure) as V2ApiError; + expect(error).toBeInstanceOf(V2ApiError); + expect(error.status).toBe(422); + expect(error.message).toBe('legacy_policy_id 9 does not exist'); + expect(isV2ApiError(error, 422)).toBe(true); + }); + + test.each([ + [422, /not valid, or something it refers to may no longer exist/], + [409, /conflicts with what is already saved/], + [413, /too much data/i], + ])( + 'given a %s without a message then the fallback does not claim a single cause', + async (status, expected) => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(new Response('', { status })); + + // When / Then + await expect(context.client.request('POST', 'user-policies')).rejects.toMatchObject({ + status, + message: expect.stringMatching(expected), + }); + } + ); + + test('given an error without a body then the message describes the status', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(new Response('', { status: 503 })); + + // When / Then + await expect(context.client.request('GET', 'me')).rejects.toMatchObject({ + status: 503, + message: 'Saved work is temporarily unavailable.', + }); + }); + + test('given 204 then the result is undefined', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(noContentResponse()); + + // When + const result = await context.client.request('DELETE', 'me', { query: { country_id: 'us' } }); + + // Then + expect(result).toBeUndefined(); + }); + + test('given no access token then it fails with 401 without calling the API', async () => { + // Given + const fetchMock = vi.fn(); + const client = createV2UserClient({ + credentials: { kind: 'session', getAccessToken: async () => null }, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When / Then + await expect(client.request('GET', 'me')).rejects.toMatchObject({ status: 401 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given the token request itself fails then it says sign-in could not be reached', async () => { + // Given + const fetchMock = vi.fn(); + const client = createV2UserClient({ + credentials: { + kind: 'session', + getAccessToken: async () => { + throw new Error('timeout'); + }, + }, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When / Then + await expect(client.request('GET', 'me')).rejects.toMatchObject({ + status: 0, + message: 'Could not reach the sign-in service. Check your connection and try again.', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given an unauthenticated browser user then requests name it, with no bearer token', async () => { + // Given + const fetchMock = vi.fn().mockResolvedValue(pageResponse([])); + const client = createV2UserClient({ + credentials: { kind: 'unauthenticated', userId: BROWSER_USER_ID }, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When + await client.request('GET', 'user-policies', { query: { country_id: 'us' } }); + + // Then + const { headers } = fetchCall(fetchMock, 0); + expect(headers[UNAUTHENTICATED_USER_HEADER]).toBe(BROWSER_USER_ID); + expect(headers.Authorization).toBeUndefined(); + }); + + test('given extra headers then they go with the session token', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(okResponse({})); + + // When + await context.client.request('POST', 'me/upgrade', { + headers: { [UNAUTHENTICATED_USER_HEADER]: BROWSER_USER_ID }, + }); + + // Then + const { headers } = fetchCall(fetchMock, 0); + expect(headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(headers[UNAUTHENTICATED_USER_HEADER]).toBe(BROWSER_USER_ID); + }); + + test('given a network failure then it throws status 0', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + // When / Then + await expect(context.client.request('GET', 'me')).rejects.toMatchObject({ status: 0 }); + }); + + test('given several pages then listAll reads until has_more is false', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const first = policyItem({ id: 'a' }); + const second = policyItem({ id: 'b' }); + fetchMock + .mockResolvedValueOnce(pageResponse([first], { hasMore: true })) + .mockResolvedValueOnce(pageResponse([second], { offset: 1 })); + + // When + const items = await context.client.listAll('user-policies', { country_id: 'uk' }); + + // Then + expect(items).toEqual([first, second]); + expect(fetchCall(fetchMock, 0).query).toEqual({ country_id: 'uk', offset: '0', limit: '500' }); + expect(fetchCall(fetchMock, 1).query).toEqual({ country_id: 'uk', offset: '1', limit: '500' }); + }); +}); diff --git a/app/src/tests/unit/components/auth/AuthMenu.test.tsx b/app/src/tests/unit/components/auth/AuthMenu.test.tsx new file mode 100644 index 000000000..f8a013da0 --- /dev/null +++ b/app/src/tests/unit/components/auth/AuthMenu.test.tsx @@ -0,0 +1,99 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import AuthMenu from '@/components/auth/AuthMenu'; +import FlagshipTopBar from '@/components/flagship/FlagshipTopBar'; +import HeaderActionButtons from '@/components/homeHeader/HeaderActionButtons'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, + TEST_PROFILE, +} from '@/tests/fixtures/libs/auth/authMocks'; + +function renderMenu(auth: AuthContextValue, countryId = 'us') { + return render( + <AuthTestProvider value={auth}> + <AuthMenu /> + </AuthTestProvider>, + countryId + ); +} + +describe('legacy calculator pages', () => { + test('given the legacy header then it has no sign-in control', () => { + // When + render(<HeaderActionButtons />); + + // Then + expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /account menu/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /country selector/i })).toBeInTheDocument(); + }); + + test('given the v3 top bar outside the workspace (no auth provider) then it has no sign-in control', () => { + // When + render(<FlagshipTopBar />); + + // Then + expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /account menu/i })).not.toBeInTheDocument(); + }); +}); + +describe('AuthMenu', () => { + test('given sign-in is disabled then nothing is shown', () => { + // When + renderMenu(makeAuthValue({ status: 'disabled' })); + + // Then + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('given a country without accounts then nothing is shown, signed in or out', () => { + // When + renderMenu(makeAuthValue(), 'ca'); + + // Then + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('given a signed-out visitor then "Sign in" starts sign-in', async () => { + // Given + const user = userEvent.setup(); + const auth = makeAuthValue(); + renderMenu(auth); + + // When + await user.click(screen.getByRole('button', { name: 'Sign in' })); + + // Then + expect(auth.signIn).toHaveBeenCalled(); + expect(screen.queryByText(/your session ended/i)).not.toBeInTheDocument(); + }); + + test('given the session ended on its own then the menu invites signing in again', () => { + // When + renderMenu(makeAuthValue({ sessionEnded: true })); + + // Then + expect(screen.getByText(/your session ended/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument(); + }); + + test('given a signed-in user then the menu shows the ID-token identity, My account and Sign out', async () => { + // Given + const user = userEvent.setup(); + const auth = signedInAuthValue(); + renderMenu(auth); + + // When + await user.click(screen.getByRole('button', { name: /account menu for ada lovelace/i })); + + // Then + expect(screen.getByText(TEST_PROFILE.email)).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /my account/i })).toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: /sign out/i })); + expect(auth.signOut).toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx b/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx index 961e7ef15..feebc7e81 100644 --- a/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx +++ b/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx @@ -25,12 +25,15 @@ describe('FlagshipTopBar', () => { clearDraftReform(); }); - test('given the bar renders then both sections are in the primary navigation', () => { + test('given the bar renders then both sections and the more menu are in the primary navigation', () => { renderTopBar(); const primary = screen.getByRole('navigation', { name: 'Primary' }); expect(primary).toContainElement(screen.getByRole('button', { name: 'Build' })); expect(primary).toContainElement(screen.getByRole('button', { name: 'Reforms' })); + expect(primary).toContainElement( + screen.getByRole('button', { name: /more policyengine links/i }) + ); }); test('given the Ask page is paused then it has no nav item', () => { diff --git a/app/src/tests/unit/components/flagship/ReformReports.test.tsx b/app/src/tests/unit/components/flagship/ReformReports.test.tsx index 531520027..eab542648 100644 --- a/app/src/tests/unit/components/flagship/ReformReports.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformReports.test.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, within } from '@test-utils'; +import { act, render, screen, userEvent, waitFor, within } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { fetchSimulationById } from '@/api/simulation'; import ReformReports from '@/components/flagship/ReformReports'; import { saveReportMeta } from '@/libs/flagship/runReport'; import { clearMetadata } from '@/reducers/metadataReducer'; @@ -10,13 +11,35 @@ import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draf vi.mock('@/api/report', () => ({ fetchReportById: vi.fn(async (_country: string, id: string) => ({ - status: 'complete', + // Report 62 is still computing; the rest are done. + status: id === '62' ? 'pending' : 'complete', + // Report 61 is an account report's: the row keeps no year, the report does. + year: id === '61' ? '2028' : '2026', finished_at: '2026-10-14T15:12:00Z', // Report 60 ran on the model the app has loaded ("test", from the seed). output: id === '60' ? JSON.stringify({ model_version: 'test' }) : null, + simulation_1_id: `sim-${id}`, })), })); +vi.mock('@/api/simulation', () => ({ + // Report 63's baseline ran a reform (policy 88); every other report's ran + // current law (2, from the seed). + fetchSimulationById: vi.fn(async (_country: string, simulationId: string) => ({ + policy_id: simulationId === 'sim-63' ? '88' : '2', + })), +})); + +const mockPush = vi.fn(); + +vi.mock('@/contexts/NavigationContext', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/contexts/NavigationContext')>(); + return { ...actual, useAppNavigate: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn() }) }; +}); + +/** An account row's ID: the report's shareable link. */ +const REPORT_ROW_ID = '44444444-4444-4444-8444-444444444444'; + const record = (overrides: Partial<FlagshipReportRecord>): FlagshipReportRecord => ({ id: 'rec-1', countryId: 'us', @@ -39,12 +62,13 @@ const META = { function renderReports( reports: FlagshipReportRecord[], - onRerun?: (report: FlagshipReportRecord) => void + onRerun?: (report: FlagshipReportRecord) => void, + reformName?: (reformId: string) => string | undefined ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( <QueryClientProvider client={queryClient}> - <ReformReports countryId="us" reports={reports} onRerun={onRerun} /> + <ReformReports countryId="us" reports={reports} onRerun={onRerun} reformName={reformName} /> </QueryClientProvider> ); } @@ -52,6 +76,77 @@ function renderReports( describe('ReformReports', () => { beforeEach(() => { localStorage.clear(); + mockPush.mockReset(); + }); + + test('given an unfinished account report then Open follows its saved report link, not the v1 report', async () => { + // Given + const user = userEvent.setup(); + renderReports([record({ id: REPORT_ROW_ID, apiReportId: '62' })]); + + // When: a report still computing reads Open + await user.click(screen.getByRole('button', { name: /^open/i })); + + // Then + expect(mockPush).toHaveBeenCalledWith(`/us/report/${REPORT_ROW_ID}`); + }); + + test('given a finished account report then View results follows its saved report link', async () => { + // Given + const user = userEvent.setup(); + renderReports([record({ id: REPORT_ROW_ID, apiReportId: '55' })]); + + // When + await user.click(await screen.findByRole('button', { name: /view results/i })); + + // Then + expect(mockPush).toHaveBeenCalledWith(`/us/report/${REPORT_ROW_ID}`); + }); + + test('given a finished report kept only in this browser then View results follows the v1 report', async () => { + // Given + const user = userEvent.setup(); + renderReports([record({ id: 'lfr-local1', apiReportId: '55' })]); + + // When + await user.click(await screen.findByRole('button', { name: /view results/i })); + + // Then + expect(mockPush).toHaveBeenCalledWith('/us/report/55'); + }); + + test('given an account report with no year of its own then it reads the year from the API', async () => { + // Given: the account row keeps no year; the v1 report carries it + renderReports([record({ id: REPORT_ROW_ID, apiReportId: '61', year: undefined })]); + + // Then + expect(await screen.findByText('2028 report')).toBeInTheDocument(); + }); + + test('given an account report compared with a saved reform after a reload then it names that reform', () => { + // Given: no run details in this page session, only the row's link + renderReports( + [record({ id: REPORT_ROW_ID, apiReportId: '55', baselineReformId: 'rf-plan-b' })], + undefined, + (reformId) => (reformId === 'rf-plan-b' ? 'Plan B' : undefined) + ); + + // Then + expect(screen.getByText('Compared with Plan B')).toBeInTheDocument(); + }); + + test('given an account report compared with a saved reform then it reads so from its link', () => { + // Given: a run this session made keeps its details under the report's link + saveReportMeta(REPORT_ROW_ID, { + ...META, + baseline: { reformId: 'rf-plan-b', policyId: '88', label: 'Plan B' }, + }); + + // When + renderReports([record({ id: REPORT_ROW_ID, apiReportId: '55' })]); + + // Then + expect(screen.getByText('Compared with Plan B')).toBeInTheDocument(); }); test('given the same reform run against current law and a saved reform then the two read apart', () => { @@ -111,3 +206,44 @@ describe('ReformReports', () => { store.dispatch(clearMetadata()); }); }); + +describe('ReformReports comparisons without a link', () => { + beforeEach(() => { + localStorage.clear(); + store.dispatch(clearMetadata()); + seedDeductionMetadata(); + }); + + test('given a report whose compared reform is gone then it still reads as compared', async () => { + // Given: no run details and no link, but its baseline ran another reform + renderReports([record({ id: 'rec-63', apiReportId: '63', baselineReformId: null })]); + + // Then + expect(await screen.findByText('Compared with a saved reform')).toBeInTheDocument(); + }); + + test('given a report whose baseline ran current law then no comparison shows', async () => { + // Given + renderReports([record({ id: 'rec-64', apiReportId: '64', baselineReformId: null })]); + + // Then + expect(await screen.findByText('2026 report')).toBeInTheDocument(); + expect(screen.queryByText(/Compared with/)).not.toBeInTheDocument(); + }); + + test('given metadata still loading then a current-law report shows no comparison', async () => { + // Given: current law reads 0 until the model's metadata loads + store.dispatch(clearMetadata()); + renderReports([record({ id: 'rec-64', apiReportId: '64', baselineReformId: null })]); + + // When: its baseline simulation has been read + await waitFor(() => expect(fetchSimulationById).toHaveBeenCalledWith('us', 'sim-64')); + await act(async () => { + await vi.mocked(fetchSimulationById).mock.results.at(-1)?.value; + }); + + // Then + expect(screen.getByText('2026 report')).toBeInTheDocument(); + expect(screen.queryByText(/Compared with/)).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx index 9b1309ba2..0a56d403a 100644 --- a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx @@ -377,6 +377,25 @@ describe('ReportAdjustPanel', () => { expect(reformId).toBe('rf-existing'); }); + test('given a saved reform whose changes could not load then it is skipped, not matched or fatal', async () => { + mockFindMine.mockResolvedValue([ + { id: 'rf-unloaded', label: 'Unloaded', parameters: [], parametersUnavailable: true }, + ]); + mockCreate.mockResolvedValue({ id: 'rf-new', label: 'CTC expansion (adjusted)' }); + const user = userEvent.setup(); + await renderPanel(); + + // Removing every provision but one leaves a set an empty reform must not match + const input = screen.getByLabelText(/new value for maximum credit per child/i); + await user.clear(input); + await user.type(input, '3600'); + await user.click(screen.getByRole('button', { name: /recompute/i })); + + await waitFor(() => expect(mockRun).toHaveBeenCalled()); + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(mockRun.mock.calls[0][3]).toBe('rf-new'); + }); + test('given a provision is removed then recompute runs without it', async () => { mockCreate.mockResolvedValue({ id: 'rf-new', label: 'CTC expansion (adjusted)' }); const user = userEvent.setup(); @@ -405,6 +424,23 @@ describe('ReportAdjustPanel', () => { expect(screen.getByLabelText(/new value for maximum credit per child/i)).toBeInTheDocument(); }); + test('given every value is back at current law then recompute waits, and says why', async () => { + // Given + const user = userEvent.setup(); + await renderPanel(); + + // When: one provision removed, the other set back to its current-law value + await user.click(screen.getByRole('button', { name: /remove maximum credit per child/i })); + const allotment = screen.getByLabelText(/new value for maximum allotment/i); + await user.clear(allotment); + await user.type(allotment, '300'); + + // Then + expect(screen.getByRole('button', { name: /recompute/i })).toBeDisabled(); + expect(screen.getByText(/nothing differs from current law/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + test('given the baseline policy could not be loaded then recompute is off and says why', async () => { const user = userEvent.setup(); await renderPanel({ baselineUnknown: true }); diff --git a/app/src/tests/unit/components/flagship/ReportComputing.test.tsx b/app/src/tests/unit/components/flagship/ReportComputing.test.tsx index 8623a5d61..14019f917 100644 --- a/app/src/tests/unit/components/flagship/ReportComputing.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportComputing.test.tsx @@ -76,10 +76,11 @@ describe('ReportComputingScreen', () => { }); describe('ReportUnresolvable', () => { - test('given a local-only link then the page says so and how to get a shareable one', () => { + test('given an old link then the page says the report is not found and how to get a link', () => { render(<ReportUnresolvable />); - expect(screen.getByText(/only opens in the browser that ran the report/i)).toBeInTheDocument(); - expect(screen.getByText(/shareable id/i)).toBeInTheDocument(); + expect(screen.getByText('Report not found.')).toBeInTheDocument(); + expect(screen.getByText(/old report link, which no longer opens/i)).toBeInTheDocument(); + expect(screen.getByText(/run the reform again from build/i)).toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx b/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx index 215e147db..90eb7d8a9 100644 --- a/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx +++ b/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx @@ -1,6 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render, screen, userEvent, waitFor, within } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ReformNotFoundError } from '@/api/reformStore'; +import { BrowserOnlyIdError } from '@/api/v2Associations'; +import { V2ApiError } from '@/api/v2UserClient'; import DraftHeader, { DraftRunBar } from '@/components/flagship/reform/DraftHeader'; import { addDraftProvision, @@ -62,7 +65,11 @@ describe('DraftHeader', () => { vi.clearAllMocks(); mockFindMine.mockResolvedValue([]); mockCreate.mockResolvedValue({ id: 'rf-new' }); - mockUpdate.mockResolvedValue({}); + // The store answers the reform as updated, as the real stores do. + mockUpdate.mockImplementation(async (reformId: string, changes: Partial<Reform>) => ({ + id: reformId, + ...changes, + })); clearDraftReform(); startDraftReform('us', 'chat', 'ask-keyword-v0'); addDraftProvision('us', { @@ -99,9 +106,10 @@ describe('DraftHeader', () => { .getAllByRole('button') .map((button) => button.textContent?.trim()) .filter( - (text) => text === 'Discard' || text === 'Save' || /^Run 2026 report$/.test(text ?? '') + (text) => + text === 'Discard' || text === 'Save' || /^Save and run 2026 report$/.test(text ?? '') ); - expect(verbs).toEqual(['Discard', 'Save', 'Run 2026 report']); + expect(verbs).toEqual(['Discard', 'Save', 'Save and run 2026 report']); // It stays in view at the bottom of the window while the reform is long. expect(bar).toHaveStyle({ position: 'sticky', bottom: '0px' }); }); @@ -122,12 +130,14 @@ describe('DraftHeader', () => { baselineValue: 600, value: 900, }); + mockCreate.mockResolvedValue({ id: 'rf-1' }); renderHeader(); // The run leaves the unchanged one out without saying so. expect(screen.queryByText(/left out of the report/)).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /run 2026 report/i })); + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); const provisions = mockRun.mock.calls[0][2]; expect(provisions.map((provision: { path: string }) => provision.path)).toEqual([ 'gov.irs.credits.eitc.max', @@ -220,6 +230,40 @@ describe('DraftHeader', () => { expect(mockRun.mock.calls[0][0]).toBe('Untitled reform'); }); + test('given an edit while the save is in flight then the run is what was saved', async () => { + // Given: the save waits until released + const user = userEvent.setup(); + addDraftProvision('us', CHANGED); + let releaseSave!: (reform: Reform) => void; + mockCreate.mockReturnValue( + new Promise<Reform>((resolve) => { + releaseSave = resolve; + }) + ); + renderHeader(); + + // When: the value and name change before the save answers + await user.click(screen.getByRole('button', { name: /run 2026 report/i })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + act(() => { + updateDraftProvisionValue(CHANGED.path, 1200); + setDraftLabel('Edited after pressing run'); + }); + await act(async () => releaseSave({ id: 'rf-new' } as Reform)); + + // Then: the run matches the saved reform it links to + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); + const savedValues = JSON.stringify(mockCreate.mock.calls[0][0].parameters); + expect(savedValues).toContain('900'); + expect(savedValues).not.toContain('1200'); + const ranProvision = mockRun.mock.calls[0][2].find( + (provision: { path: string }) => provision.path === CHANGED.path + ); + expect(ranProvision.value).toBe(900); + expect(mockRun.mock.calls[0][0]).toBe('Untitled reform'); + expect(mockRun.mock.calls[0][3]).toBe('rf-new'); + }); + test('then the draft is saved to Your reforms and the report links to it', async () => { const user = userEvent.setup(); addDraftProvision('us', CHANGED); @@ -323,18 +367,6 @@ describe('DraftHeader', () => { mockCreate.mock.calls[0][0].parameters.map((parameter: { name: string }) => parameter.name) ).toEqual([CHANGED.path]); }); - - test('then a failed save still runs the report, unlinked', async () => { - const user = userEvent.setup(); - mockCreate.mockRejectedValue(new Error('store down')); - addDraftProvision('us', CHANGED); - renderHeader(); - - await user.click(screen.getByRole('button', { name: /run 2026 report/i })); - - await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); - expect(mockRun.mock.calls[0][3]).toBeUndefined(); - }); }); describe('given what the report is compared with', () => { @@ -610,6 +642,187 @@ describe('DraftHeader', () => { await waitFor(() => expect(getDraftReform()).toBeNull()); }); + test.each([ + ['saved in the other sign-in state', () => new BrowserOnlyIdError()], + ['deleted from this browser', () => new ReformNotFoundError()], + ['deleted from the account', () => new V2ApiError(404, 'Reform not found.')], + ])( + 'given the reform the draft edits was %s then saving saves it as a new reform', + async (_, missingError) => { + // Given + const user = userEvent.setup(); + mockUpdate.mockRejectedValue(missingError()); + mockCreate.mockResolvedValue({ id: 'rf-new' }); + loadReformIntoDraft( + { + id: 'rf-gone', + countryId: 'us', + label: 'Saved elsewhere', + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 2500 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + }, + () => ({ breadcrumb: 'CTC', unit: 'currency-USD', baselineValue: 2000 }) + ); + renderHeader(); + + // When + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + // Then + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockUpdate).toHaveBeenCalledWith('rf-gone', expect.anything()); + expect(mockCreate.mock.calls[0][0]).toMatchObject({ label: 'Saved elsewhere' }); + await waitFor(() => expect(getDraftReform()).toBeNull()); + } + ); + + test('given another failure then saving reports it and keeps the draft', async () => { + // Given + const user = userEvent.setup(); + mockUpdate.mockRejectedValue(new V2ApiError(503, 'Saved work is temporarily unavailable.')); + loadReformIntoDraft( + { + id: 'rf-saved', + countryId: 'us', + label: 'Saved', + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 2500 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + }, + () => ({ breadcrumb: 'CTC', unit: 'currency-USD', baselineValue: 2000 }) + ); + renderHeader(); + + // When + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + // Then + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not save the reform. Try again.' + ); + expect(mockCreate).not.toHaveBeenCalled(); + expect(getDraftReform()?.editingReformId).toBe('rf-saved'); + }); + + test('given a save is pending then save and run waits, so the reform is not saved twice', async () => { + // Given + const user = userEvent.setup(); + addDraftProvision('us', { + path: 'gov.irs.credits.eitc.max', + breadcrumb: 'IRS → Credits → EITC → Maximum', + unit: 'currency-USD', + baselineValue: 600, + value: 900, + }); + mockCreate.mockReturnValue(new Promise(() => {})); + renderHeader(); + + // When + await user.click(screen.getByRole('button', { name: 'Save' })); + + // Then + expect(screen.getByRole('button', { name: 'Save and run 2026 report' })).toBeDisabled(); + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + + test('given save and run then the reform is saved first and the report is linked to it', async () => { + // Given + const user = userEvent.setup(); + addDraftProvision('us', { + path: 'gov.irs.credits.eitc.max', + breadcrumb: 'IRS → Credits → EITC → Maximum', + unit: 'currency-USD', + baselineValue: 600, + value: 900, + }); + mockCreate.mockResolvedValue({ id: 'rf-1' }); + renderHeader(); + + // When + await user.click(screen.getByRole('button', { name: 'Save and run 2026 report' })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(mockCreate.mock.invocationCallOrder[0]).toBeLessThan( + mockRun.mock.invocationCallOrder[0] + ); + const [, , , reformId, year, options] = mockRun.mock.calls[0]; + expect({ reformId, year, fromDraft: options.fromDraft }).toEqual({ + reformId: 'rf-1', + year: 2026, + fromDraft: true, + }); + // A second try changes this reform rather than saving another. + expect(getDraftReform()?.editingReformId).toBe('rf-1'); + }); + + test('given the draft came from a saved reform then save and run changes that reform', async () => { + // Given + const user = userEvent.setup(); + mockUpdate.mockResolvedValue({ id: 'rf-saved' }); + loadReformIntoDraft( + { + id: 'rf-saved', + countryId: 'us', + label: 'CTC to $3,000', + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3000 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + }, + () => ({ breadcrumb: 'CTC', unit: 'currency-USD', baselineValue: 2000 }) + ); + renderHeader(); + + // When + await user.click(screen.getByRole('button', { name: 'Save and run 2026 report' })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); + expect(mockUpdate).toHaveBeenCalledWith('rf-saved', expect.anything()); + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockRun.mock.calls[0][3]).toBe('rf-saved'); + }); + + test('given the save fails then no report runs and the error shows', async () => { + // Given + const user = userEvent.setup(); + addDraftProvision('us', { + path: 'gov.irs.credits.eitc.max', + breadcrumb: 'IRS → Credits → EITC → Maximum', + unit: 'currency-USD', + baselineValue: 600, + value: 900, + }); + mockCreate.mockRejectedValue(new Error('Failed to create policy')); + renderHeader(); + + // When + await user.click(screen.getByRole('button', { name: 'Save and run 2026 report' })); + + // Then + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not save the reform. Try again.' + ); + expect(mockRun).not.toHaveBeenCalled(); + }); + test('given save then a parameter left at current law is not saved as a freeze', async () => { const user = userEvent.setup(); addDraftProvision('us', { diff --git a/app/src/tests/unit/hooks/useBillEconomy.test.tsx b/app/src/tests/unit/hooks/useBillEconomy.test.tsx index ffdf38ba6..0be48a43e 100644 --- a/app/src/tests/unit/hooks/useBillEconomy.test.tsx +++ b/app/src/tests/unit/hooks/useBillEconomy.test.tsx @@ -53,8 +53,9 @@ describe('useBillEconomy', () => { await waitFor(() => expect(result.current.status).toBe('complete')); expect(mockCreatePolicy).toHaveBeenCalledWith('us', { data: { 'gov.states.ut.tax.income.rate': { [`${CURRENT_YEAR}-01-01.2100-12-31`]: 0.0445 } }, - label: 'Utah SB60', }); + // The policy carries only the changes; the bill's title is never sent. + expect(mockCreatePolicy.mock.calls[0][1]).not.toHaveProperty('label'); expect(mockFetchSocietyWide).toHaveBeenCalledWith('us', '98557', '2', { region: 'state/ut', time_period: CURRENT_YEAR, @@ -83,8 +84,9 @@ describe('useBillEconomy', () => { // A 2027 change scored in 2026 would show an effect the bill does not have then. expect(mockCreatePolicy).toHaveBeenCalledWith('us', { data: { 'gov.states.ut.tax.income.rate': { '2027-01-01.2100-12-31': 0.044 } }, - label: 'Utah SB60', }); + // The policy carries only the changes; the bill's title is never sent. + expect(mockCreatePolicy.mock.calls[0][1]).not.toHaveProperty('label'); expect(mockFetchSocietyWide).toHaveBeenCalledWith('us', '98557', '2', { region: 'state/ut', time_period: '2027', diff --git a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx new file mode 100644 index 000000000..c539377d1 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ApiFlagshipReportStore, LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; +import { ApiReformStore, LocalStorageReformStore } from '@/api/reformStore'; +import { UNAUTHENTICATED_USER_HEADER } from '@/api/v2UserClient'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { + useFlagshipReportStore, + useReformStore, + useReportRecord, + useSharedReport, + useV3SavedWork, +} from '@/hooks/useFlagshipStores'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { getBrowserUserId } from '@/libs/auth/browserUser'; +import { store } from '@/store'; +import { + ASSOCIATION_IDS, + errorResponse, + itemResponse, + LEGACY_IDS, + okResponse, + pageResponse, + policyItem, + reportItem, + sharedReportItem, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, + TEST_AUTH0_SUBJECT, +} from '@/tests/fixtures/libs/auth/authMocks'; + +const REFORM_POLICY_ID = 95762; +const REFORM_ROW = policyItem({ + id: ASSOCIATION_IDS.REFORM, + legacy_policy_id: REFORM_POLICY_ID, + name: 'CTC to $3,600', + baseline: 'current-law', + provenance: { source: 'chat', ref: null, confidence: null }, +}); + +const fetchMock = vi.fn(); + +/** Answers the account's reform list and the reform's v1 policy. */ +function respond(url: string): Response { + if (new URL(url).pathname === `/us/policy/${REFORM_POLICY_ID}`) { + return okResponse({ + policy_json: { 'gov.irs.credits.ctc.amount.base[0]': { '2026-01-01.2100-12-31': 3600 } }, + }); + } + return pageResponse([REFORM_ROW]); +} + +function v1PolicyRequests(): number { + return fetchMock.mock.calls.filter(([url]) => String(url).includes('/us/policy/')).length; +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(async (url: string) => respond(url)); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function wrapperFor(auth: AuthContextValue, countryId: 'us' | 'uk' | 'ca' = 'us') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: React.ReactNode }) => ( + <ReduxProvider store={store}> + <QueryClientProvider client={queryClient}> + <CountryProvider value={countryId}> + <AuthTestProvider value={auth}>{children}</AuthTestProvider> + </CountryProvider> + </QueryClientProvider> + </ReduxProvider> + ); +} + +describe('v3 store choice', () => { + test('given a signed-in session then reforms and reports go to the account, scoped by subject', () => { + // Given + const wrapper = wrapperFor(signedInAuthValue()); + + // When + const reforms = renderHook(() => useReformStore(), { wrapper }).result.current; + const reports = renderHook(() => useFlagshipReportStore(), { wrapper }).result.current; + const savedWork = renderHook(() => useV3SavedWork(), { wrapper }).result.current; + + // Then + expect(reforms).toBeInstanceOf(ApiReformStore); + expect(reports.store).toBeInstanceOf(ApiFlagshipReportStore); + expect(reports.isAccount).toBe(true); + expect(savedWork.scope).toBe(`account:${TEST_AUTH0_SUBJECT}`); + }); + + test("given signed out then saved work goes to the API as this browser's user", async () => { + // Given + localStorage.clear(); + const browserUserId = getBrowserUserId(); + const wrapper = wrapperFor(makeAuthValue()); + + // When + const reforms = renderHook(() => useReformStore(), { wrapper }).result.current; + const reports = renderHook(() => useFlagshipReportStore(), { wrapper }).result.current; + await reforms.findMine('us'); + + // Then + expect(reforms).toBeInstanceOf(ApiReformStore); + expect(reports.store).toBeInstanceOf(ApiFlagshipReportStore); + expect(reports.isAccount).toBe(true); + expect(reports.scope).toBe(`browser-user:${browserUserId}`); + const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>; + expect(headers[UNAUTHENTICATED_USER_HEADER]).toBe(browserUserId); + expect(headers.Authorization).toBeUndefined(); + }); + + test.each([ + ['sign-in off', makeAuthValue({ status: 'disabled' }), 'uk'], + ['signed out outside the US and UK', makeAuthValue(), 'ca'], + ['signed in outside the US and UK', signedInAuthValue(), 'ca'], + ] as const)('given %s then reforms and reports stay in this browser', (_, auth, countryId) => { + // Given + const wrapper = wrapperFor(auth, countryId); + + // When + const reforms = renderHook(() => useReformStore(), { wrapper }).result.current; + const reports = renderHook(() => useFlagshipReportStore(), { wrapper }).result.current; + + // Then + expect(reforms).toBeInstanceOf(LocalStorageReformStore); + expect(reports.store).toBeInstanceOf(LocalStorageFlagshipReportStore); + expect(reports.scope).toBe('browser'); + }); + + test.each([ + ['sign-in off', makeAuthValue({ status: 'disabled' }), 'us'], + ['signed out outside the US and UK', makeAuthValue(), 'ca'], + ] as const)('given %s then no browser user ID is made', (_, auth, countryId) => { + // Given + localStorage.clear(); + const wrapper = wrapperFor(auth, countryId); + + // When + renderHook(() => useFlagshipReportStore(), { wrapper }); + renderHook(() => useReformStore(), { wrapper }); + + // Then: nothing is saved to the API, so nothing identifies this browser + expect(localStorage.getItem('pe-browser-user-id')).toBeNull(); + }); +}); + +describe('useReformStore', () => { + test('given the reform list is loaded again then each v1 policy is fetched only once', async () => { + // Given + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + <ReduxProvider store={store}> + <QueryClientProvider client={queryClient}> + <CountryProvider value="us"> + <AuthTestProvider value={signedInAuthValue()}>{children}</AuthTestProvider> + </CountryProvider> + </QueryClientProvider> + </ReduxProvider> + ); + const reformStore = renderHook(() => useReformStore(), { wrapper }).result.current; + + // When + const first = await reformStore.findMine('us'); + const second = await reformStore.findMine('us'); + + // Then + expect(first[0].parameters).toHaveLength(1); + expect(second[0].parameters).toEqual(first[0].parameters); + expect(v1PolicyRequests()).toBe(1); + }); +}); + +describe('useReportRecord', () => { + test('given a run kept in this browser then its record names the reform it was run from', async () => { + // Given + localStorage.clear(); + await new LocalStorageFlagshipReportStore().create({ + countryId: 'us', + apiReportId: '615', + title: 'CTC expansion', + sourceNote: null, + provisions: [], + year: '2026', + reformId: 'rf-ran', + }); + + // When: sign-in off, so records are this browser's + const { result } = renderHook(() => useReportRecord('615'), { + wrapper: wrapperFor(makeAuthValue({ status: 'disabled' })), + }); + + // Then + await waitFor(() => expect(result.current?.reformId).toBe('rf-ran')); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('useReportRecord by link', () => { + test("given a signed-in session and its saved report's link then it reads the account's row", async () => { + // Given + fetchMock.mockImplementation(async (url: string) => + new URL(url).pathname === `/v2/user-reports/${ASSOCIATION_IDS.REPORT}` + ? itemResponse(reportItem({ user_policy_id: ASSOCIATION_IDS.REFORM })) + : errorResponse(404, 'not found') + ); + + // When + const { result } = renderHook(() => useReportRecord(ASSOCIATION_IDS.REPORT), { + wrapper: wrapperFor(signedInAuthValue()), + }); + + // Then + await waitFor(() => expect(result.current?.reformId).toBe(ASSOCIATION_IDS.REFORM)); + }); + + test("given someone else's saved report then this session has no record of it", async () => { + // Given: the owner routes answer 404 for another user's row + fetchMock.mockResolvedValue(errorResponse(404, 'not found')); + + // When + const { result } = renderHook(() => useReportRecord(ASSOCIATION_IDS.REPORT), { + wrapper: wrapperFor(signedInAuthValue()), + }); + + // Then + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(result.current).toBeNull(); + }); +}); + +describe('useSharedReport', () => { + test("given a saved report's link then anyone reads it with no credential", async () => { + // Given + fetchMock.mockImplementation(async () => + itemResponse(sharedReportItem({ baseline_reform: { name: 'Plan B' } })) + ); + + // When: even a signed-in session reads it without its token + const { result } = renderHook(() => useSharedReport(ASSOCIATION_IDS.REPORT), { + wrapper: wrapperFor(signedInAuthValue()), + }); + + // Then + await waitFor(() => expect(result.current.data).toBeTruthy()); + expect(result.current.data?.record).toMatchObject({ + id: ASSOCIATION_IDS.REPORT, + apiReportId: String(LEGACY_IDS.REPORT), + title: 'CTC to $3,600', + reformId: null, + baselineReformId: null, + }); + expect(result.current.data?.baselineReform).toEqual({ name: 'Plan B' }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new URL(url).pathname).toBe(`/v2/shared-reports/${ASSOCIATION_IDS.REPORT}`); + expect(init.headers).toEqual({ Accept: 'application/json' }); + }); + + test("given a v1 report's number then nothing is asked: only saved reports are shared", async () => { + // When + const { result } = renderHook(() => useSharedReport(String(LEGACY_IDS.REPORT)), { + wrapper: wrapperFor(makeAuthValue({ status: 'disabled' })), + }); + + // Then + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.fetchStatus).toBe('idle'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts b/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts index 503c59ae2..c2a4ba1be 100644 --- a/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts +++ b/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts @@ -2,7 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot'; -import type { LiveValidation } from '@/libs/flagship/reportValidation'; +import { snapshotFromLive, type LiveValidation } from '@/libs/flagship/reportValidation'; import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; import { NEWER_RELEASE_ID } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; @@ -84,4 +84,23 @@ describe('useReportValidationSnapshot', () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(result.current.snapshot).toBeNull(); }); + + test("given a report someone else ran then the owner's pin shows and nothing is pinned", async () => { + // Given: no record in this session, and the shared report's pin + const ownersPin = snapshotFromLive({ + ...settledLive, + calibration: { ...mockCalibrationMatches, releaseId: NEWER_RELEASE_ID }, + }); + + // When + const { result } = renderHook(() => + useReportValidationSnapshot(API_REPORT_ID, settledLive, ownersPin) + ); + + // Then: the drift is measured against the owner's pin + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.snapshot).toEqual(ownersPin); + expect(result.current.drift).toHaveLength(1); + expect(await new LocalStorageFlagshipReportStore().findMine()).toEqual([]); + }); }); diff --git a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx index 4cd400c6f..8d10f49b7 100644 --- a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx @@ -1,8 +1,15 @@ import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { render, screen, userEvent, waitFor } from '@test-utils'; +import { act, renderHook } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { ApiFlagshipReportStore } from '@/api/flagshipReportStore'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { NavigationProvider } from '@/contexts/NavigationContext'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; -import { flagshipReportQueryKeys } from '@/libs/flagship/runReport'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { flagshipReportQueryKeys, type RunReportProvision } from '@/libs/flagship/runReport'; +import { store } from '@/store'; import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; import { EARLIER_REPORT_ID, @@ -13,6 +20,11 @@ import { RUN_REFORM_ID, RUN_TITLE, } from '@/tests/fixtures/hooks/useRunFlagshipReportMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; const mockRunFlagshipReport = vi.fn(); const mockFindMine = vi.fn(); @@ -23,44 +35,40 @@ vi.mock('@/libs/flagship/runReport', async (importOriginal) => ({ runFlagshipReport: (...args: unknown[]) => mockRunFlagshipReport(...args), })); -/** Your reforms' list of reports, as fresh for five minutes as the app keeps it. */ -function ReportsList() { - const { data } = useQuery({ - queryKey: flagshipReportQueryKeys.mine('us'), - queryFn: () => mockFindMine(), - staleTime: 5 * 60 * 1000, - }); - return <p>{data ? `${data.length} reports` : 'Loading'}</p>; -} - -/** Where the earlier report stood when Your reforms last asked. */ -function EarlierReportStatus() { - const { data } = useQuery({ - queryKey: flagshipReportQueryKeys.status('us', EARLIER_REPORT_ID), - queryFn: () => mockFetchStatus(), - staleTime: 60_000, - }); - return <p>{data}</p>; -} - -function RunButton() { - const { run } = useRunFlagshipReport(); - return ( - <button type="button" onClick={() => run(RUN_TITLE, '', RUN_PROVISIONS, RUN_REFORM_ID)}> - Run impact report - </button> - ); -} - -function renderRun(queryClient: QueryClient, children: React.ReactNode) { - return render( +const PROVISIONS: RunReportProvision[] = [ + { + path: 'gov.irs.credits.ctc.amount', + breadcrumb: 'CTC', + unit: null, + baselineValue: 2000, + value: 3000, + }, +]; + +/** The scope a session with sign-in off keeps its saved work under. */ +const BROWSER_SCOPE = 'browser'; + +function renderRunWithAuth(auth: AuthContextValue, push = vi.fn()) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( <QueryClientProvider client={queryClient}> - {children} - <RunButton /> + <ReduxProvider store={store}> + <CountryProvider value="us"> + <AuthTestProvider value={auth}> + <NavigationProvider value={{ push, replace: vi.fn(), back: vi.fn() }}> + {children} + </NavigationProvider> + </AuthTestProvider> + </CountryProvider> + </ReduxProvider> </QueryClientProvider> ); + return renderHook(() => useRunFlagshipReport(), { wrapper }); } +/** What the hook passed to runFlagshipReport on its first run. */ +const runArguments = () => mockRunFlagshipReport.mock.calls[0][0]; + describe('useRunFlagshipReport', () => { beforeEach(() => { vi.clearAllMocks(); @@ -69,48 +77,140 @@ describe('useRunFlagshipReport', () => { mockFetchStatus.mockResolvedValue('checked'); }); - test('given a report runs then Your reforms lists it at once', async () => { - // Given - const user = userEvent.setup(); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - mockFindMine.mockResolvedValueOnce(EARLIER_REPORTS).mockResolvedValue(REPORTS_AFTER_RUN); - renderRun(queryClient, <ReportsList />); - expect(await screen.findByText('1 reports')).toBeInTheDocument(); + describe('where the run records its report', () => { + test('given a signed-in session then the run records in the account', async () => { + // Given + const { result } = renderRunWithAuth(signedInAuthValue()); - // When - await user.click(screen.getByRole('button', { name: 'Run impact report' })); + // When + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); - // Then - expect(await screen.findByText('2 reports')).toBeInTheDocument(); - }); + // Then + expect(mockRunFlagshipReport).toHaveBeenCalledTimes(1); + expect(runArguments().accountReportStore).toBeInstanceOf(ApiFlagshipReportStore); + }); - test('given a report runs then the reports’ statuses are asked for again', async () => { - // Given - const user = userEvent.setup(); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - renderRun(queryClient, <EarlierReportStatus />); - await screen.findByText('checked'); + test("given a signed-out session then the run records as this browser's user in the API", async () => { + // Given + const { result } = renderRunWithAuth(makeAuthValue()); - // When - await user.click(screen.getByRole('button', { name: 'Run impact report' })); + // When + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); - // Then - await waitFor(() => expect(mockFetchStatus).toHaveBeenCalledTimes(2)); - }); + // Then + expect(runArguments().accountReportStore).toBeInstanceOf(ApiFlagshipReportStore); + }); + + test('given sign-in off then the run records in this browser', async () => { + // Given + const { result } = renderRunWithAuth(makeAuthValue({ status: 'disabled' })); - test('given the run fails then the list stays as it was', async () => { - // Given - const user = userEvent.setup(); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - mockRunFlagshipReport.mockRejectedValue(new Error('API down')); - mockFindMine.mockResolvedValue(EARLIER_REPORTS); - renderRun(queryClient, <ReportsList />); - await screen.findByText('1 reports'); + // When + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); - // When - await user.click(screen.getByRole('button', { name: 'Run impact report' })); + // Then + await waitFor(() => expect(mockRunFlagshipReport).toHaveBeenCalledTimes(1)); + expect(runArguments().accountReportStore).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + test("given a signed-in run then the report opens at its saved report's link", async () => { + // Given: the run answers the account row's ID, which anyone can open + const reportRowId = '44444444-4444-4444-8444-444444444444'; + mockRunFlagshipReport.mockResolvedValue(reportRowId); + const push = vi.fn(); + const { result } = renderRunWithAuth(signedInAuthValue(), push); + + // When + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); + + // Then + expect(push).toHaveBeenCalledWith(`/us/report/${reportRowId}`); + }); + }); - // Then - expect(mockFindMine).toHaveBeenCalledTimes(1); + describe('Your reforms after a run', () => { + /** Your reforms' list of reports, as fresh for five minutes as the app keeps it. */ + function ReportsList() { + const { data } = useQuery({ + queryKey: flagshipReportQueryKeys.mine(BROWSER_SCOPE, 'us'), + queryFn: () => mockFindMine(), + staleTime: 5 * 60 * 1000, + }); + return <p>{data ? `${data.length} reports` : 'Loading'}</p>; + } + + /** Where the earlier report stood when Your reforms last asked. */ + function EarlierReportStatus() { + const { data } = useQuery({ + queryKey: flagshipReportQueryKeys.status('us', EARLIER_REPORT_ID), + queryFn: () => mockFetchStatus(), + staleTime: 60_000, + }); + return <p>{data}</p>; + } + + function RunButton() { + const { run } = useRunFlagshipReport(); + return ( + <button type="button" onClick={() => run(RUN_TITLE, '', RUN_PROVISIONS, RUN_REFORM_ID)}> + Run impact report + </button> + ); + } + + function renderRun(queryClient: QueryClient, children: React.ReactNode) { + return render( + <QueryClientProvider client={queryClient}> + {children} + <RunButton /> + </QueryClientProvider> + ); + } + + test('given a report runs then Your reforms lists it at once', async () => { + // Given + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockFindMine.mockResolvedValueOnce(EARLIER_REPORTS).mockResolvedValue(REPORTS_AFTER_RUN); + renderRun(queryClient, <ReportsList />); + expect(await screen.findByText('1 reports')).toBeInTheDocument(); + + // When + await user.click(screen.getByRole('button', { name: 'Run impact report' })); + + // Then + expect(await screen.findByText('2 reports')).toBeInTheDocument(); + }); + + test('given a report runs then the reports’ statuses are asked for again', async () => { + // Given + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + renderRun(queryClient, <EarlierReportStatus />); + await screen.findByText('checked'); + + // When + await user.click(screen.getByRole('button', { name: 'Run impact report' })); + + // Then + await waitFor(() => expect(mockFetchStatus).toHaveBeenCalledTimes(2)); + }); + + test('given the run fails then the list stays as it was', async () => { + // Given + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockRunFlagshipReport.mockRejectedValue(new Error('API down')); + mockFindMine.mockResolvedValue(EARLIER_REPORTS); + renderRun(queryClient, <ReportsList />); + await screen.findByText('1 reports'); + + // When + await user.click(screen.getByRole('button', { name: 'Run impact report' })); + + // Then + expect(mockFindMine).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx new file mode 100644 index 000000000..a2404370d --- /dev/null +++ b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx @@ -0,0 +1,479 @@ +import { useState } from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FlagshipReportStore } from '@/api/flagshipReportStore'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useAuthStatus } from '@/libs/auth/AuthContext'; +import { AuthProvider, takeSignInReturnTo } from '@/libs/auth/AuthProvider'; +import type { CountryId } from '@/libs/countries'; +import { + addDraftProvision, + clearDraftReform, + getDraftReform, + resumeDraftFor, + startDraftReform, +} from '@/libs/draftReform'; +import { readReportMeta, runFlagshipReport } from '@/libs/flagship/runReport'; +import { TEST_ACCESS_TOKEN } from '@/tests/fixtures/api/v2UserMocks'; +import { + stubAuthEnv, + TEST_AUTH0_CONFIG, + TEST_AUTH0_SUBJECT, + TEST_PROFILE, +} from '@/tests/fixtures/libs/auth/authMocks'; + +/** A change in a UK draft, so the draft is kept when it is set aside. */ +const UK_DRAFT_PROVISION = { + path: 'gov.hmrc.income_tax.rates.uk[0].rate', + breadcrumb: 'HMRC → Income tax → Basic rate', + unit: '/1', + baselineValue: 0.2, + value: 0.21, +}; + +const auth0 = vi.hoisted(() => ({ + state: { + isLoading: false, + isAuthenticated: false, + error: undefined as Error | undefined, + user: undefined as Record<string, unknown> | undefined, + }, + providerProps: vi.fn(), + getAccessTokenSilently: vi.fn(), + loginWithRedirect: vi.fn(), + logout: vi.fn(), +})); + +vi.mock('@auth0/auth0-react', () => ({ + Auth0Provider: (props: { children: React.ReactNode }) => { + auth0.providerProps(props); + return <>{props.children}</>; + }, + useAuth0: () => ({ + ...auth0.state, + getAccessTokenSilently: auth0.getAccessTokenSilently, + loginWithRedirect: auth0.loginWithRedirect, + logout: auth0.logout, + }), +})); + +const mockCreateReport = vi.hoisted(() => vi.fn()); + +vi.mock('@/api/policy', () => ({ + createPolicy: vi.fn().mockResolvedValue({ result: { policy_id: '77' } }), +})); +vi.mock('@/api/simulation', () => ({ + createSimulation: vi.fn().mockResolvedValue({ result: { simulation_id: '101' } }), +})); +vi.mock('@/api/report', () => ({ + createReport: mockCreateReport, +})); + +const fetchMock = vi.fn(); + +function Probe() { + const auth = useAuthStatus(); + const [token, setToken] = useState(''); + return ( + <div> + <span data-testid="status">{auth.status}</span> + <span data-testid="subject">{auth.subject ?? 'none'}</span> + <span data-testid="name">{auth.profile?.name ?? 'none'}</span> + <span data-testid="session-ended">{String(auth.sessionEnded)}</span> + <span data-testid="token">{token}</span> + <button type="button" onClick={() => auth.signIn('/us/reforms?filter=yours')}> + Sign in + </button> + <button type="button" onClick={() => auth.signOut()}> + Sign out + </button> + <button + type="button" + onClick={async () => + setToken( + await auth.getAccessToken().then( + (accessToken) => accessToken ?? 'no token', + () => 'unreachable' + ) + ) + } + > + Get token + </button> + </div> + ); +} + +function renderProvider(countryId: CountryId = 'us') { + return render( + <CountryProvider value={countryId}> + <AuthProvider> + <Probe /> + </AuthProvider> + </CountryProvider> + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + clearDraftReform(); + vi.stubGlobal('fetch', fetchMock); + auth0.state.isLoading = false; + auth0.state.isAuthenticated = false; + auth0.state.error = undefined; + auth0.state.user = undefined; + auth0.getAccessTokenSilently.mockResolvedValue(TEST_ACCESS_TOKEN); + auth0.logout.mockImplementation(async () => { + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('AuthProvider', () => { + test('given sign-in is off then children render with status disabled and no Auth0', () => { + // Given + stubAuthEnv(false); + + // When + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('disabled'); + expect(auth0.providerProps).not.toHaveBeenCalled(); + }); + + test('given the flag is on but Auth0 is not configured then sign-in stays disabled', () => { + // Given + stubAuthEnv(true, { domain: TEST_AUTH0_CONFIG.domain }); + + // When + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('disabled'); + expect(auth0.providerProps).not.toHaveBeenCalled(); + }); + + test('given sign-in is on then Auth0 keeps tokens in memory with rotating refresh tokens', () => { + // Given + stubAuthEnv(true); + + // When + renderProvider('uk'); + + // Then + expect(auth0.providerProps).toHaveBeenCalledWith( + expect.objectContaining({ + domain: TEST_AUTH0_CONFIG.domain, + clientId: TEST_AUTH0_CONFIG.clientId, + cacheLocation: 'memory', + useRefreshTokens: true, + useRefreshTokensFallback: true, + authorizationParams: { + audience: TEST_AUTH0_CONFIG.audience, + redirect_uri: `${window.location.origin}/uk/callback`, + scope: 'openid profile email offline_access', + }, + }) + ); + }); + + test('given Auth0 is restoring the session then a loading state renders instead of the workspace', () => { + // Given + stubAuthEnv(true); + auth0.state.isLoading = true; + + // When + renderProvider(); + + // Then + expect(screen.getByText('Loading…')).toBeInTheDocument(); + expect(screen.queryByTestId('status')).not.toBeInTheDocument(); + }); + + test('given no session then the workspace is signed out', () => { + // Given + stubAuthEnv(true); + + // When + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('signed_out'); + expect(screen.getByTestId('subject')).toHaveTextContent('none'); + }); + + test('given a session then it is signed in, with its identity from the ID token and no /v2/me call', () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT, ...TEST_PROFILE }; + + // When + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('signed_in'); + expect(screen.getByTestId('subject')).toHaveTextContent(TEST_AUTH0_SUBJECT); + expect(screen.getByTestId('name')).toHaveTextContent(TEST_PROFILE.name); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given a token that can no longer be renewed then the session ends and invites sign-in', async () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + auth0.getAccessTokenSilently.mockRejectedValue({ error: 'login_required' }); + renderProvider(); + startDraftReform('us', 'manual'); + + // When + await userEvent.setup().click(screen.getByRole('button', { name: 'Get token' })); + + // Then + expect(auth0.logout).toHaveBeenCalledWith({ openUrl: false }); + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('signed_out')); + expect(screen.getByTestId('session-ended')).toHaveTextContent('true'); + expect(screen.getByTestId('token')).toHaveTextContent('no token'); + // The person still here keeps their unsaved draft. + expect(getDraftReform()).not.toBeNull(); + }); + + test('given a token request that fails for another reason then the session stays signed in', async () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + auth0.getAccessTokenSilently.mockRejectedValue({ error: 'timeout' }); + renderProvider(); + + // When + await userEvent.setup().click(screen.getByRole('button', { name: 'Get token' })); + + // Then: the request is told sign-in could not be reached; the session stays + await waitFor(() => expect(screen.getByTestId('token')).toHaveTextContent('unreachable')); + expect(auth0.logout).not.toHaveBeenCalled(); + expect(screen.getByTestId('status')).toHaveTextContent('signed_in'); + }); + + test('given a draft left by a session that expired between visits then a signed-out load hides it', () => { + // Given: a signed-in visit that never signed out + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const signedInVisit = renderProvider(); + startDraftReform('us', 'manual'); + signedInVisit.unmount(); + + // When: the next visit finds the session gone + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('signed_out'); + expect(getDraftReform()).toBeNull(); + }); + + test("given a restore that fails on load then the account's drafts come back when it signs in again", () => { + // Given: a signed-in visit with a US draft and a UK one set aside + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const signedInVisit = renderProvider(); + startDraftReform('uk', 'manual'); + addDraftProvision('uk', UK_DRAFT_PROVISION); + startDraftReform('us', 'manual'); + signedInVisit.unmount(); + + // When: one load cannot restore the session (it reads as signed out)... + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + const failedRestore = renderProvider(); + expect(getDraftReform()).toBeNull(); + failedRestore.unmount(); + + // ...and the next restores it + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + renderProvider(); + + // Then: nothing was lost + expect(getDraftReform()?.countryId).toBe('us'); + expect(resumeDraftFor('uk')?.provisions).toHaveLength(1); + }); + + test('given a signed-out load then another account signing in never gets the held drafts', () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const firstAccount = renderProvider(); + startDraftReform('us', 'manual'); + firstAccount.unmount(); + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + renderProvider().unmount(); + + // When: someone else signs in on this browser, then the first account returns + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: 'google-oauth2|someone-else' }; + const otherAccount = renderProvider(); + expect(getDraftReform()).toBeNull(); + otherAccount.unmount(); + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + renderProvider(); + + // Then: the held drafts went when the other account signed in + expect(getDraftReform()).toBeNull(); + }); + + test("given another account signs in then the previous account's draft is dropped", () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const firstAccount = renderProvider(); + startDraftReform('us', 'manual'); + firstAccount.unmount(); + + // When + auth0.state.user = { sub: 'google-oauth2|someone-else' }; + renderProvider(); + + // Then + expect(getDraftReform()).toBeNull(); + }); + + test("given another account signs in then the previous account's draft set aside for another country is dropped too", () => { + // Given: a UK draft set aside while a US one was started + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const firstAccount = renderProvider(); + startDraftReform('uk', 'manual'); + addDraftProvision('uk', UK_DRAFT_PROVISION); + startDraftReform('us', 'manual'); + firstAccount.unmount(); + + // When + auth0.state.user = { sub: 'google-oauth2|someone-else' }; + renderProvider(); + + // Then + expect(resumeDraftFor('uk')).toBeNull(); + }); + + test('given a signed-out draft then signing in keeps it, now as the account’s', () => { + // Given + stubAuthEnv(true); + startDraftReform('us', 'manual'); + renderProvider().unmount(); + + // When + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + renderProvider().unmount(); + + // Then: kept, and dropped if the next load finds no session + expect(getDraftReform()).not.toBeNull(); + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + renderProvider(); + expect(getDraftReform()).toBeNull(); + }); + + test('given sign in then the redirect returns to the requested page via the country callback', async () => { + // Given + stubAuthEnv(true); + renderProvider('us'); + + // When + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign in' })); + + // Then + expect(auth0.loginWithRedirect).toHaveBeenCalledWith({ + appState: { returnTo: '/us/reforms?filter=yours' }, + authorizationParams: { + audience: TEST_AUTH0_CONFIG.audience, + redirect_uri: `${window.location.origin}/us/callback`, + scope: 'openid profile email offline_access', + }, + }); + }); + + test('given the redirect completes then the callback can read where sign-in started, once', () => { + // Given + stubAuthEnv(true); + renderProvider('us'); + const { onRedirectCallback } = auth0.providerProps.mock.calls[0][0]; + + // When + act(() => onRedirectCallback({ returnTo: '/us/build' })); + + // Then + expect(takeSignInReturnTo()).toBe('/us/build'); + expect(takeSignInReturnTo()).toBeNull(); + act(() => onRedirectCallback({ returnTo: 'https://evil.example/' })); + expect(takeSignInReturnTo()).toBe('/us'); + }); + + test('given the redirect completes then the used code leaves the address bar', () => { + // Given + stubAuthEnv(true); + window.history.replaceState(null, '', '/us/callback?code=used-code&state=used-state'); + renderProvider('us'); + const { onRedirectCallback } = auth0.providerProps.mock.calls[0][0]; + + // When + act(() => onRedirectCallback({ returnTo: '/us/build' })); + + // Then: a reload cannot replay it + expect(window.location.pathname).toBe('/us/callback'); + expect(window.location.search).toBe(''); + takeSignInReturnTo(); + }); + + test('given sign out then what the session left in this browser is cleared first', async () => { + // Given: a signed-in run's provenance and an account reform loaded for editing + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + mockCreateReport.mockResolvedValue({ id: 56 }); + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: '', + provisions: [{ path: 'gov.x', breadcrumb: 'X', unit: null, baselineValue: 1, value: 2 }], + currentLawId: 2, + accountReportStore: { + create: vi.fn().mockResolvedValue({}), + } as unknown as FlagshipReportStore, + }); + // A UK draft set aside while the US one was started. + startDraftReform('uk', 'manual'); + addDraftProvision('uk', UK_DRAFT_PROVISION); + startDraftReform('us', 'manual'); + renderProvider('us'); + + // When + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); + + // Then + expect(readReportMeta('56')).toBeNull(); + expect(getDraftReform()).toBeNull(); + expect(resumeDraftFor('uk')).toBeNull(); + expect(localStorage.getItem('pe-draft-reform-owner')).toBeNull(); + expect(auth0.logout).toHaveBeenCalledWith({ + logoutParams: { returnTo: `${window.location.origin}/us` }, + }); + }); +}); diff --git a/app/src/tests/unit/libs/auth/authConfig.test.ts b/app/src/tests/unit/libs/auth/authConfig.test.ts new file mode 100644 index 000000000..816d2aa1d --- /dev/null +++ b/app/src/tests/unit/libs/auth/authConfig.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { getAuth0Config, isCallbackPath, safeReturnTo } from '@/libs/auth/authConfig'; +import { stubAuthEnv, TEST_AUTH0_CONFIG } from '@/tests/fixtures/libs/auth/authMocks'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('getAuth0Config', () => { + test('given the flag and all settings then it returns the Auth0 config', () => { + stubAuthEnv(true); + expect(getAuth0Config()).toEqual(TEST_AUTH0_CONFIG); + }); + + test('given the flag off then it returns null', () => { + stubAuthEnv(false); + expect(getAuth0Config()).toBeNull(); + }); + + test('given a missing setting then it returns null', () => { + stubAuthEnv(true, { domain: TEST_AUTH0_CONFIG.domain, clientId: TEST_AUTH0_CONFIG.clientId }); + expect(getAuth0Config()).toBeNull(); + }); +}); + +describe('safeReturnTo', () => { + test('given an in-app path then it is kept', () => { + expect(safeReturnTo('/us/reforms?filter=yours', '/us')).toBe('/us/reforms?filter=yours'); + }); + + test.each([ + ['https://evil.example/'], + ['//evil.example/'], + ['/\\evil.example'], + ['/us/callback?code=x'], + [undefined], + ])('given %s then it falls back', (returnTo) => { + expect(safeReturnTo(returnTo, '/us')).toBe('/us'); + }); +}); + +describe('isCallbackPath', () => { + test('given the country callback then it matches', () => { + expect(isCallbackPath('/uk/callback')).toBe(true); + expect(isCallbackPath('/uk/reports')).toBe(false); + }); +}); diff --git a/app/src/tests/unit/libs/auth/browserUser.test.ts b/app/src/tests/unit/libs/auth/browserUser.test.ts new file mode 100644 index 000000000..20b663324 --- /dev/null +++ b/app/src/tests/unit/libs/auth/browserUser.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { getBrowserUserId, replaceBrowserUserId } from '@/libs/auth/browserUser'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('browserUser', () => { + test('given a first visit then the browser gets a random user ID that it keeps', () => { + // When + const first = getBrowserUserId(); + + // Then + expect(first).toMatch(UUID_PATTERN); + expect(getBrowserUserId()).toBe(first); + expect(localStorage.getItem('pe-browser-user-id')).toBe(first); + }); + + test('given an upgrade then the browser starts over with a new user ID', () => { + // Given + const before = getBrowserUserId(); + + // When + const after = replaceBrowserUserId(); + + // Then + expect(after).toMatch(UUID_PATTERN); + expect(after).not.toBe(before); + expect(getBrowserUserId()).toBe(after); + }); + + test('given storage is unavailable then one ID serves the rest of the page', () => { + // Given + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + + // When + const first = getBrowserUserId(); + + // Then + expect(first).toMatch(UUID_PATTERN); + expect(getBrowserUserId()).toBe(first); + }); + + test('given a browser without crypto.randomUUID then it still gets a random user ID', () => { + // Given: randomUUID needs a secure context; getRandomValues does not + const realCrypto = globalThis.crypto; + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => realCrypto.getRandomValues(bytes), + }); + + try { + // When + const userId = getBrowserUserId(); + + // Then + expect(userId).toMatch(UUID_PATTERN); + expect(getBrowserUserId()).toBe(userId); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('given a damaged stored ID then the browser gets a new one the API accepts', () => { + // Given + localStorage.setItem('pe-browser-user-id', 'not-a-uuid'); + + // When + const userId = getBrowserUserId(); + + // Then + expect(userId).toMatch(UUID_PATTERN); + expect(localStorage.getItem('pe-browser-user-id')).toBe(userId); + }); +}); diff --git a/app/src/tests/unit/libs/draftReform.test.ts b/app/src/tests/unit/libs/draftReform.test.ts index 4b47f332b..59d18e535 100644 --- a/app/src/tests/unit/libs/draftReform.test.ts +++ b/app/src/tests/unit/libs/draftReform.test.ts @@ -197,6 +197,24 @@ describe('draftReform', () => { expect(draft?.provisions[0].baselineValue).toBe(2000); }); + it('given a report reopened for editing with its reform then the draft edits that reform', () => { + loadProvisionsIntoDraft('us', [{ ...CTC_PROVISION, value: 3600 }], { + label: 'CTC expansion', + year: 2027, + reformId: 'rf-ran', + }); + + const draft = getDraftReform(); + expect(draft?.editingReformId).toBe('rf-ran'); + expect(draft?.year).toBe(2027); + }); + + it('given a report reopened for editing without a reform then saving makes a new one', () => { + loadProvisionsIntoDraft('us', [{ ...CTC_PROVISION, value: 3600 }], { label: 'Shared' }); + + expect(getDraftReform()?.editingReformId).toBeUndefined(); + }); + it('given a draft then draftToReform produces the store shape with a year-to-forever interval', () => { startDraftReform('us', 'bill', 'ut-hb-106'); addDraftProvision('us', { ...CTC_PROVISION, value: 3600 }); diff --git a/app/src/tests/unit/libs/draftReformHeld.test.ts b/app/src/tests/unit/libs/draftReformHeld.test.ts new file mode 100644 index 000000000..df6b2f46c --- /dev/null +++ b/app/src/tests/unit/libs/draftReformHeld.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { clearSignedInSessionData } from '@/libs/auth/sessionData'; +import { + addDraftProvision, + clearAllDraftReforms, + dropHeldDraftReforms, + getDraftReform, + holdAllDraftReforms, + restoreHeldDraftReforms, + startDraftReform, +} from '@/libs/draftReform'; + +const OWNER = 'a1b2c3d4'; +const PROVISION = { + path: 'gov.irs.credits.ctc.amount.base', + breadcrumb: 'IRS → Credits → Child tax credit → Base amount', + unit: 'currency-USD', + baselineValue: 2000, + value: 3000, +}; +const DAY_MS = 24 * 60 * 60 * 1000; + +beforeEach(() => { + localStorage.clear(); + clearAllDraftReforms(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-16T00:00:00Z')); + startDraftReform('us', 'manual'); + addDraftProvision('us', PROVISION); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('held drafts', () => { + test('given drafts held for an account then they are out of sight until it returns', () => { + // When + holdAllDraftReforms(OWNER); + + // Then + expect(getDraftReform()).toBeNull(); + restoreHeldDraftReforms(OWNER); + expect(getDraftReform()?.provisions).toHaveLength(1); + }); + + test('given a draft started since then restoring keeps it and adds only empty slots', () => { + // Given + holdAllDraftReforms(OWNER); + startDraftReform('us', 'chat'); + + // When + restoreHeldDraftReforms(OWNER); + + // Then: the newer draft in hand stays + expect(getDraftReform()?.source).toBe('chat'); + expect(getDraftReform()?.provisions).toHaveLength(0); + }); + + test('given held drafts older than a week then they do not come back', () => { + // Given + holdAllDraftReforms(OWNER); + vi.setSystemTime(Date.now() + 8 * DAY_MS); + + // When + restoreHeldDraftReforms(OWNER); + + // Then + expect(getDraftReform()).toBeNull(); + }); + + test('given a signed-out load after a week then expired held drafts are deleted', () => { + // Given + holdAllDraftReforms(OWNER); + vi.setSystemTime(Date.now() + 8 * DAY_MS); + + // When + dropHeldDraftReforms({ expiredOnly: true }); + + // Then + expect(localStorage.getItem('pe-draft-reform-held')).toBeNull(); + }); + + test('given held drafts within a week then dropping only expired ones keeps them', () => { + // Given + holdAllDraftReforms(OWNER); + + // When + dropHeldDraftReforms({ expiredOnly: true }); + + // Then + restoreHeldDraftReforms(OWNER); + expect(getDraftReform()?.provisions).toHaveLength(1); + }); + + test('given a sign-out then held drafts go too', () => { + // Given + holdAllDraftReforms(OWNER); + + // When + clearSignedInSessionData(); + + // Then + restoreHeldDraftReforms(OWNER); + expect(getDraftReform()).toBeNull(); + expect(localStorage.getItem('pe-draft-reform-held')).toBeNull(); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/createReformPolicy.test.ts b/app/src/tests/unit/libs/flagship/createReformPolicy.test.ts index 6ed565a1a..a09288349 100644 --- a/app/src/tests/unit/libs/flagship/createReformPolicy.test.ts +++ b/app/src/tests/unit/libs/flagship/createReformPolicy.test.ts @@ -36,7 +36,6 @@ describe('createReformPolicy', () => { test('given numeric text and infinity then they are sent as v2 sends them', async () => { await createReformPolicy( 'us', - 'CTC', [ { path: 'gov.irs.credits.ctc.amount.base[0].amount', @@ -62,7 +61,6 @@ describe('createReformPolicy', () => { await expect( createReformPolicy( 'us', - 'SNAP', [ { path: 'gov.usda.snap.emergency_allotment.allowed', @@ -79,7 +77,6 @@ describe('createReformPolicy', () => { test("given a parameter the metadata lacks (a bill's switch) then its value is sent as it is", async () => { await createReformPolicy( 'us', - 'Bill', [ { path: 'gov.contrib.states.mt.hb1.in_effect', diff --git a/app/src/tests/unit/libs/flagship/latestReport.test.ts b/app/src/tests/unit/libs/flagship/latestReport.test.ts index 523d9afb7..7ee719829 100644 --- a/app/src/tests/unit/libs/flagship/latestReport.test.ts +++ b/app/src/tests/unit/libs/flagship/latestReport.test.ts @@ -18,6 +18,9 @@ const REFORM = { provenance: { source: 'manual' }, } as Reform; +/** A `/v2/user-reports` row's ID: an account report's link. */ +const ACCOUNT_REPORT_ID = '44444444-4444-4444-8444-444444444444'; + const report = (overrides: Partial<FlagshipReportRecord>): FlagshipReportRecord => ({ id: 'rec', countryId: 'us', @@ -65,4 +68,37 @@ describe('latestMatchingReport', () => { latestMatchingReport(REFORM, [report({ year: '2027' }), report({ apiReportId: '9' })], 2026) ).toBeNull(); }); + + test('given an account report compared with a saved reform after a reload then it does not open', () => { + // Given: no run details in this page session, only the row's link to the saved reform + const accountReport = report({ id: ACCOUNT_REPORT_ID, baselineReformId: 'rf-plan-b' }); + + // Then + expect(latestMatchingReport(REFORM, [accountReport], 2026)).toBeNull(); + }); + + test('given an account report against current law then it opens', () => { + // Given + const accountReport = report({ id: ACCOUNT_REPORT_ID, baselineReformId: null }); + + // Then + expect(latestMatchingReport(REFORM, [accountReport], 2026)?.id).toBe(ACCOUNT_REPORT_ID); + }); + + test('given a report whose baseline ran another reform then it does not open, linked or not', () => { + // Given: no run details and no link, but its baseline was not current law + const unlinked = { + ...report({ id: ACCOUNT_REPORT_ID, baselineReformId: null }), + baselinePolicyId: '88', + }; + + // When / Then + expect(latestMatchingReport(REFORM, [unlinked], 2026, 2)).toBeNull(); + }); + + test('given a report whose baseline ran current law then it opens', () => { + const againstCurrentLaw = { ...report({ id: ACCOUNT_REPORT_ID }), baselinePolicyId: '2' }; + + expect(latestMatchingReport(REFORM, [againstCurrentLaw], 2026, 2)?.id).toBe(ACCOUNT_REPORT_ID); + }); }); diff --git a/app/src/tests/unit/libs/flagship/ownedItems.test.ts b/app/src/tests/unit/libs/flagship/ownedItems.test.ts deleted file mode 100644 index 26ded2793..000000000 --- a/app/src/tests/unit/libs/flagship/ownedItems.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { - addOwnedItem, - EDIT_TOKEN_HEADER, - editTokenHeaders, - findOwnedItem, - findOwnedReportByApiReportId, - ItemNotOwnedError, - readOwnedItems, - removeOwnedItem, - resetSessionOwnedItemsForTesting, -} from '@/libs/flagship/ownedItems'; -import { - API_REPORT_ID, - CENTRAL_REFORM_ID, - CENTRAL_REPORT_ID, - EDIT_TOKEN, - OTHER_API_REPORT_ID, - OTHER_CENTRAL_REFORM_ID, - OWNED_REFORMS_KEY, - OWNED_REPORTS_KEY, - ownedReport, - ownedUkReform, - ownedUsReform, - seedOwnedItems, - UK_CENTRAL_REFORM_ID, -} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; - -describe('ownedItems', () => { - beforeEach(() => { - resetSessionOwnedItemsForTesting(); - localStorage.clear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - test('given an added item then it is found by id with its token and a timestamp', () => { - // When - addOwnedItem('reforms', { id: CENTRAL_REFORM_ID, countryId: 'us', editToken: EDIT_TOKEN }); - - // Then - const item = findOwnedItem('reforms', CENTRAL_REFORM_ID); - expect(item).toMatchObject({ id: CENTRAL_REFORM_ID, countryId: 'us', editToken: EDIT_TOKEN }); - expect(Number.isNaN(Date.parse(item!.addedAt))).toBe(false); - }); - - test('given items for two countries then reading one country returns only its items', () => { - // Given - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform, ownedUkReform]); - - // When - const ukOnly = readOwnedItems('reforms', 'uk'); - - // Then - expect(ukOnly.map((item) => item.id)).toEqual([UK_CENTRAL_REFORM_ID]); - }); - - test('given two added items then the newest is listed first', () => { - // Given - addOwnedItem('reforms', { id: CENTRAL_REFORM_ID, countryId: 'us', editToken: EDIT_TOKEN }); - - // When - addOwnedItem('reforms', { - id: OTHER_CENTRAL_REFORM_ID, - countryId: 'us', - editToken: EDIT_TOKEN, - }); - - // Then - expect(readOwnedItems('reforms').map((item) => item.id)).toEqual([ - OTHER_CENTRAL_REFORM_ID, - CENTRAL_REFORM_ID, - ]); - }); - - test('given reforms and reports then each kind keeps its own index', () => { - // When - addOwnedItem('reforms', { id: CENTRAL_REFORM_ID, countryId: 'us', editToken: EDIT_TOKEN }); - - // Then - expect(readOwnedItems('reports')).toEqual([]); - }); - - test('given a removed item then it is no longer found', () => { - // Given - seedOwnedItems(OWNED_REFORMS_KEY, [ownedUsReform, ownedUkReform]); - - // When - removeOwnedItem('reforms', CENTRAL_REFORM_ID); - - // Then - expect(findOwnedItem('reforms', CENTRAL_REFORM_ID)).toBeNull(); - expect(findOwnedItem('reforms', UK_CENTRAL_REFORM_ID)).not.toBeNull(); - }); - - test('given a report record then it is found by the API report it points at', () => { - // When - addOwnedItem('reports', { - id: CENTRAL_REPORT_ID, - countryId: 'us', - editToken: EDIT_TOKEN, - apiReportId: API_REPORT_ID, - }); - - // Then - expect(findOwnedReportByApiReportId(API_REPORT_ID)?.id).toBe(CENTRAL_REPORT_ID); - expect(findOwnedReportByApiReportId(OTHER_API_REPORT_ID)).toBeNull(); - }); - - test('given corrupt JSON in storage then reads come back empty', () => { - // Given - localStorage.setItem(OWNED_REFORMS_KEY, '{not json'); - - // Then - expect(readOwnedItems('reforms')).toEqual([]); - }); - - test('given malformed entries in storage then only well-formed items are read', () => { - // Given - localStorage.setItem( - OWNED_REFORMS_KEY, - JSON.stringify([ownedUsReform, { id: 42 }, null, { id: 'x', countryId: 'us' }]) - ); - - // Then - expect(readOwnedItems('reforms')).toEqual([ownedUsReform]); - }); - - test('given storage that throws then reads are empty and writes do not throw', () => { - // Given - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new Error('SecurityError'); - }); - vi.spyOn(localStorage, 'setItem').mockImplementation(() => { - throw new Error('QuotaExceededError'); - }); - - // Then - expect(readOwnedItems('reforms')).toEqual([]); - expect(() => - addOwnedItem('reforms', { id: CENTRAL_REFORM_ID, countryId: 'us', editToken: EDIT_TOKEN }) - ).not.toThrow(); - expect(() => removeOwnedItem('reforms', CENTRAL_REFORM_ID)).not.toThrow(); - }); - - test('given an owned item then its edit token goes in the request header', () => { - // Given - seedOwnedItems(OWNED_REPORTS_KEY, [ownedReport]); - - // Then - expect(editTokenHeaders('reports', CENTRAL_REPORT_ID)).toEqual({ - [EDIT_TOKEN_HEADER]: EDIT_TOKEN, - }); - }); - - test('given an item this browser did not create then asking for its token throws', () => { - expect(() => editTokenHeaders('reforms', CENTRAL_REFORM_ID)).toThrow(ItemNotOwnedError); - }); -}); - -describe('ownedItems when localStorage refuses the write', () => { - beforeEach(() => { - resetSessionOwnedItemsForTesting(); - localStorage.clear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - test('then the item is held for the session: it still lists and still edits', () => { - vi.spyOn(localStorage, 'setItem').mockImplementation(() => { - throw new DOMException('QuotaExceededError'); - }); - - const stored = addOwnedItem('reforms', { - id: CENTRAL_REFORM_ID, - countryId: 'us', - editToken: EDIT_TOKEN, - }); - - expect(stored).toBe(false); - expect(readOwnedItems('reforms', 'us').map((item) => item.id)).toEqual([CENTRAL_REFORM_ID]); - expect(editTokenHeaders('reforms', CENTRAL_REFORM_ID)).toEqual({ - [EDIT_TOKEN_HEADER]: EDIT_TOKEN, - }); - - removeOwnedItem('reforms', CENTRAL_REFORM_ID); - expect(readOwnedItems('reforms')).toEqual([]); - }); - - test('then a later write that succeeds moves the item into localStorage', () => { - const setItem = vi.spyOn(localStorage, 'setItem').mockImplementationOnce(() => { - throw new DOMException('QuotaExceededError'); - }); - addOwnedItem('reforms', { id: CENTRAL_REFORM_ID, countryId: 'us', editToken: EDIT_TOKEN }); - setItem.mockRestore(); - - expect( - addOwnedItem('reforms', { id: CENTRAL_REFORM_ID, countryId: 'us', editToken: EDIT_TOKEN }) - ).toBe(true); - expect(JSON.parse(localStorage.getItem(OWNED_REFORMS_KEY) ?? '[]')).toHaveLength(1); - expect(readOwnedItems('reforms')).toHaveLength(1); - }); -}); diff --git a/app/src/tests/unit/libs/flagship/reportComparison.test.ts b/app/src/tests/unit/libs/flagship/reportComparison.test.ts new file mode 100644 index 000000000..aa83e56c2 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/reportComparison.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; +import { ranAgainstReform } from '@/libs/flagship/reportComparison'; + +/** The US current-law policy. */ +const CURRENT_LAW_POLICY_ID = 2; + +describe('ranAgainstReform', () => { + test('given a baseline that ran current law then it was not compared with a reform', () => { + expect(ranAgainstReform('2', CURRENT_LAW_POLICY_ID)).toBe(false); + }); + + test('given a baseline that ran another policy then it was compared with a reform', () => { + expect(ranAgainstReform('88', CURRENT_LAW_POLICY_ID)).toBe(true); + }); + + test('given metadata still loading (current law reads 0) then it is unknown', () => { + expect(ranAgainstReform('2', 0)).toBeUndefined(); + expect(ranAgainstReform('88', 0)).toBeUndefined(); + }); + + test('given no baseline policy then it is unknown', () => { + expect(ranAgainstReform(undefined, CURRENT_LAW_POLICY_ID)).toBeUndefined(); + expect(ranAgainstReform('', CURRENT_LAW_POLICY_ID)).toBeUndefined(); + expect(ranAgainstReform(null, CURRENT_LAW_POLICY_ID)).toBeUndefined(); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/reportRerun.test.ts b/app/src/tests/unit/libs/flagship/reportRerun.test.ts index d4acf1f34..4e37284be 100644 --- a/app/src/tests/unit/libs/flagship/reportRerun.test.ts +++ b/app/src/tests/unit/libs/flagship/reportRerun.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; import { rerunSettings } from '@/libs/flagship/reportRerun'; import { saveReportMeta } from '@/libs/flagship/runReport'; +import type { Reform } from '@/types/ingredients/Reform'; const fetchReportById = vi.fn(); const fetchSimulationById = vi.fn(); @@ -24,6 +25,34 @@ const REPORT: FlagshipReportRecord = { reformId: 'rf-1', }; +/** A `/v2/user-reports` row's ID: an account report's link. */ +const REPORT_ROW_ID = '44444444-4444-4444-8444-444444444444'; + +/** + * An account report: the row keeps no year (its v1 report does), its + * budget window, and the saved reform it was compared with. + */ +const ACCOUNT_REPORT: FlagshipReportRecord = { + ...REPORT, + id: REPORT_ROW_ID, + year: undefined, + budgetWindowYears: 10, + baselineReformId: 'rf-plan-b', +}; + +/** The saved reform the account report was compared with. */ +const PLAN_B: Reform = { + id: 'rf-plan-b', + countryId: 'us', + label: 'Plan B', + policyId: '87', + parameters: [], + baseline: 'current-law', + provenance: { source: 'manual' }, +}; + +const savedReform = (reformId: string) => (reformId === PLAN_B.id ? PLAN_B : undefined); + describe('rerunSettings', () => { beforeEach(() => { localStorage.clear(); @@ -45,6 +74,7 @@ describe('rerunSettings', () => { }); expect(await rerunSettings('us', REPORT)).toEqual({ + year: 2027, region: 'state/md', windowYears: 10, baseline: { reformId: 'rf-plan-b', label: 'Plan B', policyId: 88 }, @@ -55,6 +85,7 @@ describe('rerunSettings', () => { fetchSimulationById.mockResolvedValue({ population_type: 'geography', population_id: 'us' }); expect(await rerunSettings('us', REPORT)).toEqual({ + year: 2027, region: undefined, windowYears: undefined, baseline: undefined, @@ -66,4 +97,137 @@ describe('rerunSettings', () => { expect((await rerunSettings('us', REPORT)).region).toBeUndefined(); }); + + describe('given an account report that no longer links the reform it was compared with', () => { + // The compared reform was deleted (its link went null), or the report + // recomputed someone else's: the row names no saved reform. + const UNLINKED_REPORT: FlagshipReportRecord = { ...ACCOUNT_REPORT, baselineReformId: null }; + + beforeEach(() => { + fetchReportById.mockResolvedValue({ simulation_1_id: '9', year: '2028' }); + }); + + test('then it keeps the comparison its baseline policy shows', async () => { + // Given: the report's baseline ran policy 88, not current law (2) + fetchSimulationById.mockResolvedValue({ + population_type: 'geography', + population_id: 'us', + policy_id: '88', + }); + + // When + const settings = await rerunSettings('us', UNLINKED_REPORT, { currentLawPolicyId: 2 }); + + // Then + expect(settings.baseline).toEqual({ + reformId: null, + label: 'a saved reform', + policyId: 88, + }); + }); + + test('then a report against current law still runs against current law', async () => { + // Given + fetchSimulationById.mockResolvedValue({ + population_type: 'geography', + population_id: 'us', + policy_id: '2', + }); + + // When / Then + expect( + (await rerunSettings('us', UNLINKED_REPORT, { currentLawPolicyId: 2 })).baseline + ).toBeUndefined(); + }); + }); + + describe('given an account report', () => { + beforeEach(() => { + fetchReportById.mockResolvedValue({ simulation_1_id: '9', year: '2028' }); + fetchSimulationById.mockResolvedValue({ + population_type: 'geography', + population_id: 'state/md', + policy_id: '88', + }); + }); + + test('then its year, window and comparison come from its record and the API', async () => { + // Given: no run details in this page session (another device, or a reload) + + // When + const settings = await rerunSettings('us', ACCOUNT_REPORT, { savedReform }); + + // Then: the baseline is the policy the report ran, named from the saved reform + expect(settings).toEqual({ + year: 2028, + region: 'state/md', + windowYears: 10, + baseline: { reformId: 'rf-plan-b', label: 'Plan B', policyId: 88 }, + }); + }); + + test('then the run details this session kept under its link come first', async () => { + // Given + saveReportMeta(REPORT_ROW_ID, { + title: 'Bigger CTC', + sourceNote: 'Hand-built', + provisions: [], + createdAt: '2026-10-14T00:00:00Z', + budgetWindow: { startYear: 2028, windowSize: 3 }, + baseline: { reformId: 'rf-plan-c', policyId: '90', label: 'Plan C' }, + }); + + // When + const settings = await rerunSettings('us', ACCOUNT_REPORT, { savedReform }); + + // Then + expect(settings).toMatchObject({ + windowYears: 3, + baseline: { reformId: 'rf-plan-c', label: 'Plan C', policyId: 90 }, + }); + }); + + test('then a compared reform that is no longer saved keeps its comparison, untitled', async () => { + // When + const settings = await rerunSettings('us', ACCOUNT_REPORT); + + // Then + expect(settings.baseline).toEqual({ + reformId: 'rf-plan-b', + label: 'Untitled reform', + policyId: 88, + }); + }); + + test('then a report against current law runs against current law', async () => { + // When + const settings = await rerunSettings( + 'us', + { ...ACCOUNT_REPORT, baselineReformId: null }, + { savedReform } + ); + + // Then + expect(settings.baseline).toBeUndefined(); + }); + + test("given the API cannot be read then the comparison is the saved reform's own policy", async () => { + // Given + fetchReportById.mockRejectedValue(new Error('down')); + + // When + const settings = await rerunSettings('us', ACCOUNT_REPORT, { savedReform }); + + // Then + expect(settings.baseline).toEqual({ reformId: 'rf-plan-b', label: 'Plan B', policyId: 87 }); + }); + + test('given neither the API nor the saved reform names the comparison then it does not run', async () => { + // Given + fetchReportById.mockRejectedValue(new Error('down')); + + // When / Then: rather than run against current law + await expect(rerunSettings('us', ACCOUNT_REPORT)).rejects.toThrow(); + }); + }); }); diff --git a/app/src/tests/unit/libs/flagship/reportSchema.test.ts b/app/src/tests/unit/libs/flagship/reportSchema.test.ts index 6874c5e2f..56ed0bb68 100644 --- a/app/src/tests/unit/libs/flagship/reportSchema.test.ts +++ b/app/src/tests/unit/libs/flagship/reportSchema.test.ts @@ -2,40 +2,28 @@ import { describe, expect, test } from 'vitest'; import { parseReportMeta, parseReportRecord, - parseReportRow, readableReports, REPORT_SCHEMA_VERSION, - reportProvisionsWireError, ReportSchemaError, - reportValidationWireError, stampReportMeta, stampReportRecord, unwrapReport, } from '@/libs/flagship/reportSchema'; -import { validationToWire } from '@/libs/flagship/reportValidation'; -import { CENTRAL_REPORT_ID } from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; import { - CTC_PROVISION, CURRENT_SCHEMA_VERSION, currentMeta, currentRecord, GROWING_PROVISION, historicMeta, historicRecords, - historicRows, malformedMeta, malformedRecords, recordFromNewerVersion, recordWithDamagedPin, recordWithoutUserId, - rowWithoutUserId, sparseMeta, sparseMetaExpected, } from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; -import { - MATCHED_AT, - mockReportValidationSnapshot, -} from '@/tests/fixtures/libs/flagship/reportValidationMocks'; /** What a stored value reads back as after a trip through localStorage. */ function throughStorage(value: unknown): unknown { @@ -161,87 +149,33 @@ describe('parseReportRecord', () => { }); }); -describe('parseReportRow', () => { - test.each(historicRows)( - 'given a central row with %s then it reads as a record', - (_n, row, expected) => { - expect(parseReportRow(row)).toEqual({ ok: true, value: expected }); - } - ); - - test('given a row with provisions that are not a list then it reads as a typed error', () => { - const result = parseReportRow({ ...rowWithoutUserId, provisions: 'oops' }); - - expect(result.ok ? null : result.error.field).toBe('provisions'); - }); - - test('given a row whose pin is too damaged to convert then it reads without the pin', () => { - const row = { - ...rowWithoutUserId, - validation: { matched_at: MATCHED_AT, calibration: { matches: 'x' } }, +describe('parseReportRecord and the account fields', () => { + test('given a budget window and a compared reform then both read back unchanged', () => { + const withAccountFields = { + ...currentRecord, + budgetWindowYears: 10, + baselineReformId: 'b5c4d3e2-1f0a-4b9c-8d7e-6f5a4b3c2d1e', }; - expect(parseReportRow(row)).toEqual({ + expect(parseReportRecord(stampReportRecord(withAccountFields))).toEqual({ ok: true, - value: { ...currentRecord, id: CENTRAL_REPORT_ID, validation: null }, + value: withAccountFields, }); }); - test('given a row that is not an object then it reads as a typed error', () => { - const result = parseReportRow('row'); - - expect(result.ok ? null : result.error.code).toBe('not-an-object'); - }); -}); - -describe('reportProvisionsWireError', () => { - test('given provisions as the client sends them then there is no error', () => { - const wire = [ - { - path: CTC_PROVISION.path, - breadcrumb: CTC_PROVISION.breadcrumb, - unit: CTC_PROVISION.unit, - baseline_value: CTC_PROVISION.baselineValue, - value: CTC_PROVISION.value, - }, - ]; - - expect(reportProvisionsWireError(wire)).toBeNull(); - }); - - test('given a provision without a path then the error names it', () => { - expect(reportProvisionsWireError([{ breadcrumb: 'CTC', value: 1 }])).toBe( - 'provisions[0].path must be a non-empty string' - ); - }); + test.each([1, 0, 'ten', 2.5])( + 'given a damaged budget window (%s) then it reads as none rather than failing', + (budgetWindowYears) => { + const result = parseReportRecord({ ...currentRecord, budgetWindowYears }); - test('given a provision that is not an object then the error names it', () => { - expect(reportProvisionsWireError(['CTC'])).toBe('provisions[0] must be an object'); - }); -}); + expect(result).toEqual({ ok: true, value: { ...currentRecord, budgetWindowYears: null } }); + } + ); -describe('reportValidationWireError', () => { - test('given a pin as the client sends it then there is no error', () => { - expect(reportValidationWireError(validationToWire(mockReportValidationSnapshot))).toBeNull(); - }); + test('given a record without a year then it reads, as account rows leave the year to the report', () => { + const { year: _year, ...withoutYear } = currentRecord; - test.each([ - ['no pin', undefined, 'validation must be a snapshot with matched_at'], - [ - 'scorecard programs that are not a list', - { ...validationToWire(mockReportValidationSnapshot), scorecard: { programs: 'ctc' } }, - 'validation.scorecard.programs must be an array', - ], - [ - 'a match depth that is not a number', - { - ...validationToWire(mockReportValidationSnapshot), - calibration: { geography: 'US', reached_count: 1, matches: [{ depth: 'deep' }] }, - }, - 'validation.calibration.matches[0].depth must be a number', - ], - ])('given %s then the error names it', (_name, wire, message) => { - expect(reportValidationWireError(wire)).toBe(message); + expect(parseReportRecord(withoutYear)).toEqual({ ok: true, value: withoutYear }); }); }); diff --git a/app/src/tests/unit/libs/flagship/runReport.test.ts b/app/src/tests/unit/libs/flagship/runReport.test.ts index 9875dd7ee..7e4302893 100644 --- a/app/src/tests/unit/libs/flagship/runReport.test.ts +++ b/app/src/tests/unit/libs/flagship/runReport.test.ts @@ -1,9 +1,16 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { readReportMeta, runFlagshipReport } from '@/libs/flagship/runReport'; +import type { FlagshipReportRecord, FlagshipReportStore } from '@/api/flagshipReportStore'; +import { + forgetAccountReportMeta, + readReportMeta, + REPORT_META_KEY, + reportMetaFromRecord, + runFlagshipReport, +} from '@/libs/flagship/runReport'; const mockCreatePolicy = vi.fn(); const mockCreateSimulation = vi.fn(); -const mockCreateReportAndAssociate = vi.fn(); +const mockCreateReport = vi.fn(); const mockFetchBudgetWindow = vi.fn(); const mockFetchSocietyWide = vi.fn(); @@ -14,7 +21,7 @@ vi.mock('@/api/simulation', () => ({ createSimulation: (...args: any[]) => mockCreateSimulation(...args), })); vi.mock('@/api/report', () => ({ - createReportAndAssociateWithUser: (...args: any[]) => mockCreateReportAndAssociate(...args), + createReport: (...args: any[]) => mockCreateReport(...args), })); vi.mock('@/api/budgetWindow', () => ({ fetchBudgetWindow: (...args: any[]) => mockFetchBudgetWindow(...args), @@ -23,6 +30,18 @@ vi.mock('@/api/societyWideCalculation', () => ({ fetchSocietyWideCalculation: (...args: any[]) => mockFetchSocietyWide(...args), })); +/** The localStorage keys the legacy calculator keeps its saved work under. */ +const LEGACY_KEYS = [ + 'user-policy-associations', + 'user-population-households', + 'user-simulation-associations', + 'user-report-associations', +]; + +const REFORM_ROW_ID = '55555555-5555-4555-8555-555555555555'; +/** The account row a signed-in run writes; its ID is the report's link. */ +const REPORT_ROW_ID = '44444444-4444-4444-8444-444444444444'; + const PROVISION = { path: 'gov.irs.credits.ctc.amount.base[0].amount', breadcrumb: 'IRS → Credits → Child tax credit → Base amount', @@ -35,13 +54,13 @@ describe('runFlagshipReport', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + // Account runs keep their details in memory for the page session. + forgetAccountReportMeta(); mockCreatePolicy.mockResolvedValue({ result: { policy_id: '77' } }); mockCreateSimulation .mockResolvedValueOnce({ result: { simulation_id: '101' } }) .mockResolvedValueOnce({ result: { simulation_id: '102' } }); - mockCreateReportAndAssociate.mockResolvedValue({ - metadata: { baseReportId: '55', userReportId: 'sur-abc', countryId: 'us' }, - }); + mockCreateReport.mockResolvedValue({ id: 55, country_id: 'us', year: '2026' }); }); test('starts both simulations before either finishes and waits for both before creating the report', async () => { @@ -67,10 +86,10 @@ describe('runFlagshipReport', () => { currentLawId: 2, }); await vi.waitFor(() => expect(mockCreateSimulation).toHaveBeenCalledTimes(2)); - expect(mockCreateReportAndAssociate).not.toHaveBeenCalled(); + expect(mockCreateReport).not.toHaveBeenCalled(); resolveReform({ result: { simulation_id: '102' } }); await Promise.resolve(); - expect(mockCreateReportAndAssociate).not.toHaveBeenCalled(); + expect(mockCreateReport).not.toHaveBeenCalled(); resolveBaseline({ result: { simulation_id: '101' } }); await expect(pending).resolves.toBe('55'); }); @@ -85,8 +104,8 @@ describe('runFlagshipReport', () => { }); expect(userReportId).toBe('55'); + // The base policy carries only the changes; the title stays on the record. expect(mockCreatePolicy).toHaveBeenCalledWith('us', { - label: 'CTC expansion', data: { 'gov.irs.credits.ctc.amount.base[0].amount': { '2026-01-01.2100-12-31': 2500 }, }, @@ -101,12 +120,11 @@ describe('runFlagshipReport', () => { population_type: 'geography', policy_id: 77, }); - expect(mockCreateReportAndAssociate).toHaveBeenCalledWith( - expect.objectContaining({ - countryId: 'us', - payload: { simulation_1_id: 101, simulation_2_id: 102, year: '2026' }, - }) - ); + expect(mockCreateReport).toHaveBeenCalledWith('us', { + simulation_1_id: 101, + simulation_2_id: 102, + year: '2026', + }); }); test('given a state or district then both simulations run on it', async () => { @@ -234,11 +252,19 @@ describe('runFlagshipReport', () => { ], }; + /** Plan B's change, as its unlabelled baseline policy carries it. */ + const PLAN_B_DATA = { + 'gov.irs.credits.ctc.amount.base[0].amount': { '2026-01-01.2100-12-31': 3000 }, + }; + beforeEach(() => { - // The reform and the baseline each become their own policy. + // The reform and the baseline each become their own policy, told + // apart by what they change: neither carries a name. mockCreatePolicy.mockImplementation( - async (_country: string, payload: { label?: string }) => ({ - result: { policy_id: payload.label === 'Plan B' ? '88' : '77' }, + async (_country: string, payload: { data: Record<string, unknown> }) => ({ + result: { + policy_id: JSON.stringify(payload.data) === JSON.stringify(PLAN_B_DATA) ? '88' : '77', + }, }) ); mockFetchSocietyWide.mockResolvedValue({ status: 'computing', result: null }); @@ -255,12 +281,11 @@ describe('runFlagshipReport', () => { baseline: SAVED_BASELINE, }); - expect(mockCreatePolicy).toHaveBeenCalledWith('us', { - label: 'Plan B', - data: { - 'gov.irs.credits.ctc.amount.base[0].amount': { '2026-01-01.2100-12-31': 3000 }, - }, - }); + expect(mockCreatePolicy).toHaveBeenCalledWith('us', { data: PLAN_B_DATA }); + // Names live on the user's records, never on the base policies. + for (const [, payload] of mockCreatePolicy.mock.calls) { + expect(payload).not.toHaveProperty('label'); + } }); test('then the baseline simulation, the calculation and the window all use it', async () => { @@ -297,6 +322,29 @@ describe('runFlagshipReport', () => { }); }); + test('then the account record links the compared reform, without its name', async () => { + const accountStore = { + create: vi.fn().mockResolvedValue({ id: 'row-1' }), + } as unknown as FlagshipReportStore; + + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: '', + provisions: [PROVISION], + currentLawId: 2, + baseline: SAVED_BASELINE, + accountReportStore: accountStore, + }); + + expect(accountStore.create).toHaveBeenCalledWith( + expect.objectContaining({ baselineReformId: 'rf-plan-b' }) + ); + expect(JSON.stringify(vi.mocked(accountStore.create).mock.calls[0][0])).not.toContain( + 'Plan B' + ); + }); + test('then the report meta records it by id and name', async () => { await runFlagshipReport({ countryId: 'us', @@ -386,4 +434,184 @@ describe('runFlagshipReport', () => { ).rejects.toThrow(/no provisions/); expect(mockCreatePolicy).not.toHaveBeenCalled(); }); + + test('given a signed-out run then only the v3 keys are written, never the legacy ones', async () => { + // When + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: 'Federal · Introduced', + provisions: [PROVISION], + currentLawId: 2, + reformId: 'rf-local1', + }); + + // Then + const records = JSON.parse(localStorage.getItem('pe-flagship-reports') ?? '[]'); + expect(records).toEqual([ + expect.objectContaining({ + apiReportId: '55', + title: 'CTC expansion', + reformId: 'rf-local1', + }), + ]); + expect(localStorage.getItem(REPORT_META_KEY)).not.toBeNull(); + for (const legacyKey of LEGACY_KEYS) { + expect(localStorage.getItem(legacyKey)).toBeNull(); + } + }); + + test('given a signed-in run then exactly one account association is written', async () => { + // Given + const accountStore = { + create: vi.fn().mockResolvedValue({ id: REPORT_ROW_ID }), + } as unknown as FlagshipReportStore; + + // When + const reportId = await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: 'Federal · Introduced', + provisions: [PROVISION], + currentLawId: 2, + reformId: REFORM_ROW_ID, + year: 2027, + accountReportStore: accountStore, + }); + + // Then: the account row's ID is the report's link, which anyone can open + expect(reportId).toBe(REPORT_ROW_ID); + expect(mockCreateReport).toHaveBeenCalledWith('us', { + simulation_1_id: 101, + simulation_2_id: 102, + year: '2027', + }); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenCalledWith({ + countryId: 'us', + apiReportId: '55', + title: 'CTC expansion', + sourceNote: 'Federal · Introduced', + provisions: [PROVISION], + year: '2027', + reformId: REFORM_ROW_ID, + budgetWindowYears: null, + baselineReformId: null, + }); + expect(localStorage.getItem('pe-flagship-reports')).toBeNull(); + for (const legacyKey of LEGACY_KEYS) { + expect(localStorage.getItem(legacyKey)).toBeNull(); + } + expect(readReportMeta(REPORT_ROW_ID)?.title).toBe('CTC expansion'); + }); + + test('given a run with a budget window then the account record keeps the window', async () => { + // Given + const accountStore = { + create: vi.fn().mockResolvedValue({ id: REPORT_ROW_ID }), + } as unknown as FlagshipReportStore; + + // When + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: '', + provisions: [PROVISION], + currentLawId: 2, + windowYears: 10, + accountReportStore: accountStore, + }); + + // Then: a reload on any device reads it back from the record + expect(accountStore.create).toHaveBeenCalledWith( + expect.objectContaining({ budgetWindowYears: 10 }) + ); + }); + + test('given a signed-in run then its provenance stays out of localStorage and goes on sign-out', async () => { + // Given + const accountStore = { + create: vi.fn().mockResolvedValue({ id: REPORT_ROW_ID }), + } as unknown as FlagshipReportStore; + + // When + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: 'Federal · Introduced', + provisions: [PROVISION], + currentLawId: 2, + accountReportStore: accountStore, + }); + + // Then: the next person on this browser cannot find it + expect(localStorage.getItem(REPORT_META_KEY)).toBeNull(); + expect(readReportMeta(REPORT_ROW_ID)?.title).toBe('CTC expansion'); + forgetAccountReportMeta(); + expect(readReportMeta(REPORT_ROW_ID)).toBeNull(); + }); + + test('given the account association fails then the signed-in run fails', async () => { + // Given + const accountStore = { + create: vi.fn().mockRejectedValue(new Error('Saved work is temporarily unavailable.')), + } as unknown as FlagshipReportStore; + + // When / Then + await expect( + runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: '', + provisions: [PROVISION], + currentLawId: 2, + accountReportStore: accountStore, + }) + ).rejects.toThrow('Saved work is temporarily unavailable.'); + }); +}); + +describe('reportMetaFromRecord', () => { + const accountRecord: FlagshipReportRecord = { + id: 'row-1', + countryId: 'us', + apiReportId: '55', + title: 'CTC expansion', + sourceNote: 'Hand-built draft', + provisions: [PROVISION], + createdAt: '2026-10-14T00:00:00Z', + budgetWindowYears: 10, + }; + + test('given the compared reform, named by the caller, then the details carry it', () => { + const baseline = { reformId: 'rf-plan-b', policyId: '88', label: 'Plan B' }; + + expect( + reportMetaFromRecord({ ...accountRecord, baselineReformId: 'rf-plan-b' }, '2027', baseline) + ?.baseline + ).toEqual(baseline); + expect(reportMetaFromRecord(accountRecord, '2027')).not.toHaveProperty('baseline'); + }); + + test('given a record with a budget window then the window starts in the report year', () => { + expect(reportMetaFromRecord(accountRecord, '2027')).toEqual({ + title: 'CTC expansion', + sourceNote: 'Hand-built draft', + provisions: [PROVISION], + createdAt: '2026-10-14T00:00:00Z', + budgetWindow: { startYear: 2027, windowSize: 10 }, + }); + }); + + test('given no window, or no report year yet, then the details carry no window', () => { + expect( + reportMetaFromRecord({ ...accountRecord, budgetWindowYears: null }, '2027') + ).not.toHaveProperty('budgetWindow'); + expect(reportMetaFromRecord(accountRecord, undefined)).not.toHaveProperty('budgetWindow'); + }); + + test('given no record, or one without provisions, then there are no details', () => { + expect(reportMetaFromRecord(null, '2027')).toBeNull(); + expect(reportMetaFromRecord({ ...accountRecord, provisions: [] }, '2027')).toBeNull(); + }); }); diff --git a/app/src/tests/unit/libs/flagship/storeText.test.ts b/app/src/tests/unit/libs/flagship/storeText.test.ts index c017c3985..d84b5ffac 100644 --- a/app/src/tests/unit/libs/flagship/storeText.test.ts +++ b/app/src/tests/unit/libs/flagship/storeText.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'vitest'; -import { ReformAdapter } from '@/adapters/ReformAdapter'; -import { clampStoreText, MAX_STORE_TEXT_LENGTH } from '@/libs/flagship/storeText'; +import { + clampStoreText, + MAX_STORE_NAME_LENGTH, + MAX_STORE_NOTE_LENGTH, +} from '@/libs/flagship/storeText'; describe('clampStoreText', () => { test('given text within the limit then it passes unchanged, as do null and undefined', () => { @@ -9,28 +12,36 @@ describe('clampStoreText', () => { expect(clampStoreText(undefined)).toBeUndefined(); }); - test('given a long bill title then it is cut to the store limit with an ellipsis', () => { - const title = 'A'.repeat(MAX_STORE_TEXT_LENGTH + 40); + test('given a long bill title then it is cut to the name limit with an ellipsis', () => { + const title = 'A'.repeat(MAX_STORE_NAME_LENGTH + 40); const clamped = clampStoreText(title); - expect(clamped).toHaveLength(MAX_STORE_TEXT_LENGTH); + expect(clamped).toHaveLength(MAX_STORE_NAME_LENGTH); expect(clamped.endsWith('…')).toBe(true); }); - test('given a reform with a long label then its create and update payloads keep within the limit', () => { - const label = 'B'.repeat(MAX_STORE_TEXT_LENGTH + 1); + test('given a long note then it is cut to the note limit', () => { + const note = 'B'.repeat(MAX_STORE_NOTE_LENGTH + 1); - const created = ReformAdapter.toCreationPayload({ - countryId: 'us', - label, - parameters: [], - baseline: 'current-law', - provenance: { source: 'bill' }, - }); - const updated = ReformAdapter.toUpdatePayload({ label }); + expect(clampStoreText(note, MAX_STORE_NOTE_LENGTH)).toHaveLength(MAX_STORE_NOTE_LENGTH); + }); + + test('given an emoji at the cut then it is kept whole, never split into half a character', () => { + // 253 letters put the emoji (two UTF-16 units) across the old cut point. + const title = `${'A'.repeat(MAX_STORE_NAME_LENGTH - 2)}🎉${'B'.repeat(20)}`; + + const clamped = clampStoreText(title); + + expect(Array.from(clamped)).toHaveLength(MAX_STORE_NAME_LENGTH); + expect(clamped.endsWith('🎉…')).toBe(true); + expect(clamped.isWellFormed()).toBe(true); + }); + + test('given a name with emoji within the limit in characters then it is not cut', () => { + // 250 emoji are 500 UTF-16 units but 250 characters, as the API counts. + const title = '🎉'.repeat(MAX_STORE_NAME_LENGTH - 5); - expect(created.label).toHaveLength(MAX_STORE_TEXT_LENGTH); - expect(updated.label).toHaveLength(MAX_STORE_TEXT_LENGTH); + expect(clampStoreText(title)).toBe(title); }); }); diff --git a/app/src/tests/unit/libs/flagship/v3CleanSlate.test.tsx b/app/src/tests/unit/libs/flagship/v3CleanSlate.test.tsx new file mode 100644 index 000000000..8775d97ba --- /dev/null +++ b/app/src/tests/unit/libs/flagship/v3CleanSlate.test.tsx @@ -0,0 +1,107 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; +import { LocalStorageReformStore } from '@/api/reformStore'; +import { MOCK_USER_ID } from '@/constants'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { runFlagshipReport } from '@/libs/flagship/runReport'; +import { store } from '@/store'; + +/** + * v3 is a clean slate: it never reads or writes the legacy calculator's + * saved-work keys, signed in or out. + */ + +vi.mock('@/api/policy', () => ({ + createPolicy: vi.fn().mockResolvedValue({ result: { policy_id: '77' } }), + fetchPolicyById: vi.fn(), +})); +vi.mock('@/api/simulation', () => ({ + createSimulation: vi.fn().mockResolvedValue({ result: { simulation_id: '101' } }), + fetchSimulationById: vi.fn(), +})); +vi.mock('@/api/report', () => ({ + createReport: vi.fn().mockResolvedValue({ id: 615 }), + fetchReportById: vi.fn(), +})); + +const LEGACY_KEYS = [ + 'user-policy-associations', + 'user-population-households', + 'user-simulation-associations', + 'user-report-associations', + 'user-geographic-associations', +]; + +const touchedKeys = new Set<string>(); + +beforeEach(() => { + localStorage.clear(); + touchedKeys.clear(); + for (const method of ['getItem', 'setItem', 'removeItem'] as const) { + const original = localStorage[method].bind(localStorage); + vi.spyOn(localStorage, method).mockImplementation((key: string, ...rest: string[]) => { + touchedKeys.add(key); + return (original as (...args: string[]) => string | null)(key, ...rest); + }); + } +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function legacyKeysTouched(): string[] { + return LEGACY_KEYS.filter((key) => touchedKeys.has(key)); +} + +describe('v3 and the legacy calculator storage', () => { + test('given a signed-out run, reform save and record listing then no legacy key is touched', async () => { + // When + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: 'Hand-built draft', + provisions: [{ path: 'gov.x', breadcrumb: 'X', unit: null, baselineValue: 1, value: 2 }], + currentLawId: 2, + }); + await new LocalStorageReformStore().create({ + countryId: 'us', + label: 'CTC expansion', + parameters: [], + baseline: 'current-law', + provenance: { source: 'manual' }, + }); + await new LocalStorageFlagshipReportStore().findMine('us'); + + // Then + expect(touchedKeys.has('pe-flagship-reports')).toBe(true); + expect(legacyKeysTouched()).toEqual([]); + }); + + test('given an old sur- report link then it resolves to nothing without reading legacy storage', () => { + // Given + localStorage.setItem( + 'user-report-associations', + JSON.stringify([{ id: 'sur-old1', reportId: '615', userId: MOCK_USER_ID, countryId: 'us' }]) + ); + touchedKeys.clear(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + <ReduxProvider store={store}> + <QueryClientProvider client={new QueryClient()}> + <CountryProvider value="us">{children}</CountryProvider> + </QueryClientProvider> + </ReduxProvider> + ); + + // When + const { result } = renderHook(() => useFlagshipReport('sur-old1'), { wrapper }); + + // Then + expect(result.current).toMatchObject({ report: undefined, isLoading: false }); + expect(legacyKeysTouched()).toEqual([]); + }); +}); diff --git a/app/src/tests/unit/libs/storeBackend.test.ts b/app/src/tests/unit/libs/storeBackend.test.ts deleted file mode 100644 index baf4165f5..000000000 --- a/app/src/tests/unit/libs/storeBackend.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { getStoreBackend } from '@/libs/storeBackend'; - -vi.stubGlobal('fetch', vi.fn()); - -describe('getStoreBackend', () => { - beforeEach(() => { - vi.mocked(fetch).mockReset(); - }); - - test("given the function is called then it returns 'api'", () => { - // Given / When - const result = getStoreBackend(); - - // Then - expect(result).toBe('api'); - }); - - test('given multiple invocations then it consistently returns api', () => { - // Given / When - const results = [getStoreBackend(), getStoreBackend(), getStoreBackend()]; - - // Then - expect(results).toEqual(['api', 'api', 'api']); - }); - - test('given the return type then it satisfies the StoreBackend union', () => { - // Given / When - const result = getStoreBackend(); - - // Then - const validBackends = ['api', 'localStorage']; - expect(validBackends).toContain(result); - }); -}); diff --git a/app/src/tests/unit/pages/flagship/Account.page.test.tsx b/app/src/tests/unit/pages/flagship/Account.page.test.tsx new file mode 100644 index 000000000..f5117cd99 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/Account.page.test.tsx @@ -0,0 +1,172 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import AccountPage from '@/pages/flagship/Account.page'; +import { + errorResponse, + itemResponse, + mockMeItem, + noContentResponse, + okResponse, + TEST_ACCESS_TOKEN, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; + +const fetchMock = vi.fn(); + +function renderPage(auth: AuthContextValue, countryId = 'us') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + <QueryClientProvider client={queryClient}> + <AuthTestProvider value={auth}> + <AccountPage /> + </AuthTestProvider> + </QueryClientProvider>, + countryId + ); +} + +/** Answers GET /v2/me with the user, and everything else with `other`. */ +function answerMe(other: () => Response = () => noContentResponse()) { + fetchMock.mockImplementation(async (url: string, init: RequestInit) => + new URL(url).pathname === '/v2/me' && init.method === 'GET' ? itemResponse(mockMeItem) : other() + ); +} + +beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); + +describe('AccountPage', () => { + test('given sign-in is disabled then the page says so', () => { + renderPage(makeAuthValue({ status: 'disabled' })); + + expect(screen.getByText('Signing in is not available here yet.')).toBeInTheDocument(); + }); + + test('given a signed-out visitor then the page offers sign-in', async () => { + // Given + const user = userEvent.setup(); + const auth = makeAuthValue(); + renderPage(auth); + + // When + await user.click(screen.getByRole('button', { name: 'Sign in' })); + + // Then + expect(auth.signIn).toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given a signed-in user then the page loads their PolicyEngine user from /v2/me', async () => { + // Given + answerMe(); + + // When + renderPage(signedInAuthValue()); + + // Then + expect(await screen.findByText(mockMeItem.id)).toBeInTheDocument(); + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.policyengine.org/v2/me?country_id=us'); + expect(init.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + }); + + test('given /v2/me fails then the error shows with Try again, and nothing falls back', async () => { + // Given + fetchMock.mockResolvedValueOnce(errorResponse(503, 'Saved work is temporarily unavailable.')); + const user = userEvent.setup(); + renderPage(signedInAuthValue()); + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not load your PolicyEngine account: Saved work is temporarily unavailable/ + ); + + // When + answerMe(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText(mockMeItem.id)).toBeInTheDocument(); + }); + + test('given "Download my data" then the export is fetched with the session token', async () => { + // Given + const user = userEvent.setup(); + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:export'); + // jsdom has no revokeObjectURL + const revokeObjectURL = vi.fn(); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: revokeObjectURL, + }); + answerMe(() => + okResponse({ user: mockMeItem, policies: [], households: [], simulations: [], reports: [] }) + ); + renderPage(signedInAuthValue()); + + // When + await user.click(await screen.findByRole('button', { name: /download my data/i })); + + // Then + await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); + const [url, init] = fetchMock.mock.calls[1]; + expect(url).toBe('https://api.policyengine.org/v2/me/export?country_id=us'); + expect(init.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:export'); + }); + + test('given a confirmed deletion then the account is deleted and the user signed out', async () => { + // Given + const user = userEvent.setup(); + const auth = signedInAuthValue(); + answerMe(); + renderPage(auth); + + // When + await user.click(await screen.findByRole('button', { name: /delete my account/i })); + const confirm = await screen.findByRole('dialog'); + await user.click( + Array.from(confirm.querySelectorAll('button')).find( + (button) => button.textContent === 'Delete my account' + )! + ); + + // Then + await waitFor(() => expect(auth.signOut).toHaveBeenCalled()); + const [url, init] = fetchMock.mock.calls[1]; + expect(url).toBe('https://api.policyengine.org/v2/me?country_id=us'); + expect(init.method).toBe('DELETE'); + }); + + test('given a country without account storage then the page points to the US and UK', () => { + renderPage(signedInAuthValue(), 'ca'); + + expect( + screen.getByText(/accounts store work for the us and uk calculators/i) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /delete my account/i })).not.toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test.each(['ca', 'ng', 'il'])( + 'given a signed-out visitor in %s then the page points to the US and UK, with no sign-in', + (countryId) => { + renderPage(makeAuthValue(), countryId); + + expect( + screen.getByText(/accounts store work for the us and uk calculators/i) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /sign out/i })).not.toBeInTheDocument(); + } + ); +}); diff --git a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx new file mode 100644 index 000000000..02f89b847 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx @@ -0,0 +1,153 @@ +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { UNAUTHENTICATED_USER_HEADER } from '@/api/v2UserClient'; +import AccountUpgradeNotice from '@/components/auth/AccountUpgradeNotice'; +import { NavigationProvider } from '@/contexts/NavigationContext'; +import { + dismissAccountUpgradeNotice, + UPGRADE_FAILURE_MESSAGE, + UPGRADE_SUCCESS_MESSAGE, +} from '@/libs/auth/accountUpgrade'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; +import { getBrowserUserId } from '@/libs/auth/browserUser'; +import AuthCallbackPage from '@/pages/flagship/AuthCallback.page'; +import { errorResponse, mockMeItem, okResponse } from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; +import type { UpgradeOutcome } from '@/types/v2User'; + +vi.mock('@/libs/auth/AuthProvider', () => ({ + takeSignInReturnTo: vi.fn(), +})); + +const fetchMock = vi.fn(); +const navigation = { push: vi.fn(), replace: vi.fn(), back: vi.fn() }; + +function renderCallback(auth: AuthContextValue) { + return render( + <NavigationProvider value={navigation}> + <AuthTestProvider value={auth}> + <AuthCallbackPage /> + <AccountUpgradeNotice /> + </AuthTestProvider> + </NavigationProvider> + ); +} + +/** Answers the upgrade with `outcome`, or with a failure. */ +function answerUpgrade(answer: UpgradeOutcome | 'fail') { + fetchMock.mockImplementation(async (url: string) => { + if (new URL(url).pathname !== '/v2/me/upgrade') { + return new Response('', { status: 404 }); + } + return answer === 'fail' + ? errorResponse(503, 'Saved work is temporarily unavailable.') + : okResponse({ item: mockMeItem, outcome: answer }); + }); +} + +function upgradeRequest(index = 0) { + const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit]; + return { url: new URL(url), headers: init.headers as Record<string, string> }; +} + +beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + vi.mocked(takeSignInReturnTo).mockReturnValue('/us/reforms'); + dismissAccountUpgradeNotice(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('AuthCallbackPage', () => { + test("given this browser's user had saved work then it is upgraded into the account, then it returns", async () => { + // Given + const browserUserId = getBrowserUserId(); + answerUpgrade('converted'); + + // When + renderCallback(signedInAuthValue()); + + // Then + await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); + const request = upgradeRequest(); + expect(request.url.searchParams.get('country_id')).toBe('us'); + expect(request.headers[UNAUTHENTICATED_USER_HEADER]).toBe(browserUserId); + expect(request.headers.Authorization).toMatch(/^Bearer /); + expect(await screen.findByRole('status', { name: 'Added to your account' })).toHaveTextContent( + UPGRADE_SUCCESS_MESSAGE + ); + // The browser starts over, so whoever is next here signed out starts empty. + expect(getBrowserUserId()).not.toBe(browserUserId); + }); + + test('given nothing to upgrade then it returns without a notice', async () => { + // Given + answerUpgrade('none'); + + // When + renderCallback(signedInAuthValue()); + + // Then + await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('status', { name: 'Added to your account' })).not.toBeInTheDocument(); + }); + + test('given the upgrade fails then the browser keeps its user, and Try again upgrades it', async () => { + // Given + const browserUserId = getBrowserUserId(); + answerUpgrade('fail'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const user = userEvent.setup(); + renderCallback(signedInAuthValue()); + expect( + await screen.findByRole('alert', { name: 'Not added to your account yet' }) + ).toHaveTextContent(UPGRADE_FAILURE_MESSAGE); + await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); + expect(getBrowserUserId()).toBe(browserUserId); + + // When + answerUpgrade('merged'); + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText(UPGRADE_SUCCESS_MESSAGE)).toBeInTheDocument(); + expect(upgradeRequest(1).headers[UNAUTHENTICATED_USER_HEADER]).toBe(browserUserId); + expect(getBrowserUserId()).not.toBe(browserUserId); + }); + + test('given sign-in did not complete then the error shows and nothing is called', () => { + // When + renderCallback(makeAuthValue({ error: new Error('access_denied') })); + + // Then + expect(screen.getByText('Sign-in did not complete')).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + }); +}); + +describe('AccountUpgradeNotice', () => { + test('given the notice then it can be dismissed', async () => { + // Given + answerUpgrade('converted'); + const user = userEvent.setup(); + renderCallback(signedInAuthValue()); + await screen.findByRole('status', { name: 'Added to your account' }); + + // When + await user.click(screen.getByRole('button', { name: 'Dismiss' })); + + // Then + expect(screen.queryByRole('status', { name: 'Added to your account' })).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/Reforms.test.tsx b/app/src/tests/unit/pages/flagship/Reforms.test.tsx index 9737aced4..e289eb8ce 100644 --- a/app/src/tests/unit/pages/flagship/Reforms.test.tsx +++ b/app/src/tests/unit/pages/flagship/Reforms.test.tsx @@ -1,8 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, renderWithCountry, screen, userEvent, within } from '@test-utils'; +import { render, renderWithCountry, screen, userEvent, waitFor, within } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { CURRENT_YEAR } from '@/constants'; import { clearDraftReform, draftYear, getDraftReform } from '@/libs/draftReform'; +import { saveReportMeta } from '@/libs/flagship/runReport'; import ReformsPage from '@/pages/flagship/Reforms.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; @@ -54,6 +55,18 @@ vi.mock('@/api/report', async (importOriginal) => ({ fetchReportById: async (_countryId: string, reportId: string) => ({ id: reportId, status: reportId === '901' ? 'complete' : 'pending', + year: reportId === '904' ? '2028' : '2026', + simulation_1_id: '9', + }), +})); + +// Every report's baseline simulation: Maryland, run against policy 88. +vi.mock('@/api/simulation', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/api/simulation')>()), + fetchSimulationById: async () => ({ + population_type: 'geography', + population_id: 'state/md', + policy_id: '88', }), })); @@ -728,6 +741,32 @@ describe('ReformsPage', () => { expect(mockNavigate).toHaveBeenCalledWith('/us/report/902'); }); + test('given a report record saved through the API then its year comes from the report', async () => { + // Given: API records keep no year; the API report carries it + mockFindMine.mockResolvedValue([CHAT_REFORM]); + mockFindReports.mockResolvedValue([ + { + id: 'r-api', + countryId: 'us', + apiReportId: '904', + title: 'CTC', + sourceNote: null, + provisions: [], + createdAt: '2026-10-14T09:00:00Z', + reformId: 'rf-1', + }, + ]); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + + // When + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + + // Then + const list = screen.getByRole('region', { name: 'Reports' }); + expect(await within(list).findByText('2028 report')).toBeInTheDocument(); + }); + test('given a reform with no reports then its card and detail say so', async () => { mockFindMine.mockResolvedValue([CHAT_REFORM]); const user = userEvent.setup(); @@ -903,4 +942,223 @@ describe('ReformsPage', () => { expect(await screen.findByText(/no analyzed bills yet/i)).toBeInTheDocument(); }); + + test('given a reform whose changes could not load then its card and detail say so', async () => { + mockFindMine.mockResolvedValue([ + CHAT_REFORM, + { + ...CHAT_REFORM, + id: 'rf-2', + label: 'Unloadable reform', + parameters: [], + parametersUnavailable: true, + }, + ]); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + + // The other reform still lists normally + expect(await screen.findByText('CTC to $3,600 for children under 6')).toBeInTheDocument(); + expect(screen.getByText('Changes could not be loaded')).toBeInTheDocument(); + + await user.click(screen.getByText('Unloadable reform')); + + expect(screen.getByRole('alert')).toHaveTextContent(/changes could not be loaded/i); + expect(screen.getByRole('button', { name: /view full impact report/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /delete/i })).toBeEnabled(); + }); + + test('given a reform whose changes could not load then "Try again" reloads the list', async () => { + seedDeductionMetadata(); + mockFindMine.mockResolvedValue([ + { ...CHAT_REFORM, parameters: [], parametersUnavailable: true }, + ]); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + mockFindMine.mockResolvedValue([CHAT_REFORM]); + + await user.click(screen.getByRole('button', { name: 'Try again' })); + + await waitFor(() => + expect(screen.getByRole('table', { name: 'Parameters' })).toHaveTextContent('3600') + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /view full impact report/i })).toBeEnabled(); + }); + + test('given a saved reform that changes nothing then its report cannot run, and it says why', async () => { + // Given: current law already sets the base amount to $2,200 from 2026 + seedDeductionMetadata(); + try { + mockFindMine.mockResolvedValue([ + { + ...CHAT_REFORM, + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 2200 }], + }, + ], + }, + ]); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + + // When + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + + // Then + expect(screen.getByRole('button', { name: /view full impact report/i })).toBeDisabled(); + expect( + screen.getByText(/nothing in this reform differs from current law/i) + ).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + } finally { + store.dispatch(clearMetadata()); + } + }); + + describe('given a report to open again or re-run', () => { + beforeEach(() => localStorage.clear()); + + /** A `/v2/user-reports` row's ID: an account report's link. */ + const ACCOUNT_REPORT_ROW_ID = '44444444-4444-4444-8444-444444444444'; + + /** The saved reform the reports were compared with. */ + const PLAN_B_REFORM: Reform = { + ...CHAT_REFORM, + id: 'rf-plan-b', + label: 'Plan B', + policyId: '87', + }; + + /** CHAT_REFORM's provision, as its report ran it. */ + const RUN_PROVISION = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Base amount', + unit: 'currency-USD', + baselineValue: 2000, + value: 3600, + intervals: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }; + + /** + * An account report: the row keeps no year (its v1 report, 904, ran + * 2028), a ten-year window, and the saved reform it was compared with. + */ + const ACCOUNT_REPORT = { + id: ACCOUNT_REPORT_ROW_ID, + countryId: 'us', + apiReportId: '904', + title: 'CTC', + sourceNote: 'Hand-built', + provisions: [RUN_PROVISION], + createdAt: '2026-10-14T09:00:00Z', + reformId: 'rf-1', + budgetWindowYears: 10, + baselineReformId: 'rf-plan-b', + }; + + /** A report kept in this browser: its link is the v1 report's number. */ + const BROWSER_REPORT = { + id: 'lfr-local1', + countryId: 'us', + apiReportId: '902', + title: 'CTC', + sourceNote: 'Hand-built', + provisions: [RUN_PROVISION], + year: '2026', + createdAt: '2026-10-14T09:00:00Z', + reformId: 'rf-1', + }; + + async function openReform(reports: object[]) { + mockFindMine.mockResolvedValue([CHAT_REFORM, PLAN_B_REFORM]); + mockFindReports.mockResolvedValue(reports); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + return user; + } + + test('given an account report then Re-run keeps its year, window and comparison, linked to the same reform', async () => { + // Given: no run details in this page session, only the row + const user = await openReform([ACCOUNT_REPORT]); + const list = screen.getByRole('region', { name: 'Reports' }); + expect(within(list).getByText('Compared with Plan B')).toBeInTheDocument(); + + // When + await user.click(within(list).getByRole('button', { name: /re-run/i })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); + const [title, sourceNote, provisions, reformId, year, settings] = mockRun.mock.calls[0]; + expect({ title, sourceNote, provisions, reformId, year }).toEqual({ + title: 'CTC', + sourceNote: 'Hand-built', + provisions: [RUN_PROVISION], + reformId: 'rf-1', + year: 2028, + }); + expect(settings).toEqual({ + region: 'state/md', + windowYears: 10, + baseline: { reformId: 'rf-plan-b', label: 'Plan B', policyId: 88 }, + }); + }); + + test('given a report kept in this browser then Re-run keeps the details stored under its v1 report', async () => { + // Given + saveReportMeta('902', { + title: 'CTC', + sourceNote: 'Hand-built', + provisions: [RUN_PROVISION], + createdAt: '2026-10-14T09:00:00Z', + budgetWindow: { startYear: 2026, windowSize: 3 }, + baseline: { reformId: 'rf-plan-b', policyId: '88', label: 'Plan B' }, + }); + const user = await openReform([BROWSER_REPORT]); + + // When + await user.click(screen.getByRole('button', { name: /re-run/i })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); + const [, , , reformId, year, settings] = mockRun.mock.calls[0]; + expect({ reformId, year }).toEqual({ reformId: 'rf-1', year: 2026 }); + expect(settings).toEqual({ + region: 'state/md', + windowYears: 3, + baseline: { reformId: 'rf-plan-b', label: 'Plan B', policyId: 88 }, + }); + }); + + test('given a finished account report then View results opens its link, not the v1 report', async () => { + // Given: report 901 is complete + const user = await openReform([{ ...ACCOUNT_REPORT, apiReportId: '901' }]); + + // When + await user.click(await screen.findByRole('button', { name: /view results/i })); + + // Then + expect(mockNavigate).toHaveBeenCalledWith(`/us/report/${ACCOUNT_REPORT_ROW_ID}`); + }); + + test('given an account report of the same values then View full impact report opens its link', async () => { + // Given: against current law, in the reform's year (its v1 report's: 2026) + const user = await openReform([ + { ...ACCOUNT_REPORT, apiReportId: '905', budgetWindowYears: null, baselineReformId: null }, + ]); + + // When + await user.click(screen.getByRole('button', { name: /view full impact report/i })); + + // Then + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith(`/us/report/${ACCOUNT_REPORT_ROW_ID}`) + ); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx b/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx new file mode 100644 index 000000000..08c5e8224 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx @@ -0,0 +1,216 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FlagshipReportData } from '@/hooks/useFlagshipReport'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + ASSOCIATION_IDS, + errorResponse, + itemResponse, + LEGACY_IDS, + sharedReportItem, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + EXISTING_REPORT, + EXISTING_SIMULATIONS, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { AuthTestProvider, makeAuthValue } from '@/tests/fixtures/libs/auth/authMocks'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; + +const mockUseFlagshipReport = vi.fn(); +const mockCalcStatus = vi.fn(); +const fetchMock = vi.fn(); + +// The real isApiReportId: which links open is what these tests are about. +vi.mock('@/hooks/useFlagshipReport', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/hooks/useFlagshipReport')>()), + useFlagshipReport: (apiReportId: string) => mockUseFlagshipReport(apiReportId), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => mockCalcStatus(), +})); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/api/metadata')>()), + fetchMetadata: () => new Promise(() => {}), +})); + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: () => undefined, +})); + +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); + +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/components/flagship/CalibrationMatches')>()), + useCalibrationMatches: () => null, +})); + +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/components/flagship/ValidationPanel')>()), + useModelTrackRecord: () => null, +})); + +/** A run that failed: the page then shows the report's title over the error. */ +const FAILED_RUN = { + status: 'error', + isComplete: false, + isError: true, + error: { message: 'The simulation failed' }, + result: null, +}; + +/** A finished run: the page shows the report, with its year switcher. */ +const COMPLETE_RUN = { + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), +}; + +/** Where the report page keeps the budget-window years asked for, per link. */ +const REQUESTED_YEARS_KEY_PREFIX = 'pe-flagship-report-years-'; + +const requestedYearsKeys = () => + Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index)).filter((key) => + key?.startsWith(REQUESTED_YEARS_KEY_PREFIX) + ); + +const SHARED_PATH = `/v2/shared-reports/${ASSOCIATION_IDS.REPORT}`; + +function reportData(overrides: Partial<FlagshipReportData> = {}): FlagshipReportData { + return { + report: undefined, + simulations: [], + policies: [], + isLoading: false, + error: null, + notFound: false, + failedPolicyIds: [], + retry: vi.fn(), + ...overrides, + }; +} + +/** Opens a report link; sign-in off unless a session is given. */ +function renderPage(link: string, auth?: AuthContextValue) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const page = ( + <QueryClientProvider client={queryClient}> + <FlagshipReportPage userReportId={link} /> + </QueryClientProvider> + ); + return render(auth ? <AuthTestProvider value={auth}>{page}</AuthTestProvider> : page); +} + +describe('FlagshipReportPage links', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + store.dispatch(clearMetadata()); + mockCalcStatus.mockReturnValue(FAILED_RUN); + mockUseFlagshipReport.mockImplementation((apiReportId: string) => + apiReportId + ? reportData({ report: EXISTING_REPORT, simulations: EXISTING_SIMULATIONS }) + : reportData() + ); + fetchMock.mockImplementation(async (url: string) => + new URL(url).pathname === SHARED_PATH + ? itemResponse(sharedReportItem()) + : errorResponse(404, 'not found') + ); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("given a saved report's link then anyone opens it with its owner's details", async () => { + // When: opened by someone who never ran it, with sign-in off + renderPage(ASSOCIATION_IDS.REPORT); + + // Then: the owner's title, over the v1 report the saved report names + expect(await screen.findByRole('heading', { name: 'CTC to $3,600' })).toBeInTheDocument(); + expect(mockUseFlagshipReport).toHaveBeenLastCalledWith(String(LEGACY_IDS.REPORT)); + const sharedRead = fetchMock.mock.calls.find(([url]) => new URL(url).pathname === SHARED_PATH)!; + expect(sharedRead[1].headers).not.toHaveProperty('Authorization'); + expect(sharedRead[1].headers).not.toHaveProperty('PolicyEngine-Unauthenticated-User'); + }); + + test('given a signed-out browser that did not run it then the link opens the same way', async () => { + // When + renderPage(ASSOCIATION_IDS.REPORT, makeAuthValue()); + + // Then + expect(await screen.findByRole('heading', { name: 'CTC to $3,600' })).toBeInTheDocument(); + }); + + test('given a link to a deleted report then it says not found', async () => { + // Given + fetchMock.mockResolvedValue(errorResponse(404, 'not found')); + + // When + renderPage(ASSOCIATION_IDS.REPORT); + + // Then + expect(await screen.findByRole('heading', { name: 'Report not found' })).toBeInTheDocument(); + expect(mockUseFlagshipReport).not.toHaveBeenCalledWith(String(LEGACY_IDS.REPORT)); + }); + + test('given an old v1 report link where accounts exist then it no longer opens', () => { + // When + renderPage(String(LEGACY_IDS.REPORT), makeAuthValue()); + + // Then + expect(screen.getByText(/old report link, which no longer opens/i)).toBeInTheDocument(); + expect(mockUseFlagshipReport).not.toHaveBeenCalledWith(String(LEGACY_IDS.REPORT)); + }); + + test('given a v1 report link with sign-in off then it opens, as nothing is shared', () => { + // When + renderPage(String(LEGACY_IDS.REPORT)); + + // Then + expect(mockUseFlagshipReport).toHaveBeenLastCalledWith(String(LEGACY_IDS.REPORT)); + expect(screen.queryByText(/old report link/i)).not.toBeInTheDocument(); + }); + + test('given an old browser-only link then it no longer opens', () => { + // When + renderPage('sur-mtqcsp78hwc7'); + + // Then + expect(screen.getByText(/old report link, which no longer opens/i)).toBeInTheDocument(); + }); + + describe('given a finished report with a budget window', () => { + beforeEach(() => { + mockCalcStatus.mockReturnValue(COMPLETE_RUN); + fetchMock.mockImplementation(async (url: string) => + new URL(url).pathname === SHARED_PATH + ? itemResponse(sharedReportItem({ budget_window_years: 3 })) + : errorResponse(404, 'not found') + ); + }); + + test("given a saved report's link then the years asked for stay out of this browser's storage", async () => { + // Given + const user = userEvent.setup(); + renderPage(ASSOCIATION_IDS.REPORT); + + // When: another year of the window is asked for + await user.click(await screen.findByRole('tab', { name: /^2027,/ })); + + // Then: the link, which opens the report for anyone, is not left behind + expect(screen.getByRole('tab', { name: /^2027,/ })).toHaveAttribute('aria-selected', 'true'); + expect(requestedYearsKeys()).toEqual([]); + }); + }); +}); diff --git a/app/src/types/ingredients/Reform.ts b/app/src/types/ingredients/Reform.ts index 524cacfae..9ce984445 100644 --- a/app/src/types/ingredients/Reform.ts +++ b/app/src/types/ingredients/Reform.ts @@ -35,8 +35,8 @@ export type ReformBaseline = (typeof reformBaselines)[number]; * record in the main PolicyEngine API. When a reform is simulated, it is * materialized into a canonical Policy via the existing createPolicy() * flow and the resulting id is linked back via policyId. It carries no - * user identifier: the browser that saved it keeps it in its own index - * (see libs/flagship/ownedItems). + * user identifier: its owner comes from the session (see + * hooks/useFlagshipStores). */ export interface Reform { id?: string; @@ -45,6 +45,12 @@ export interface Reform { /** Canonical policy id in api.policyengine.org once materialized */ policyId?: string | null; parameters: Parameter[]; + /** + * Set when the reform's changes could not be loaded (a saved reform + * whose v1 policy did not load). `parameters` is then empty and must + * not be shown, run, matched or saved as the reform's changes. + */ + parametersUnavailable?: boolean; baseline: ReformBaseline; provenance: ReformProvenance; createdAt?: string; diff --git a/app/src/types/metadata/reformMetadata.ts b/app/src/types/metadata/reformMetadata.ts deleted file mode 100644 index aa525aa9f..000000000 --- a/app/src/types/metadata/reformMetadata.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { countryIds } from '@/libs/countries'; -import { ReformBaseline, ReformConfidence, ReformSource } from '@/types/ingredients/Reform'; - -/** - * Wire format for reform parameters. - * Uses snake_case to match API conventions. - */ -export interface ReformParameterMetadata { - name: string; - values: { - start_date: string; - end_date: string; - value: any; - }[]; -} - -export interface ReformProvenanceMetadata { - source: ReformSource; - ref?: string | null; - confidence?: ReformConfidence | null; -} - -/** - * API response format for reforms - * Uses snake_case to match API conventions - */ -export interface ReformMetadata { - id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - policy_id?: string | null; - parameters: ReformParameterMetadata[]; - baseline: ReformBaseline; - provenance: ReformProvenanceMetadata; - created_at?: string; - updated_at?: string; -} - -/** - * API creation payload format for reforms - * Uses snake_case to match API conventions - */ -export type ReformCreationMetadata = Omit<ReformMetadata, 'id' | 'created_at' | 'updated_at'>; - -/** - * The creation response: the reform plus its edit token, which the - * server returns this one time and keeps only as a hash. - */ -export interface ReformCreatedMetadata extends ReformMetadata { - edit_token: string; -} - -/** - * API update payload format for reforms; all content fields optional - */ -export type ReformUpdateMetadata = Partial< - Omit<ReformMetadata, 'id' | 'country_id' | 'created_at' | 'updated_at'> ->; diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts new file mode 100644 index 000000000..63059ed0d --- /dev/null +++ b/app/src/types/v2User.ts @@ -0,0 +1,193 @@ +/** + * Wire types for the PolicyEngine API's saved-work routes (`/v2/me*` and + * `/v2/user-*`). Field names are snake_case, as on the wire. The server + * derives the user from the session's bearer token, or signed out from + * this browser's unauthenticated user, so request bodies never carry a + * `user_id`; responses do. + */ + +/** Countries with account storage. Every other country stays on localStorage. */ +export const ASSOCIATION_COUNTRY_IDS = ['us', 'uk'] as const; +export type AssociationCountryId = (typeof ASSOCIATION_COUNTRY_IDS)[number]; + +export function isAssociationCountry(countryId: unknown): countryId is AssociationCountryId { + return ( + typeof countryId === 'string' && + (ASSOCIATION_COUNTRY_IDS as readonly string[]).includes(countryId) + ); +} + +/** `{"status": "ok", "message": null, "result": <R>}` or `{"status": "error", "message": "…"}`. */ +export interface V2Envelope<R> { + status: 'ok' | 'error'; + message: string | null; + result?: R; +} + +export interface V2ItemResult<T> { + item: T; +} + +export interface V2PageResult<T> { + items: T[]; + offset: number; + limit: number; + has_more: boolean; +} + +/** An unauthenticated user is this browser's; a standard user signs in with Auth0. */ +export type UserType = 'unauthenticated' | 'standard'; + +export interface MeItem { + /** The PolicyEngine user ID that owns every association. */ + id: string; + user_type: UserType; + /** Null for an unauthenticated user. */ + primary_country: AssociationCountryId | null; + first_name: string | null; + last_name: string | null; + email: string | null; + created_at: string; +} + +export interface ProvenanceWire { + source: 'manual' | 'chat' | 'bill' | 'tool'; + ref: string | null; + confidence: 'exact' | 'approximated' | 'partial' | null; +} + +export type BaselineWire = 'current-law' | 'current-policy'; + +interface AssociationItemBase { + id: string; + country_id: AssociationCountryId; + user_id: string; + name: string | null; + description: string | null; + created_at: string; + updated_at: string; +} + +export interface UserPolicyItem extends AssociationItemBase { + policy_id: string | null; + legacy_policy_id: number | null; + baseline: BaselineWire | null; + provenance: ProvenanceWire | null; +} + +export interface ReportIntervalWire { + parameter?: string; + start_date: string; + end_date: string; + value: unknown; +} + +export interface ReportProvisionWire { + path: string; + breadcrumb: string; + unit: string | null; + baseline_value: unknown; + value: unknown; + intervals: ReportIntervalWire[] | null; + /** How a value set from one year on grows after it; each key only when set. */ + growth?: string; + growth_start?: number; + growth_base?: number; + growth_round_to?: number; + growth_round?: 'nearest' | 'down' | 'up'; +} + +export interface UserReportItem extends AssociationItemBase { + report_id: string | null; + legacy_report_id: number | null; + user_policy_id: string | null; + /** The saved reform the report was compared with, when not current law. */ + baseline_user_policy_id: string | null; + source_note: string | null; + provisions: ReportProvisionWire[] | null; + /** ReportValidationWire on the wire; validated by validationFromWire. */ + validation: unknown | null; + /** Years in the budget window run with the report, from its year (2–75). */ + budget_window_years: number | null; + last_run_at: string | null; +} + +/** + * A saved report as anyone holding its link reads it (`/v2/shared-reports`): + * the owner and the owner's saved-reform IDs are left out, and the reform it + * was compared with is named instead. + */ +export interface SharedReportItem { + id: string; + country_id: AssociationCountryId; + legacy_report_id: number | null; + name: string | null; + source_note: string | null; + provisions: ReportProvisionWire[] | null; + /** ReportValidationWire on the wire; validated by validationFromWire. */ + validation: unknown | null; + budget_window_years: number | null; + /** The owner's saved reform the report was compared with; null for current law. */ + baseline_reform: { name: string | null } | null; + last_run_at: string | null; + created_at: string; + updated_at: string; +} + +/* Create and patch bodies (strict on the server: unknown keys are a 400). */ + +export interface UserPolicyCreateBody { + country_id: AssociationCountryId; + legacy_policy_id: number; + name?: string | null; + description?: string | null; + baseline?: BaselineWire | null; + provenance?: ProvenanceWire | null; +} + +export type UserPolicyPatchBody = Partial< + Pick< + UserPolicyCreateBody, + 'legacy_policy_id' | 'name' | 'description' | 'baseline' | 'provenance' + > +>; + +export interface UserReportCreateBody { + country_id: AssociationCountryId; + legacy_report_id: number; + name?: string | null; + description?: string | null; + user_policy_id?: string | null; + baseline_user_policy_id?: string | null; + source_note?: string | null; + provisions?: ReportProvisionWire[] | null; + validation?: unknown | null; + budget_window_years?: number | null; + last_run_at?: string | null; +} + +/* /v2/me/export */ + +/** Everything saved to the account, in every country; downloaded as is. */ +export interface MeExportResult { + /** Null for a signed-out browser that has saved nothing yet. */ + user: MeItem | null; + policies: UserPolicyItem[]; + reports: UserReportItem[]; + [otherRecords: string]: unknown; +} + +/* /v2/me/upgrade */ + +/** + * What signing in did with this browser's unauthenticated user: made it + * the new account ("converted"), moved its work into an existing account + * ("merged"), or found nothing to upgrade ("none"). + */ +export type UpgradeOutcome = 'converted' | 'merged' | 'none'; + +export interface MeUpgradeResult { + /** The signed-in account. */ + item: MeItem; + outcome: UpgradeOutcome; +} diff --git a/bun.lock b/bun.lock index c61dfc7c8..390778744 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "policyengine-monorepo", @@ -19,6 +18,7 @@ "name": "policyengine-app-v2", "version": "0.0.0", "dependencies": { + "@auth0/auth0-react": "2.27.0", "@policyengine/ui-kit": "^0.4.0", "@reduxjs/toolkit": "^2.8.2", "@tabler/icons-react": "^3.34.0", @@ -106,7 +106,7 @@ "version": "0.1.0", "dependencies": { "@anthropic-ai/sdk": "^0.116.0", - "@neondatabase/serverless": "^1.1.0", + "@auth0/auth0-react": "2.27.0", "@normy/react-query": "^0.20.0", "@policyengine/ui-kit": "^0.4.0", "@reduxjs/toolkit": "^2.8.2", @@ -117,13 +117,11 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.13", - "drizzle-orm": "^0.45.2", "framer-motion": "^12.23.24", "fuse.js": "^7.0.0", "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "^15.3.3", - "postgres": "^3.4.9", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -137,7 +135,6 @@ "tailwindcss": "^4.2.0", }, "devDependencies": { - "@electric-sql/pglite": "^0.5.4", "@tailwindcss/postcss": "^4.1.8", "@types/node": "^22.15.0", "@types/react": "^19.1.0", @@ -145,7 +142,6 @@ "@types/react-plotly.js": "^2.6.3", "@types/react-simple-maps": "^3.0.6", "@types/react-syntax-highlighter": "^15.5.0", - "drizzle-kit": "^0.31.10", "postcss": "^8.5.4", "typescript": "^5.8.0", }, @@ -194,6 +190,10 @@ }, }, }, + "overrides": { + "@auth0/auth0-auth-js": "1.15.0", + "@auth0/auth0-spa-js": "2.27.0", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -203,6 +203,12 @@ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@auth0/auth0-auth-js": ["@auth0/auth0-auth-js@1.15.0", "", { "dependencies": { "jose": "^6.0.8", "openid-client": "^6.8.0" } }, "sha512-obn/Qhxx3eNs2wV6/lLsIDIcEiSJvBEEy15hppB5Me1j2No2toa7KqQcXHBCyGX4mBBdVnxZJBjEX6E4WanYwg=="], + + "@auth0/auth0-react": ["@auth0/auth0-react@2.27.0", "", { "dependencies": { "@auth0/auth0-spa-js": "^2.27.0" }, "peerDependencies": { "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-SVDiQM1Eatv+zLzy7qyxMdcETdbGVOqXR+zaBXbMyX32xUzTNIppgcHFoD0AFLqVQrBZgTHX4ujj9D9+sfCXsQ=="], + + "@auth0/auth0-spa-js": ["@auth0/auth0-spa-js@2.27.0", "", { "dependencies": { "@auth0/auth0-auth-js": "^1.15.0", "browser-tabs-lock": "^1.3.0", "dpop": "^2.1.1", "es-cookie": "~1.3.2" } }, "sha512-E9U4v9IiowT+z26PQYT55ZAU5J1feyEV9umVX+lvnaHl43E3GvGVsTrfukhPLLWnoCRItWa2TxVQ3Svj1S4qyA=="], + "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], @@ -267,8 +273,6 @@ "@csstools/selector-specificity": ["@csstools/selector-specificity@5.0.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw=="], - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="], "@edge-runtime/format": ["@edge-runtime/format@2.2.1", "", {}, "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g=="], @@ -281,69 +285,63 @@ "@edge-runtime/vm": ["@edge-runtime/vm@3.2.0", "", { "dependencies": { "@edge-runtime/primitives": "4.1.0" } }, "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw=="], - "@electric-sql/pglite": ["@electric-sql/pglite@0.5.4", "", {}, "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g=="], - "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], - "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -483,8 +481,6 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@neondatabase/serverless": ["@neondatabase/serverless@1.1.0", "", {}, "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q=="], - "@next/env": ["@next/env@15.5.12", "", {}, "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.14", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-ogBjgsFrPPz19abP3VwcYSahbkUOMMvJjxCOYWYndw+PydeMuLuB4XrvNkNutFrTjC9St2KFULRdKID8Sd/CMQ=="], @@ -1139,6 +1135,8 @@ "browser-assert": ["browser-assert@1.2.1", "", {}, "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ=="], + "browser-tabs-lock": ["browser-tabs-lock@1.3.0", "", { "dependencies": { "lodash": ">=4.17.21" } }, "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -1367,11 +1365,9 @@ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "draw-svg-path": ["draw-svg-path@1.0.0", "", { "dependencies": { "abs-svg-path": "~0.1.1", "normalize-svg-path": "~0.1.0" } }, "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg=="], - - "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + "dpop": ["dpop@2.1.2", "", {}, "sha512-hN3XjjCcgYWmNL8DFAV1Z3alXIvLolvjis189NI/U4k4Iu/m4rBeUYObbIjnRzQYDQv4zMaXXskjVDqSkmyh7Q=="], - "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "draw-svg-path": ["draw-svg-path@1.0.0", "", { "dependencies": { "abs-svg-path": "~0.1.1", "normalize-svg-path": "~0.1.0" } }, "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg=="], "dtype": ["dtype@2.0.0", "", {}, "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg=="], @@ -1407,6 +1403,8 @@ "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], + "es-cookie": ["es-cookie@1.3.2", "", {}, "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -1433,7 +1431,7 @@ "es6-weak-map": ["es6-weak-map@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], @@ -1837,6 +1835,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1909,6 +1909,8 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], @@ -2113,6 +2115,8 @@ "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + "oauth4webapi": ["oauth4webapi@3.8.8", "", {}, "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -2133,6 +2137,8 @@ "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "openid-client": ["openid-client@6.8.8", "", { "dependencies": { "jose": "^6.2.12", "oauth4webapi": "^3.8.8" } }, "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -2221,8 +2227,6 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], - "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -2269,7 +2273,7 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], @@ -2421,8 +2425,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], @@ -2501,8 +2503,6 @@ "stylelint-scss": ["stylelint-scss@6.14.0", "", { "dependencies": { "css-tree": "^3.0.1", "is-plain-object": "^5.0.0", "known-css-properties": "^0.37.0", "mdn-data": "^2.25.0", "postcss-media-query-parser": "^0.2.3", "postcss-resolve-nested-selector": "^0.1.6", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "stylelint": "^16.8.2" } }, "sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA=="], - "sugarss": ["sugarss@4.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-WCjS5NfuVJjkQzK10s8WOBY+hhDxxNt/N6ZaGwxFZ+wN3/lKKFSaaKUNecULcTTvE4urLcKaZFQD8vO0mOZujw=="], - "supercluster": ["supercluster@7.1.5", "", { "dependencies": { "kdbush": "^3.0.0" } }, "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg=="], "superscript-text": ["superscript-text@1.0.0", "", {}, "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ=="], @@ -2781,8 +2781,6 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -2817,6 +2815,8 @@ "@storybook/builder-vite/@storybook/csf-plugin": ["@storybook/csf-plugin@8.6.15", "", { "dependencies": { "unplugin": "^1.3.1" }, "peerDependencies": { "storybook": "^8.6.15" } }, "sha512-ZLz/mtOoE1Jj2lE4pK3U7MmYrv5+lot3mGtwxGb832tcABMc97j9O+reCVxZYc7DeFbBuuEdMT9rBL/O3kXYmw=="], + "@storybook/core/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -2847,8 +2847,6 @@ "@vercel/node/@types/node": ["@types/node@20.11.0", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ=="], - "@vercel/node/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], - "@vercel/node/typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="], "@vercel/static-config/ajv": ["ajv@8.6.3", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw=="], @@ -2987,7 +2985,7 @@ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-docgen/doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], @@ -3039,61 +3037,71 @@ "victory-vendor/d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + "@plotly/d3-sankey-circular/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + "@plotly/d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + "@storybook/core/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + "@storybook/core/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + "@storybook/core/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + "@storybook/core/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + "@storybook/core/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + "@storybook/core/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + "@storybook/core/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + "@storybook/core/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + "@storybook/core/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + "@storybook/core/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + "@storybook/core/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + "@storybook/core/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + "@storybook/core/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + "@storybook/core/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + "@storybook/core/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + "@storybook/core/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@storybook/core/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "@storybook/core/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@storybook/core/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "@storybook/core/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@plotly/d3-sankey-circular/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "@storybook/core/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@plotly/d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "@storybook/core/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@storybook/core/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@storybook/core/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@storybook/core/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@storybook/core/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw=="], @@ -3127,58 +3135,6 @@ "@vercel/node/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@vercel/node/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], - - "@vercel/node/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], - - "@vercel/node/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], - - "@vercel/node/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], - - "@vercel/node/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], - - "@vercel/node/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], - - "@vercel/node/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], - - "@vercel/node/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], - - "@vercel/node/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], - - "@vercel/node/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], - - "@vercel/node/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], - - "@vercel/node/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], - - "@vercel/node/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], - - "@vercel/node/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], - - "@vercel/node/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], - - "@vercel/node/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], - - "@vercel/node/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], - - "@vercel/node/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], - - "@vercel/node/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], - - "@vercel/node/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], - - "@vercel/node/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], - - "@vercel/node/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], - - "@vercel/node/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], - - "@vercel/node/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], - - "@vercel/node/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], - - "@vercel/node/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], - "@vercel/static-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "color-normalize/color-rgba/color-parse": ["color-parse@1.4.3", "", { "dependencies": { "color-name": "^1.0.0" } }, "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A=="], @@ -3285,6 +3241,58 @@ "victory-vendor/@types/d3-interpolate/@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], diff --git a/calculator-app/.env.example b/calculator-app/.env.example new file mode 100644 index 000000000..907993782 --- /dev/null +++ b/calculator-app/.env.example @@ -0,0 +1,36 @@ +# calculator-app environment (copy to .env.local for local development). +# NEXT_PUBLIC_* values are inlined at build time; next.config.ts maps the +# API and sign-in ones onto import.meta.env.VITE_* for the shared app code, +# so the VITE_* spellings work here too. + +# --- PolicyEngine API -------------------------------------------------------- +# Origin of the PolicyEngine API (v1 artifacts and the /v2 saved-work routes). +# Defaults to https://api.policyengine.org. Staging builds must point at the +# staging API so staging artifacts never land in production. +# NEXT_PUBLIC_API_URL=https://<policyengine-api-staging host> + +# --- Accounts and sign-in (Auth0 social sign-on, v3 workspace only) --------- +# Inert unless NEXT_PUBLIC_AUTH_ENABLED=true AND all three Auth0 values are +# set; otherwise the app behaves exactly as it does without accounts +# (everything saved in localStorage). When on, the US and UK workspace saves +# everything to the PolicyEngine API: signed in as the account, signed out as +# this browser's unauthenticated user. The legacy calculator never signs in. +# NEXT_PUBLIC_AUTH_ENABLED=true +# Auth0 (custom) domain, e.g. auth.policyengine.org +# NEXT_PUBLIC_AUTH0_DOMAIN= +# Client ID of the SPA application for this environment +# NEXT_PUBLIC_AUTH0_CLIENT_ID= +# Identifier of the PolicyEngine API resource server, e.g. https://api.policyengine.org/ +# NEXT_PUBLIC_AUTH0_AUDIENCE= +# +# Auth0 application settings this app relies on: +# Allowed callback URLs: <origin>/us/callback, <origin>/uk/callback +# Allowed logout URLs and web origins: <origin>/<country>, <origin> +# Refresh-token rotation on; the API resource server allows offline access. + +# --- Flagship shell ----------------------------------------------------------- +# NEXT_PUBLIC_FLAGSHIP_SHELL=true + +# --- Tracker bill feed (read-only, public anon key) -------------------------- +# NEXT_PUBLIC_TRACKER_SUPABASE_URL=https://ffgngqlgfsvqartilful.supabase.co +# NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY=<anon key> diff --git a/calculator-app/.gitignore b/calculator-app/.gitignore index 878e2584e..5f31746d5 100644 --- a/calculator-app/.gitignore +++ b/calculator-app/.gitignore @@ -4,3 +4,4 @@ next-env.d.ts tsconfig.tsbuildinfo .vercel .env* +!.env.example diff --git a/calculator-app/drizzle.config.ts b/calculator-app/drizzle.config.ts deleted file mode 100644 index c8118a30b..000000000 --- a/calculator-app/drizzle.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - dialect: "postgresql", - schema: "./src/db/schema.ts", - out: "./src/db/migrations", - dbCredentials: { - // Only needed for push/migrate/studio; `generate` works without it. - url: process.env.DATABASE_URL ?? "", - }, -}); diff --git a/calculator-app/next.config.ts b/calculator-app/next.config.ts index c90d58142..3a8a6727a 100644 --- a/calculator-app/next.config.ts +++ b/calculator-app/next.config.ts @@ -10,6 +10,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const WEBSITE_ORIGIN = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://policyengine.org"; +/** The first of the given build-time environment variables that is set. */ +function envValue(...names: string[]): string { + for (const name of names) { + const value = process.env[name]; + if (value) { + return value; + } + } + return ""; +} + const nextConfig: NextConfig = { // Compile TypeScript files from ../app/src/ (the shared Vite codebase) experimental: { @@ -51,6 +62,23 @@ const nextConfig: NextConfig = { "import.meta.env.VITE_FLAGSHIP_SHELL": JSON.stringify( process.env.NEXT_PUBLIC_FLAGSHIP_SHELL || "", ), + // Sign-in and the PolicyEngine API origin. Shared code reads the + // VITE_* names; either spelling may be set on the deployment. + "import.meta.env.VITE_API_URL": JSON.stringify( + envValue("NEXT_PUBLIC_API_URL", "VITE_API_URL"), + ), + "import.meta.env.VITE_AUTH_ENABLED": JSON.stringify( + envValue("NEXT_PUBLIC_AUTH_ENABLED", "VITE_AUTH_ENABLED"), + ), + "import.meta.env.VITE_AUTH0_DOMAIN": JSON.stringify( + envValue("NEXT_PUBLIC_AUTH0_DOMAIN", "VITE_AUTH0_DOMAIN"), + ), + "import.meta.env.VITE_AUTH0_CLIENT_ID": JSON.stringify( + envValue("NEXT_PUBLIC_AUTH0_CLIENT_ID", "VITE_AUTH0_CLIENT_ID"), + ), + "import.meta.env.VITE_AUTH0_AUDIENCE": JSON.stringify( + envValue("NEXT_PUBLIC_AUTH0_AUDIENCE", "VITE_AUTH0_AUDIENCE"), + ), }), ); diff --git a/calculator-app/package.json b/calculator-app/package.json index abcf6e17b..03ecfb7c4 100644 --- a/calculator-app/package.json +++ b/calculator-app/package.json @@ -6,14 +6,11 @@ "dev": "sh -c 'next dev --port ${PORT:-3001}'", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" + "typecheck": "tsc --noEmit" }, "dependencies": { "@anthropic-ai/sdk": "^0.116.0", - "@neondatabase/serverless": "^1.1.0", + "@auth0/auth0-react": "2.27.0", "@normy/react-query": "^0.20.0", "@policyengine/ui-kit": "^0.4.0", "@reduxjs/toolkit": "^2.8.2", @@ -24,13 +21,11 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.13", - "drizzle-orm": "^0.45.2", "framer-motion": "^12.23.24", "fuse.js": "^7.0.0", "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "^15.3.3", - "postgres": "^3.4.9", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -44,7 +39,6 @@ "tailwindcss": "^4.2.0" }, "devDependencies": { - "@electric-sql/pglite": "^0.5.4", "@tailwindcss/postcss": "^4.1.8", "@types/node": "^22.15.0", "@types/react": "^19.1.0", @@ -52,7 +46,6 @@ "@types/react-plotly.js": "^2.6.3", "@types/react-simple-maps": "^3.0.6", "@types/react-syntax-highlighter": "^15.5.0", - "drizzle-kit": "^0.31.10", "postcss": "^8.5.4", "typescript": "^5.8.0" } diff --git a/calculator-app/src/app/[countryId]/(workspace)/account/page.tsx b/calculator-app/src/app/[countryId]/(workspace)/account/page.tsx new file mode 100644 index 000000000..4f37cea2d --- /dev/null +++ b/calculator-app/src/app/[countryId]/(workspace)/account/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import AccountPage from "@/pages/flagship/Account.page"; +import FlagshipGate from "../FlagshipGate"; + +/** The v3 account page (`/{countryId}/account`), in the workspace shell. */ +export default function AccountRoute() { + return ( + <FlagshipGate> + <AccountPage /> + </FlagshipGate> + ); +} diff --git a/calculator-app/src/app/[countryId]/(workspace)/callback/page.tsx b/calculator-app/src/app/[countryId]/(workspace)/callback/page.tsx new file mode 100644 index 000000000..8747f43c9 --- /dev/null +++ b/calculator-app/src/app/[countryId]/(workspace)/callback/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import AuthCallbackPage from "@/pages/flagship/AuthCallback.page"; +import FlagshipGate from "../FlagshipGate"; + +/** + * Auth0 redirect target (`/{countryId}/callback`). It sits in the + * workspace group so the workspace's AuthProvider completes the sign-in + * and stays mounted on the way back to where it started. + */ +export default function AuthCallbackRoute() { + return ( + <FlagshipGate> + <AuthCallbackPage /> + </FlagshipGate> + ); +} diff --git a/calculator-app/src/app/[countryId]/(workspace)/layout.tsx b/calculator-app/src/app/[countryId]/(workspace)/layout.tsx index 4f8e3dc61..5377f2b16 100644 --- a/calculator-app/src/app/[countryId]/(workspace)/layout.tsx +++ b/calculator-app/src/app/[countryId]/(workspace)/layout.tsx @@ -1,10 +1,17 @@ "use client"; +import AccountUpgradeNotice from "@/components/auth/AccountUpgradeNotice"; +import { AuthProvider } from "@/libs/auth/AuthProvider"; import WorkspaceNavigation from "./WorkspaceNavigation"; /** * Flagship workspace shell. Kept in its own route group so the local * screen switching never wraps the legacy calculator routes. + * + * Accounts belong to the workspace alone: the legacy calculator routes + * have no AuthProvider and keep saved work in the browser. The provider + * is inert unless NEXT_PUBLIC_AUTH_ENABLED=true and Auth0 is configured; + * then saved work goes to the PolicyEngine API, signed in or not. */ export default function WorkspaceLayout({ children, @@ -12,9 +19,10 @@ export default function WorkspaceLayout({ children: React.ReactNode; }) { return ( - <> + <AuthProvider> <WorkspaceNavigation>{children}</WorkspaceNavigation> + <AccountUpgradeNotice /> <div id="fullscreen-portal" /> - </> + </AuthProvider> ); } diff --git a/calculator-app/src/app/api/reforms/[reformId]/route.ts b/calculator-app/src/app/api/reforms/[reformId]/route.ts deleted file mode 100644 index 225eed574..000000000 --- a/calculator-app/src/app/api/reforms/[reformId]/route.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { - flagshipApiDisabledResponse, - isFlagshipApiEnabled, -} from "@/libs/flagship/apiGate"; -import { eq } from "drizzle-orm"; -import { reformBaselines, reformSources } from "@/types/ingredients/Reform"; -import type { ReformUpdateMetadata } from "@/types/metadata/reformMetadata"; -import { getDb, isDbConfigured } from "../../../../db"; -import { invalidText, readJsonBody } from "../../../../db/limits"; -import { guardSchema } from "../../../../db/errors"; -import { isUuid, presentsEditToken } from "../../../../db/ownership"; -import { reforms } from "../../../../db/schema"; -import { reformRowToMetadata } from "../../../../db/serialize"; - -// Handlers use Web-standard Request/Response (not next/server): these files -// are also typechecked inside app/tsconfig.json's program, and importing -// next/server would pull Next's React typings into the Vite app's graph. -// -// Reads need only the unguessable id. Changes also need the edit token the -// creating browser received (see db/ownership.ts); legacy rows without one -// are read-only. - -type RouteContext = { params: Promise<{ reformId: string }> }; - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function dbUnavailable(): Response { - return json({ error: "Reform store is not configured" }, 503); -} - -function notFound(): Response { - return json({ error: "Reform not found" }, 404); -} - -/** - * 404 when the reform does not exist, 403 when the request lacks its - * edit token; null when the change may go ahead. - */ -async function denyUnlessEditable( - request: Request, - reformId: string, -): Promise<Response | null> { - const [row] = await getDb() - .select({ editTokenHash: reforms.editTokenHash }) - .from(reforms) - .where(eq(reforms.id, reformId)); - if (!row) { - return notFound(); - } - if (!(await presentsEditToken(request, row.editTokenHash))) { - return json( - { error: "This reform can only be changed by its creator" }, - 403, - ); - } - return null; -} - -async function handleGET( - _request: Request, - context: RouteContext, -): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const { reformId } = await context.params; - if (!isUuid(reformId)) { - return notFound(); - } - - const [row] = await getDb() - .select() - .from(reforms) - .where(eq(reforms.id, reformId)); - if (!row) { - return notFound(); - } - - return json(reformRowToMetadata(row)); -} - -async function handlePATCH( - request: Request, - context: RouteContext, -): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const { reformId } = await context.params; - if (!isUuid(reformId)) { - return notFound(); - } - const denied = await denyUnlessEditable(request, reformId); - if (denied) { - return denied; - } - - const parsed = await readJsonBody(request); - if ("error" in parsed) { - return json({ error: parsed.error }, parsed.status); - } - const payload = (parsed.body ?? {}) as ReformUpdateMetadata; - - const updates: Partial<typeof reforms.$inferInsert> = {}; - if (payload.label !== undefined) { - const labelError = invalidText("label", payload.label); - if (labelError) { - return json({ error: labelError }, 400); - } - updates.label = payload.label; - } - if (payload.policy_id !== undefined) { - updates.policyId = payload.policy_id; - } - if (payload.parameters !== undefined) { - if (!Array.isArray(payload.parameters)) { - return json({ error: "parameters must be an array" }, 400); - } - updates.parameters = payload.parameters; - } - if (payload.baseline !== undefined) { - if (!reformBaselines.includes(payload.baseline)) { - return json( - { error: `baseline must be one of: ${reformBaselines.join(", ")}` }, - 400, - ); - } - updates.baseline = payload.baseline; - } - if (payload.provenance !== undefined) { - if ( - !payload.provenance || - !reformSources.includes(payload.provenance.source) - ) { - return json( - { - error: `provenance.source must be one of: ${reformSources.join(", ")}`, - }, - 400, - ); - } - updates.provenance = payload.provenance; - } - if (Object.keys(updates).length === 0) { - return json({ error: "No updatable fields provided" }, 400); - } - updates.updatedAt = new Date(); - - const [row] = await getDb() - .update(reforms) - .set(updates) - .where(eq(reforms.id, reformId)) - .returning(); - - if (!row) { - return notFound(); - } - - return json(reformRowToMetadata(row)); -} - -async function handleDELETE( - request: Request, - context: RouteContext, -): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const { reformId } = await context.params; - if (!isUuid(reformId)) { - return notFound(); - } - const denied = await denyUnlessEditable(request, reformId); - if (denied) { - return denied; - } - - const [row] = await getDb() - .delete(reforms) - .where(eq(reforms.id, reformId)) - .returning(); - if (!row) { - return notFound(); - } - - return json({ deleted: true }); -} - -// A database missing the latest migration answers 503, not 500 (see db/errors). -export async function GET( - ...args: Parameters<typeof handleGET> -): Promise<Response> { - return guardSchema(() => handleGET(...args)); -} - -export async function PATCH( - ...args: Parameters<typeof handlePATCH> -): Promise<Response> { - return guardSchema(() => handlePATCH(...args)); -} - -export async function DELETE( - ...args: Parameters<typeof handleDELETE> -): Promise<Response> { - return guardSchema(() => handleDELETE(...args)); -} diff --git a/calculator-app/src/app/api/reforms/route.ts b/calculator-app/src/app/api/reforms/route.ts deleted file mode 100644 index 82f8bdeca..000000000 --- a/calculator-app/src/app/api/reforms/route.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - flagshipApiDisabledResponse, - isFlagshipApiEnabled, -} from "@/libs/flagship/apiGate"; -import { desc, inArray } from "drizzle-orm"; -import { reformBaselines, reformSources } from "@/types/ingredients/Reform"; -import type { - ReformCreatedMetadata, - ReformCreationMetadata, -} from "@/types/metadata/reformMetadata"; -import { countryIds } from "@/libs/countries"; -import { getDb, isDbConfigured } from "../../../db"; -import { - invalidText, - insertWithinBudget, - newRowBudgetResponse, - readJsonBody, -} from "../../../db/limits"; -import { guardSchema } from "../../../db/errors"; -import { - issueEditToken, - MAX_IDS_PER_REQUEST, - parseIdList, -} from "../../../db/ownership"; -import { reforms } from "../../../db/schema"; -import { reformRowToMetadata } from "../../../db/serialize"; - -// Handlers use Web-standard Request/Response (not next/server): these files -// are also typechecked inside app/tsconfig.json's program, and importing -// next/server would pull Next's React typings into the Vite app's graph. -// -// Anonymous ownership (no sign-in yet): rows carry no user identifier, and -// the handlers read no IP address, user agent, or other request metadata. -// Each browser keeps the ids it created and lists them by id; the edit -// token issued at creation guards changes (see db/ownership.ts). - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function dbUnavailable(): Response { - return json({ error: "Reform store is not configured" }, 503); -} - -function validateCreationPayload(payload: any): string | null { - if (!payload || typeof payload !== "object") { - return "Invalid JSON body"; - } - if (!countryIds.includes(payload.country_id)) { - return `country_id must be one of: ${countryIds.join(", ")}`; - } - const labelError = invalidText("label", payload.label); - if (labelError) { - return labelError; - } - if (!Array.isArray(payload.parameters)) { - return "parameters must be an array"; - } - for (const parameter of payload.parameters) { - if ( - typeof parameter?.name !== "string" || - !Array.isArray(parameter?.values) - ) { - return "each parameter needs a name and a values array"; - } - } - if (!reformBaselines.includes(payload.baseline)) { - return `baseline must be one of: ${reformBaselines.join(", ")}`; - } - if ( - !payload.provenance || - !reformSources.includes(payload.provenance.source) - ) { - return `provenance.source must be one of: ${reformSources.join(", ")}`; - } - return null; -} - -async function handleGET(request: Request): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - // A batch read by id; there is no listing by user or of everything. - const ids = parseIdList(new URL(request.url).searchParams); - if (!ids) { - return json( - { - error: `ids must list up to ${MAX_IDS_PER_REQUEST} comma-separated reform ids`, - }, - 400, - ); - } - if (ids.length === 0) { - return json([]); - } - - const rows = await getDb() - .select() - .from(reforms) - .where(inArray(reforms.id, ids)) - .orderBy(desc(reforms.updatedAt)); - - return json(rows.map(reformRowToMetadata)); -} - -async function handlePOST(request: Request): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const parsed = await readJsonBody(request); - if ("error" in parsed) { - return json({ error: parsed.error }, parsed.status); - } - const payload = parsed.body as ReformCreationMetadata; - - const validationError = validateCreationPayload(payload); - if (validationError) { - return json({ error: validationError }, 400); - } - - const { token, hash } = await issueEditToken(); - const row = await insertWithinBudget(reforms, async (tx) => { - const [inserted] = await tx - .insert(reforms) - .values({ - editTokenHash: hash, - countryId: payload.country_id, - label: payload.label ?? null, - policyId: payload.policy_id ?? null, - parameters: payload.parameters, - baseline: payload.baseline, - provenance: payload.provenance, - }) - .returning(); - return inserted; - }); - if (!row) { - return newRowBudgetResponse(); - } - - // The only time the token leaves the server; the row keeps its hash. - const created: ReformCreatedMetadata = { - ...reformRowToMetadata(row), - edit_token: token, - }; - return json(created, 201); -} - -// A database missing the latest migration answers 503, not 500 (see db/errors). -export async function GET( - ...args: Parameters<typeof handleGET> -): Promise<Response> { - return guardSchema(() => handleGET(...args)); -} - -export async function POST( - ...args: Parameters<typeof handlePOST> -): Promise<Response> { - return guardSchema(() => handlePOST(...args)); -} diff --git a/calculator-app/src/app/api/reports/[reportId]/route.ts b/calculator-app/src/app/api/reports/[reportId]/route.ts deleted file mode 100644 index 523b6b08c..000000000 --- a/calculator-app/src/app/api/reports/[reportId]/route.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - flagshipApiDisabledResponse, - isFlagshipApiEnabled, -} from "@/libs/flagship/apiGate"; -import { eq } from "drizzle-orm"; -import { reportValidationWireError } from "@/libs/flagship/reportSchema"; -import { getDb, isDbConfigured } from "../../../../db"; -import { readJsonBody } from "../../../../db/limits"; -import { guardSchema } from "../../../../db/errors"; -import { isUuid, presentsEditToken } from "../../../../db/ownership"; -import { reports } from "../../../../db/schema"; -import { reportRowToMetadata } from "../../../../db/serialize"; - -// Reads need only the unguessable id; pinning a validation also needs the -// edit token the creating browser received. Legacy rows are read-only. - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -async function handleGET( - _request: Request, - { params }: { params: Promise<{ reportId: string }> }, -): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return json({ error: "Report store is not configured" }, 503); - } - - const { reportId } = await params; - if (!isUuid(reportId)) { - return json({ error: "Report not found" }, 404); - } - const [row] = await getDb() - .select() - .from(reports) - .where(eq(reports.id, reportId)); - - if (!row) { - return json({ error: "Report not found" }, 404); - } - return json(reportRowToMetadata(row)); -} - -function isValidationPayload(value: any): boolean { - return ( - !!value && - typeof value === "object" && - typeof value.matched_at === "string" && - typeof value.map_model_version === "string" && - (value.calibration === null || typeof value.calibration === "object") && - (value.scorecard === null || typeof value.scorecard === "object") - ); -} - -/** Pins what validation matched the report against. Idempotent. */ -async function handlePATCH( - request: Request, - { params }: { params: Promise<{ reportId: string }> }, -): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return json({ error: "Report store is not configured" }, 503); - } - - const { reportId } = await params; - if (!isUuid(reportId)) { - return json({ error: "Report not found" }, 404); - } - const [owner] = await getDb() - .select({ editTokenHash: reports.editTokenHash }) - .from(reports) - .where(eq(reports.id, reportId)); - if (!owner) { - return json({ error: "Report not found" }, 404); - } - if (!(await presentsEditToken(request, owner.editTokenHash))) { - return json( - { error: "This report can only be changed by its creator" }, - 403, - ); - } - - const parsed = await readJsonBody(request); - if ("error" in parsed) { - return json({ error: parsed.error }, parsed.status); - } - const payload: any = parsed.body; - if (!isValidationPayload(payload?.validation)) { - return json({ error: "validation snapshot is required" }, 400); - } - // The snapshot as the client's report schema reads it back. - const validationError = reportValidationWireError(payload.validation); - if (validationError) { - return json({ error: validationError }, 400); - } - - const [row] = await getDb() - .update(reports) - .set({ validation: payload.validation }) - .where(eq(reports.id, reportId)) - .returning(); - - if (!row) { - return json({ error: "Report not found" }, 404); - } - return json(reportRowToMetadata(row)); -} - -// A database missing the latest migration answers 503, not 500 (see db/errors). -export async function GET( - ...args: Parameters<typeof handleGET> -): Promise<Response> { - return guardSchema(() => handleGET(...args)); -} - -export async function PATCH( - ...args: Parameters<typeof handlePATCH> -): Promise<Response> { - return guardSchema(() => handlePATCH(...args)); -} diff --git a/calculator-app/src/app/api/reports/route.ts b/calculator-app/src/app/api/reports/route.ts deleted file mode 100644 index da804e012..000000000 --- a/calculator-app/src/app/api/reports/route.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { - flagshipApiDisabledResponse, - isFlagshipApiEnabled, -} from "@/libs/flagship/apiGate"; -import { desc, inArray } from "drizzle-orm"; -import { countryIds } from "@/libs/countries"; -import { reportProvisionsWireError } from "@/libs/flagship/reportSchema"; -import { getDb, isDbConfigured } from "../../../db"; -import { - invalidText, - insertWithinBudget, - newRowBudgetResponse, - readJsonBody, -} from "../../../db/limits"; -import { guardSchema } from "../../../db/errors"; -import { - isUuid, - issueEditToken, - MAX_IDS_PER_REQUEST, - parseIdList, -} from "../../../db/ownership"; -import { reports } from "../../../db/schema"; -import { reportRowToMetadata } from "../../../db/serialize"; - -// Web-standard Request/Response for the same reason as api/reforms: these -// files are typechecked inside app/tsconfig.json's program too. -// -// Same anonymous ownership as api/reforms: no user identifier and no -// request metadata stored, batch reads by id only, and an edit token for -// changes. There is deliberately no lookup by api_report_id: API report -// ids are sequential, so that lookup would let anyone enumerate records. - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function dbUnavailable(): Response { - return json({ error: "Report store is not configured" }, 503); -} - -function validateCreationPayload(payload: any): string | null { - if (!payload || typeof payload !== "object") { - return "Invalid JSON body"; - } - if (!countryIds.includes(payload.country_id)) { - return `country_id must be one of: ${countryIds.join(", ")}`; - } - if (typeof payload.api_report_id !== "string" || !payload.api_report_id) { - return "api_report_id is required"; - } - const textError = - invalidText("title", payload.title) ?? - invalidText("source_note", payload.source_note); - if (textError) { - return textError; - } - if (!Array.isArray(payload.provisions)) { - return "provisions must be an array"; - } - // Each provision as the client's report schema reads it back. - const provisionsError = reportProvisionsWireError(payload.provisions); - if (provisionsError) { - return provisionsError; - } - if (typeof payload.year !== "string" || !payload.year) { - return "year is required"; - } - if ( - payload.reform_id != null && - (typeof payload.reform_id !== "string" || !isUuid(payload.reform_id)) - ) { - return "reform_id must be a central reform id or null"; - } - return null; -} - -async function handleGET(request: Request): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const ids = parseIdList(new URL(request.url).searchParams); - if (!ids) { - return json( - { - error: `ids must list up to ${MAX_IDS_PER_REQUEST} comma-separated report ids`, - }, - 400, - ); - } - if (ids.length === 0) { - return json([]); - } - - const rows = await getDb() - .select() - .from(reports) - .where(inArray(reports.id, ids)) - .orderBy(desc(reports.createdAt)); - - return json(rows.map(reportRowToMetadata)); -} - -async function handlePOST(request: Request): Promise<Response> { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const parsed = await readJsonBody(request); - if ("error" in parsed) { - return json({ error: parsed.error }, parsed.status); - } - const payload: any = parsed.body; - - const validationError = validateCreationPayload(payload); - if (validationError) { - return json({ error: validationError }, 400); - } - - const { token, hash } = await issueEditToken(); - const row = await insertWithinBudget(reports, async (tx) => { - const [inserted] = await tx - .insert(reports) - .values({ - editTokenHash: hash, - countryId: payload.country_id, - apiReportId: payload.api_report_id, - title: payload.title ?? null, - sourceNote: payload.source_note ?? null, - provisions: payload.provisions, - reformId: payload.reform_id ?? null, - year: payload.year, - }) - .returning(); - return inserted; - }); - if (!row) { - return newRowBudgetResponse(); - } - - // The only time the token leaves the server; the row keeps its hash. - return json({ ...reportRowToMetadata(row), edit_token: token }, 201); -} - -// A database missing the latest migration answers 503, not 500 (see db/errors). -export async function GET( - ...args: Parameters<typeof handleGET> -): Promise<Response> { - return guardSchema(() => handleGET(...args)); -} - -export async function POST( - ...args: Parameters<typeof handlePOST> -): Promise<Response> { - return guardSchema(() => handlePOST(...args)); -} diff --git a/calculator-app/src/db/README.md b/calculator-app/src/db/README.md deleted file mode 100644 index 661326de9..000000000 --- a/calculator-app/src/db/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# Central reform database - -Postgres store for the flagship app's reform objects and report records, -served by the route handlers in `src/app/api/reforms/` and -`src/app/api/reports/`. Schema is managed with Drizzle; checked-in migrations -live in `migrations/`. - -## Ownership without sign-in - -There is no sign-in yet, so the server stores no user identifier: - -- Each row lives under a server-generated UUID. There is no list-by-user - route; `GET ?ids=a,b,c` reads up to 100 rows whose ids the caller already - knows. Reports cannot be looked up by `api_report_id`, because those ids are - sequential. -- `POST` issues a random 256-bit edit token, returns it once as `edit_token`, - and stores only its SHA-256 hash (`edit_token_hash`). `PATCH` and `DELETE` - require the token in the `X-Edit-Token` header; a missing or wrong token gets 403. See `ownership.ts`. -- The browser keeps the ids it created, with their tokens, in localStorage - (`app/src/libs/flagship/ownedItems.ts`). Lists read that index and fetch the - rows by id. Clearing site data loses the index: the rows stay readable by id, - but nobody can change them. -- Rows written before migration `0003_anonymous-ownership` keep their legacy - `user_id` (never written now, never returned) and have no token hash, so - they are read-only. No browser index holds their ids, so they also drop out - of every list; they stay readable by id. - -### Abuse limits - -In code (`limits.ts`), with no client identifier: - -- Request bodies over 256 KB get 413 (POST and PATCH). -- `label`, `title` and `source_note` must be strings of at most 500 - characters, or 400. The client shortens them to 500 before it sends them - (`app/src/libs/flagship/storeText.ts`), so a long bill title still saves. -- Each table takes at most 120 new rows per minute across all visitors; a - create over that gets 429 with `Retry-After: 60`. The count and the insert - run in one transaction under a per-table advisory lock - (`pg_advisory_xact_lock`), so concurrent creates cannot all read a count - under the limit and all insert. The client then saves that one item to its - localStorage fallback and keeps using the central store. Edits are not - counted. -- Known trade-off: the limit is shared, so one client can use it up for - everyone (their creates then stay in their own browser's fallback). The - per-client limit below is what prevents that; enforce it, not only log it, - before the flagship flag goes on in production. - -Per-client rate limiting belongs at Vercel's edge, where the firewall keys on -the IP address and our handlers never see it. It is project configuration, -not code, so create it on the Vercel project that receives visitors' requests -for these routes. Roll it out in log mode first: - -```bash -vercel firewall rules add "Flagship store writes" \ - --condition '{"type":"path","op":"pre","value":"/api/reforms"}' \ - --condition '{"type":"method","op":"inc","value":["POST","PATCH","DELETE"]}' \ - --or \ - --condition '{"type":"path","op":"pre","value":"/api/reports"}' \ - --condition '{"type":"method","op":"inc","value":["POST","PATCH","DELETE"]}' \ - --action rate_limit \ - --rate-limit-window 60 \ - --rate-limit-requests 30 \ - --rate-limit-keys ip \ - --rate-limit-action log \ - --yes -vercel firewall diff # review, then publish from the dashboard or CLI -``` - -After a few days of traffic, check in the Firewall traffic view that the rule -sees distinct client addresses (not one proxy address), then switch -`--rate-limit-action` to `rate_limit` (429). Counters are per region. -(`vercel firewall` needs a recent Vercel CLI; the dashboard works too.) - -### Privacy - -- Rows hold no user identifier, IP address, or user agent, and the handlers - read no request metadata other than `X-Edit-Token`. (The hosting platform's - own request logs are outside this schema.) -- Households (income, ages, family details) are never stored here. Reforms hold - policy parameters; report records hold a pointer to the API report and the - policy provisions. Households stay in the browser. - -## One-time provisioning (Neon via Vercel marketplace) - -```bash -cd calculator-app -vercel link # link to the calculator Vercel project -vercel integration add neon # provisions DB + sets DATABASE_URL -vercel env pull .env.local --yes # bring DATABASE_URL down for local dev -``` - -## Applying migrations - -Nothing applies migrations automatically: not CI, not the deploy. Run them by -hand on every database a flag-on deployment uses, **before** the code that -needs them deploys. The code reads every column in `schema.ts`; against a -database without the latest migration, the routes answer 503 (see -`errors.ts`), so the client falls back to localStorage rather than failing, -but nothing reaches the central store until the migration runs. - -`drizzle-kit` does not auto-load `.env.local`, so source it first: - -```bash -source <(grep -v '^#' .env.local | sed 's/^/export /') && bun run db:migrate -``` - -## Day-to-day - -- Change `schema.ts`, then `bun run db:generate` to emit a new migration. -- Never edit generated SQL in `migrations/` by hand. -- Route handlers return 503 when `DATABASE_URL` is unset, so environments - without the database (including current prod) degrade cleanly; the client - falls back via `VITE_STORE_BACKEND=localStorage` if needed. - -## Storage backend (frozen — coordinate with the storage owner) - -The flagship's user-artifact storage (reforms + reports tables in this -schema) is owned by the org storage workstream. To connect it, the -owner provides a Postgres connection string from the existing shared -Supabase infrastructure: - -``` -DATABASE_URL=postgresql://...pooler.supabase.com:6543/postgres -``` - -Then run migrations from calculator-app/: `bun run db:migrate`. - -Without DATABASE_URL, the app runs fully on localStorage fallbacks — -no behavior change. (A temporary `policyengine-app` project used for -beat-1 verification was deleted on 2026-08-06 to avoid parallel -infrastructure; its one-row export lives outside the repo.) - -The tracker bill feed is separate (read-only, public anon key) and -stays configured: - -``` -NEXT_PUBLIC_TRACKER_SUPABASE_URL=https://ffgngqlgfsvqartilful.supabase.co -NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY=<anon key> -``` - -The beat-2 seam (repointing bills into the unified project) is -`app/src/api/billFeed.ts`. diff --git a/calculator-app/src/db/errors.ts b/calculator-app/src/db/errors.ts deleted file mode 100644 index 7072d7e2b..000000000 --- a/calculator-app/src/db/errors.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Postgres codes for a schema the code expects but the database lacks — - * a migration not run yet: an undefined column or table. - */ -const UNMIGRATED_CODES = new Set(["42703", "42P01"]); - -/** Whether a database error means the latest migration has not run. */ -export function isUnmigratedSchemaError(error: unknown): boolean { - const code = - (error as { code?: unknown } | null)?.code ?? - (error as { cause?: { code?: unknown } } | null)?.cause?.code; - return typeof code === "string" && UNMIGRATED_CODES.has(code); -} - -/** - * Runs a store route; a database without the latest migration answers - * 503 — "store unavailable" — rather than 500, so the client falls back - * to its localStorage store instead of failing the save. Other errors - * stay errors. - */ -export async function guardSchema( - handler: () => Promise<Response>, -): Promise<Response> { - try { - return await handler(); - } catch (error) { - if (isUnmigratedSchemaError(error)) { - return new Response( - JSON.stringify({ - error: "The store's database needs its latest migration", - }), - { status: 503, headers: { "Content-Type": "application/json" } }, - ); - } - throw error; - } -} diff --git a/calculator-app/src/db/index.ts b/calculator-app/src/db/index.ts deleted file mode 100644 index 43da62744..000000000 --- a/calculator-app/src/db/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; -import postgres from "postgres"; -import * as schema from "./schema"; - -/** - * Driver-agnostic database handle: satisfied by postgres-js against - * Supabase in production and by PGlite in integration tests. - */ -export type ReformDb = PgDatabase<PgQueryResultHKT, typeof schema>; - -// Lazy initialization: Next.js evaluates top-level module code at build -// time, so eager init would crash `next build` on projects without the -// database provisioned. -function createDb(): ReformDb { - // Supabase's pooled connection string (transaction mode) does not - // support prepared statements. - const sql = postgres(process.env.DATABASE_URL!, { prepare: false }); - return drizzle(sql, { schema }); -} - -let _db: ReformDb | null = null; - -export function getDb(): ReformDb { - if (!_db) { - _db = createDb(); - } - return _db; -} - -export function isDbConfigured(): boolean { - return _db !== null || Boolean(process.env.DATABASE_URL); -} - -/** Inject a database (e.g. PGlite) for integration tests; pass null to reset. */ -export function setDbForTesting(db: ReformDb | null): void { - _db = db; -} diff --git a/calculator-app/src/db/limits.ts b/calculator-app/src/db/limits.ts deleted file mode 100644 index 39572f394..000000000 --- a/calculator-app/src/db/limits.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { count, gt, sql } from "drizzle-orm"; -import { getDb, type ReformDb } from "./index"; -import { reforms, reports } from "./schema"; - -/** - * Abuse limits for the flagship store routes. They need no client - * identifier: the per-client rate limit lives at Vercel's edge, keyed by - * IP there and never seen here (see README). These limits bound what any - * one request, and all requests together, can add to the database. - */ - -/** Largest request body the store routes accept. */ -export const MAX_BODY_BYTES = 256 * 1024; - -/** Longest free-text field: a reform label, a report title or source note. */ -export const MAX_TEXT_LENGTH = 500; - -/** - * New rows per table per minute, across all visitors. A backstop for the - * edge rate limit: it caps growth even when that rule is missing. Clients - * that hit it save to their localStorage fallback instead. - */ -export const NEW_ROWS_PER_MINUTE = 120; - -export type JsonBodyResult = - | { body: unknown } - | { error: string; status: 400 | 413 }; - -/** - * Reads a JSON body of at most MAX_BODY_BYTES. The declared length is - * checked first; the actual length too, since a chunked body declares - * none. (Vercel already caps bodies at 4.5 MB before the handler runs.) - */ -export async function readJsonBody(request: Request): Promise<JsonBodyResult> { - const tooLarge = { - error: `Request body must be at most ${MAX_BODY_BYTES / 1024} KB`, - status: 413 as const, - }; - if (Number(request.headers.get("content-length")) > MAX_BODY_BYTES) { - return tooLarge; - } - const text = await request.text(); - if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) { - return tooLarge; - } - try { - return { body: JSON.parse(text) }; - } catch { - return { error: "Invalid JSON body", status: 400 }; - } -} - -/** - * Why an optional free-text field is invalid, or null when it is fine: - * absent, null, or a string of at most MAX_TEXT_LENGTH characters. - */ -export function invalidText(name: string, value: unknown): string | null { - if (value === undefined || value === null) { - return null; - } - if (typeof value !== "string") { - return `${name} must be a string or null`; - } - if (value.length > MAX_TEXT_LENGTH) { - return `${name} must be at most ${MAX_TEXT_LENGTH} characters`; - } - return null; -} - -/** A transaction-scoped advisory lock per table, so creates on it count in turn. */ -const BUDGET_LOCK_KEYS = new Map<typeof reforms | typeof reports, number>([ - [reforms, 724_101], - [reports, 724_102], -]); - -/** - * Inserts a row if the table took fewer than NEW_ROWS_PER_MINUTE in the - * last minute; null when the budget is used up. The count and the insert - * share one transaction under the table's advisory lock: counted apart, - * concurrent creates would all read a count under the limit and all - * insert. The lock is transaction-scoped, which Supabase's transaction - * pooler supports, and is released at commit. - */ -export async function insertWithinBudget<T>( - table: typeof reforms | typeof reports, - insert: (tx: ReformDb) => Promise<T>, -): Promise<T | null> { - return getDb().transaction(async (tx) => { - await tx.execute( - sql`select pg_advisory_xact_lock(${BUDGET_LOCK_KEYS.get(table)!})`, - ); - // The database clock, so app-server clock skew cannot widen the window. - const [row] = await tx - .select({ value: count() }) - .from(table) - .where(gt(table.createdAt, sql`now() - interval '1 minute'`)); - if ((row?.value ?? 0) >= NEW_ROWS_PER_MINUTE) { - return null; - } - return insert(tx as unknown as ReformDb); - }); -} - -/** 429 for a create while the new-row budget is used up. */ -export function newRowBudgetResponse(): Response { - return new Response( - JSON.stringify({ - error: "The store is taking no new items this minute; try again shortly", - }), - { - status: 429, - headers: { "Content-Type": "application/json", "Retry-After": "60" }, - }, - ); -} diff --git a/calculator-app/src/db/migrations/0000_create-reforms.sql b/calculator-app/src/db/migrations/0000_create-reforms.sql deleted file mode 100644 index 2ff485d0c..000000000 --- a/calculator-app/src/db/migrations/0000_create-reforms.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE "reforms" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" text NOT NULL, - "country_id" text NOT NULL, - "label" text, - "policy_id" text, - "parameters" jsonb NOT NULL, - "baseline" text DEFAULT 'current-law' NOT NULL, - "provenance" jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE INDEX "reforms_user_country_idx" ON "reforms" USING btree ("user_id","country_id"); \ No newline at end of file diff --git a/calculator-app/src/db/migrations/0001_create-reports.sql b/calculator-app/src/db/migrations/0001_create-reports.sql deleted file mode 100644 index f3c1d8cda..000000000 --- a/calculator-app/src/db/migrations/0001_create-reports.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE "reports" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" text NOT NULL, - "country_id" text NOT NULL, - "api_report_id" text NOT NULL, - "title" text, - "source_note" text, - "provisions" jsonb NOT NULL, - "reform_id" uuid, - "year" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE INDEX "reports_user_country_idx" ON "reports" USING btree ("user_id","country_id"); \ No newline at end of file diff --git a/calculator-app/src/db/migrations/0002_add-report-validation.sql b/calculator-app/src/db/migrations/0002_add-report-validation.sql deleted file mode 100644 index 790d2d57d..000000000 --- a/calculator-app/src/db/migrations/0002_add-report-validation.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "reports" ADD COLUMN "validation" jsonb; \ No newline at end of file diff --git a/calculator-app/src/db/migrations/0003_anonymous-ownership.sql b/calculator-app/src/db/migrations/0003_anonymous-ownership.sql deleted file mode 100644 index 33af019b6..000000000 --- a/calculator-app/src/db/migrations/0003_anonymous-ownership.sql +++ /dev/null @@ -1,8 +0,0 @@ -DROP INDEX "reforms_user_country_idx";--> statement-breakpoint -DROP INDEX "reports_user_country_idx";--> statement-breakpoint -ALTER TABLE "reforms" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint -ALTER TABLE "reports" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint -ALTER TABLE "reforms" ADD COLUMN "edit_token_hash" text;--> statement-breakpoint -ALTER TABLE "reports" ADD COLUMN "edit_token_hash" text;--> statement-breakpoint -CREATE INDEX "reforms_created_at_idx" ON "reforms" USING btree ("created_at");--> statement-breakpoint -CREATE INDEX "reports_created_at_idx" ON "reports" USING btree ("created_at"); \ No newline at end of file diff --git a/calculator-app/src/db/migrations/meta/0000_snapshot.json b/calculator-app/src/db/migrations/meta/0000_snapshot.json deleted file mode 100644 index 46b026529..000000000 --- a/calculator-app/src/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "id": "7749c19b-8fd8-455d-bd5a-82d1a0429a77", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.reforms": { - "name": "reforms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "policy_id": { - "name": "policy_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "parameters": { - "name": "parameters", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "baseline": { - "name": "baseline", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'current-law'" - }, - "provenance": { - "name": "provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reforms_user_country_idx": { - "name": "reforms_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/calculator-app/src/db/migrations/meta/0001_snapshot.json b/calculator-app/src/db/migrations/meta/0001_snapshot.json deleted file mode 100644 index 4db91cd54..000000000 --- a/calculator-app/src/db/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "id": "f41db9f9-c335-4c06-866b-ed0269a64a90", - "prevId": "7749c19b-8fd8-455d-bd5a-82d1a0429a77", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.reforms": { - "name": "reforms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "policy_id": { - "name": "policy_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "parameters": { - "name": "parameters", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "baseline": { - "name": "baseline", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'current-law'" - }, - "provenance": { - "name": "provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reforms_user_country_idx": { - "name": "reforms_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.reports": { - "name": "reports", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_report_id": { - "name": "api_report_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_note": { - "name": "source_note", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provisions": { - "name": "provisions", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "reform_id": { - "name": "reform_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "year": { - "name": "year", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reports_user_country_idx": { - "name": "reports_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/calculator-app/src/db/migrations/meta/0002_snapshot.json b/calculator-app/src/db/migrations/meta/0002_snapshot.json deleted file mode 100644 index 808dc14a4..000000000 --- a/calculator-app/src/db/migrations/meta/0002_snapshot.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "id": "11a9cc4e-ef2d-4d24-89ed-a1f7d99ca271", - "prevId": "f41db9f9-c335-4c06-866b-ed0269a64a90", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.reforms": { - "name": "reforms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "policy_id": { - "name": "policy_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "parameters": { - "name": "parameters", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "baseline": { - "name": "baseline", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'current-law'" - }, - "provenance": { - "name": "provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reforms_user_country_idx": { - "name": "reforms_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.reports": { - "name": "reports", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_report_id": { - "name": "api_report_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_note": { - "name": "source_note", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provisions": { - "name": "provisions", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "reform_id": { - "name": "reform_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "year": { - "name": "year", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "validation": { - "name": "validation", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reports_user_country_idx": { - "name": "reports_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/calculator-app/src/db/migrations/meta/0003_snapshot.json b/calculator-app/src/db/migrations/meta/0003_snapshot.json deleted file mode 100644 index 463edbaee..000000000 --- a/calculator-app/src/db/migrations/meta/0003_snapshot.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "id": "e3af9e8e-a595-4984-a5a9-4f1ba9d2bf65", - "prevId": "11a9cc4e-ef2d-4d24-89ed-a1f7d99ca271", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.reforms": { - "name": "reforms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "edit_token_hash": { - "name": "edit_token_hash", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "policy_id": { - "name": "policy_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "parameters": { - "name": "parameters", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "baseline": { - "name": "baseline", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'current-law'" - }, - "provenance": { - "name": "provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reforms_created_at_idx": { - "name": "reforms_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.reports": { - "name": "reports", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "edit_token_hash": { - "name": "edit_token_hash", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_report_id": { - "name": "api_report_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_note": { - "name": "source_note", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provisions": { - "name": "provisions", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "reform_id": { - "name": "reform_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "year": { - "name": "year", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "validation": { - "name": "validation", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reports_created_at_idx": { - "name": "reports_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/calculator-app/src/db/migrations/meta/_journal.json b/calculator-app/src/db/migrations/meta/_journal.json deleted file mode 100644 index 6ebcf9633..000000000 --- a/calculator-app/src/db/migrations/meta/_journal.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1785726114869, - "tag": "0000_create-reforms", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1785973002089, - "tag": "0001_create-reports", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1788771086146, - "tag": "0002_add-report-validation", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1791913432535, - "tag": "0003_anonymous-ownership", - "breakpoints": true - } - ] -} diff --git a/calculator-app/src/db/ownership.ts b/calculator-app/src/db/ownership.ts deleted file mode 100644 index 1a32e9ae0..000000000 --- a/calculator-app/src/db/ownership.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { EDIT_TOKEN_HEADER, isUuid } from "@/libs/flagship/ownedItems"; - -export { isUuid }; - -/** - * Anonymous ownership for flagship items. There is no sign-in, so the - * server stores no user identifier: creating an item issues a random - * 256-bit edit token, returns it to the browser once, and keeps only its - * SHA-256 hash. Changing or deleting the item requires the token. - * - * Uses Web Crypto rather than node:crypto because these files are also - * typechecked inside the Vite app's program. - */ - -const TOKEN_BYTES = 32; - -function toHex(bytes: Uint8Array): string { - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( - "", - ); -} - -export async function hashEditToken(token: string): Promise<string> { - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(token), - ); - return toHex(new Uint8Array(digest)); -} - -/** A fresh token for the browser and the hash to store in its place. */ -export async function issueEditToken(): Promise<{ - token: string; - hash: string; -}> { - const token = toHex(crypto.getRandomValues(new Uint8Array(TOKEN_BYTES))); - return { token, hash: await hashEditToken(token) }; -} - -function constantTimeEqual(a: string, b: string): boolean { - if (a.length !== b.length) { - return false; - } - let difference = 0; - for (let index = 0; index < a.length; index += 1) { - difference |= a.charCodeAt(index) ^ b.charCodeAt(index); - } - return difference === 0; -} - -/** - * Whether the request carries the token whose hash the row stores. - * Legacy rows have no hash, so nobody can change them. - */ -export async function presentsEditToken( - request: Request, - storedHash: string | null, -): Promise<boolean> { - const token = request.headers.get(EDIT_TOKEN_HEADER); - if (!storedHash || !token) { - return false; - } - return constantTimeEqual(await hashEditToken(token), storedHash); -} - -/** Most ids one batch read accepts. */ -export const MAX_IDS_PER_REQUEST = 100; - -/** - * The `ids` query parameter of a batch read: comma-separated item ids, - * deduplicated. Entries that are not UUIDs cannot name a row, so they - * are dropped. Returns null when the parameter is missing or too long. - */ -export function parseIdList(searchParams: URLSearchParams): string[] | null { - const raw = searchParams.get("ids"); - if (raw === null) { - return null; - } - const ids = [...new Set(raw.split(",").map((id) => id.trim()))].filter( - isUuid, - ); - return ids.length > MAX_IDS_PER_REQUEST ? null : ids; -} diff --git a/calculator-app/src/db/schema.ts b/calculator-app/src/db/schema.ts deleted file mode 100644 index 33103b0e0..000000000 --- a/calculator-app/src/db/schema.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - index, - jsonb, - pgTable, - text, - timestamp, - uuid, -} from "drizzle-orm/pg-core"; -import type { - ReformParameterMetadata, - ReformProvenanceMetadata, -} from "@/types/metadata/reformMetadata"; - -/** - * Central store for the flagship app's reform objects. - * - * A reform is mutable content; the canonical immutable Policy lives in - * the main PolicyEngine API and is linked via policy_id once the reform - * has been materialized for simulation. - * - * Rows carry no user identifier (and no IP address or user agent). The - * unguessable id is the only way to reach a row; the browser that - * created it keeps the id in its own index, with the edit token whose - * hash is stored here. - */ -export const reforms = pgTable( - "reforms", - { - id: uuid("id").defaultRandom().primaryKey(), - /** Legacy: client-supplied on rows written before edit tokens. Never written now. */ - userId: text("user_id"), - /** SHA-256 of the edit token issued at creation. Null on legacy rows, which are read-only. */ - editTokenHash: text("edit_token_hash"), - countryId: text("country_id").notNull(), - label: text("label"), - policyId: text("policy_id"), - parameters: jsonb("parameters") - .notNull() - .$type<ReformParameterMetadata[]>(), - baseline: text("baseline").notNull().default("current-law"), - provenance: jsonb("provenance").notNull().$type<ReformProvenanceMetadata>(), - createdAt: timestamp("created_at", { withTimezone: true }) - .defaultNow() - .notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .defaultNow() - .notNull(), - }, - // Serves the new-row budget (db/limits.ts), which counts recent rows. - (table) => [index("reforms_created_at_idx").on(table.createdAt)], -); - -export type ReformRow = typeof reforms.$inferSelect; -export type NewReformRow = typeof reforms.$inferInsert; - -/** - * A generated impact report: a pointer to the computed output on the - * PolicyEngine API plus the provenance snapshot shown in the report - * header. Outputs themselves are never duplicated here — the API caches - * and persists them. Same ownership model as reforms: no user - * identifier, an unguessable id, and the hash of an edit token. - */ -export const reports = pgTable( - "reports", - { - id: uuid("id").defaultRandom().primaryKey(), - /** Legacy: client-supplied on rows written before edit tokens. Never written now. */ - userId: text("user_id"), - /** SHA-256 of the edit token issued at creation. Null on legacy rows, which are read-only. */ - editTokenHash: text("edit_token_hash"), - countryId: text("country_id").notNull(), - /** Report id on api.policyengine.org — the pointer to the outputs. */ - apiReportId: text("api_report_id").notNull(), - title: text("title"), - /** e.g. "Utah · Enacted" for bills, "Hand-built" for drafts. */ - sourceNote: text("source_note"), - /** Provision snapshot at run time, for the provenance header. */ - provisions: jsonb("provisions") - .notNull() - .$type<ReportProvisionMetadata[]>(), - /** Originating reform row, when the run came from a saved reform. */ - reformId: uuid("reform_id"), - year: text("year").notNull(), - /** - * What validation matched this report against, pinned at match time: - * the populace release and model version behind the calibration - * check and the scorecard programs. Later releases make the stored - * comparison historical, which the report can then say. - */ - validation: jsonb("validation").$type<ReportValidationMetadata | null>(), - createdAt: timestamp("created_at", { withTimezone: true }) - .defaultNow() - .notNull(), - }, - (table) => [index("reports_created_at_idx").on(table.createdAt)], -); - -export interface ReportProvisionMetadata { - path: string; - breadcrumb: string; - unit: string | null; - baseline_value: unknown; - value: unknown; -} - -export interface ReportValidationMatchMetadata { - variable: string; - depth: number; - ring: "primary" | "mechanism" | "downstream"; - target_count: number; - mean_abs_relative_error: number; - worst: { - name: string; - source: string; - geography: string; - relative_error: number | null; - }; -} - -export interface ReportValidationMetadata { - matched_at: string; - /** Model version the dependency map was traced from. */ - map_model_version: string; - calibration: { - release_id: string | null; - geography: string; - reached_count: number; - matches: ReportValidationMatchMetadata[]; - } | null; - scorecard: { programs: string[] } | null; -} - -export type ReportRow = typeof reports.$inferSelect; -export type NewReportRow = typeof reports.$inferInsert; diff --git a/calculator-app/src/db/serialize.ts b/calculator-app/src/db/serialize.ts deleted file mode 100644 index c498073d6..000000000 --- a/calculator-app/src/db/serialize.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ReformMetadata } from "@/types/metadata/reformMetadata"; -import type { ReportRow } from "./schema"; -import type { ReformRow } from "./schema"; - -// The wire format leaves out the legacy user_id and the edit token hash: -// neither ever leaves the server. - -/** Convert a database row to the snake_case wire format the client stores expect. */ -export function reformRowToMetadata(row: ReformRow): ReformMetadata { - return { - id: row.id, - country_id: row.countryId as ReformMetadata["country_id"], - label: row.label, - policy_id: row.policyId, - parameters: row.parameters, - baseline: row.baseline as ReformMetadata["baseline"], - provenance: row.provenance, - created_at: row.createdAt.toISOString(), - updated_at: row.updatedAt.toISOString(), - }; -} - -/** Wire format for report rows (snake_case, ISO dates). */ -export function reportRowToMetadata(row: ReportRow) { - return { - id: row.id, - country_id: row.countryId, - api_report_id: row.apiReportId, - title: row.title, - source_note: row.sourceNote, - provisions: row.provisions, - reform_id: row.reformId, - year: row.year, - validation: row.validation ?? null, - created_at: row.createdAt.toISOString(), - }; -} diff --git a/calculator-app/src/vite-env-shim.d.ts b/calculator-app/src/vite-env-shim.d.ts index 7c03e4029..cacd395eb 100644 --- a/calculator-app/src/vite-env-shim.d.ts +++ b/calculator-app/src/vite-env-shim.d.ts @@ -13,7 +13,11 @@ interface ImportMetaEnv { readonly VITE_CALCULATOR_URL: string; readonly VITE_FLAGSHIP_SHELL?: string; readonly VITE_FLAGSHIP_OVERRIDE?: string; - readonly VITE_STORE_BACKEND?: string; + readonly VITE_API_URL?: string; + readonly VITE_AUTH_ENABLED?: string; + readonly VITE_AUTH0_DOMAIN?: string; + readonly VITE_AUTH0_CLIENT_ID?: string; + readonly VITE_AUTH0_AUDIENCE?: string; } interface ImportMeta { diff --git a/package.json b/package.json index 67fe9d46e..d50528265 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "jsdom": "^26.1.0", "turbo": "^2.5.3" }, + "overrides": { + "@auth0/auth0-auth-js": "1.15.0", + "@auth0/auth0-spa-js": "2.27.0" + }, "packageManager": "bun@1.2.21", "engines": { "node": ">=22.0.0"