From 1933b6f630a5191cbba35b13aec69dc267009e73 Mon Sep 17 00:00:00 2001 From: Toufic Batache <taffou2a@gmail.com> Date: Fri, 8 Sep 2023 17:30:25 +0200 Subject: [PATCH] Added budget cache and implemented public/private simulations behavior --- example.env | 1 - src/lib/budgets.ts | 8 +- .../components/BudgetConnexionModal.svelte | 4 +- .../BudgetSimulationSharingModal.svelte | 63 ++++----- src/lib/components/ReformsChanges.svelte | 2 +- src/lib/server/auditors/config.ts | 11 +- src/lib/server/config.ts | 2 - src/lib/simulations.ts | 1 + src/routes/+layout.svelte | 55 ++++---- src/routes/+page.svelte | 30 ++--- src/routes/budget/+server.ts | 125 ++++++++++++++++-- src/routes/simulations_budget/+page.svelte | 4 +- src/routes/simulations_budget/+server.ts | 92 ++++++------- .../[simulation]/+page.server.ts | 20 ++- .../[simulation]/+page.svelte | 11 -- .../simulations_budget/index/+server.ts | 8 +- src/routes/simulations_budget/server.old.ts | 114 ++++++++++++++++ 17 files changed, 373 insertions(+), 178 deletions(-) create mode 100644 src/routes/simulations_budget/server.old.ts diff --git a/example.env b/example.env index 6108a4f35..4d1de3e19 100644 --- a/example.env +++ b/example.env @@ -18,7 +18,6 @@ BASE_URL="http://localhost:5173" # BUDGET_API_URL="https://SECRET.DOMAIN.NAME/state_simulation" # Public HTTP(S) URL of LexImpact Socio-Fiscal Budget server -# BUDGET_PUBLIC_API_URL="https://SECRET.DOMAIN.NAME/default_state_simulation" # Secret key used to sign JSON web tokens sent to Budget API # BUDGET_JWT_SECRET="SECRET" diff --git a/src/lib/budgets.ts b/src/lib/budgets.ts index 8ec2a2035..d5ec0c7f6 100644 --- a/src/lib/budgets.ts +++ b/src/lib/budgets.ts @@ -1,5 +1,3 @@ -import type { ParametricReform } from "$lib/reforms" - export interface BudgetCalculationResult { compare_before_after?: BudgetPopulationComparison | null quantiles: BudgetQuantile[] @@ -62,11 +60,7 @@ export interface BudgetSimulation { plf: BudgetCalculationResult revaluation: BudgetCalculationResult } -} - -export interface BudgetSimulationCache { - budgetSimulation: BudgetSimulation - parametricReform: ParametricReform + isPublic: boolean } export interface StateBudget { diff --git a/src/lib/components/BudgetConnexionModal.svelte b/src/lib/components/BudgetConnexionModal.svelte index 386d3f82c..273e1293a 100644 --- a/src/lib/components/BudgetConnexionModal.svelte +++ b/src/lib/components/BudgetConnexionModal.svelte @@ -52,8 +52,8 @@ console.error(`Error fetching cached simulations`) return } - const { index } = await res.json() - cachedSimulations = index + const { simulations } = await res.json() + cachedSimulations = simulations } function onClose() { diff --git a/src/lib/components/BudgetSimulationSharingModal.svelte b/src/lib/components/BudgetSimulationSharingModal.svelte index e05e4b099..50a2a7393 100644 --- a/src/lib/components/BudgetSimulationSharingModal.svelte +++ b/src/lib/components/BudgetSimulationSharingModal.svelte @@ -15,6 +15,7 @@ import type { DisplayMode } from "$lib/displays" import type { ParametricReform } from "$lib/reforms" import { budgetEditableParametersName } from "$lib/variables" + import { hashObject } from "$lib/hash.js" export let displayMode: DisplayMode export let isOpen = false @@ -24,7 +25,6 @@ > let clipboardElement: HTMLElement let hasClickedCopy = false - let isSimulationShared = false const parametricReform = getContext( "parametricReform", ) as Writable<ParametricReform> @@ -47,6 +47,8 @@ ] let url = "" + $: isSimulationShared = $budgetSimulation?.isPublic + function copyLink() { if (hasClickedCopy) { return @@ -61,14 +63,13 @@ setTimeout(() => (hasClickedCopy = false), 2500) } - async function onChange({ target }: Event) { - if ((target as HTMLInputElement).checked) { - const urlString = "/simulations_budget" - const res = await fetch(urlString, { - body: JSON.stringify({ - budgetSimulation: $budgetSimulation?.result, - displayMode, - parametricReform: Object.entries($parametricReform) + async function onChange() { + const urlString = "/simulations_budget" + const res = await fetch(urlString, { + body: JSON.stringify({ + displayMode, + hash: hashObject( + Object.entries($parametricReform) .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) .reduce((filtered: ParametricReform, [parameterName, value]) => { if (budgetEditableParametersName.has(parameterName)) { @@ -76,32 +77,32 @@ } return filtered }, {}), - }), - headers: { - Accept: "application/json", - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }) - if (!res.ok) { - console.error( - `Error ${ - res.status - } while creating a share link at ${urlString}\n\n${await res.text()}`, - ) - return - } - const { token } = await res.json() - url = new URL( - `/simulations_budget/${token}`, - $page.data.baseUrl, - ).toString() + ), + }), + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + method: "POST", + }) + if (!res.ok) { + console.error( + `Error ${ + res.status + } while sharing budget simulation\n\n${await res.text()}`, + ) + return + } + const { hash } = await res.json() + url = new URL(`/simulations_budget/${hash}`, $page.data.baseUrl).toString() + $budgetSimulation = { + ...$budgetSimulation, + isPublic: true, } } function onClose() { isOpen = false - isSimulationShared = false } </script> @@ -171,7 +172,7 @@ type="checkbox" class="sr-only peer" bind:checked={isSimulationShared} - on:change={(event) => onChange(event)} + on:change={onChange} disabled={isSimulationShared} /> <div diff --git a/src/lib/components/ReformsChanges.svelte b/src/lib/components/ReformsChanges.svelte index 5af044233..7cfccfbfe 100644 --- a/src/lib/components/ReformsChanges.svelte +++ b/src/lib/components/ReformsChanges.svelte @@ -66,7 +66,7 @@ <!--<h5 class="text-sm"> {reformMetadataByName[$billName].label} </h5>--> - <ul class="bg-gray-200 text-xs leading-relaxed text-gray-600"> + <ul class="text-xs leading-relaxed text-gray-600"> <li class="my-1"> <Tooltip arrowClass="bg-white border-le-rouge-bill" diff --git a/src/lib/server/auditors/config.ts b/src/lib/server/auditors/config.ts index 253e63cdc..4603fec64 100644 --- a/src/lib/server/auditors/config.ts +++ b/src/lib/server/auditors/config.ts @@ -65,9 +65,14 @@ export function auditConfig( auditRequire, ) } - for (const key of ["budgetApiUrl", "budgetPublicApiUrl"]) { - audit.attribute(data, key, true, errors, remainingKeys, auditHttpUrl) - } + audit.attribute( + data, + "budgetApiUrl", + true, + errors, + remainingKeys, + auditHttpUrl, + ) for (const key of [ "childrenKey", "familyEntityKey", diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 3e2a97b96..2abbd37ae 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -10,7 +10,6 @@ export interface Config { apiWebSocketBaseUrls: string[] baseUrl: string budgetApiUrl?: string - budgetPublicApiUrl?: string budgetJwtSecret?: string childrenKey: string familyEntityKey: string @@ -49,7 +48,6 @@ const [validConfig, error] = validateConfig({ apiBaseUrls: process.env["API_BASE_URLS"], baseUrl: process.env["BASE_URL"], budgetApiUrl: process.env["BUDGET_API_URL"], - budgetPublicApiUrl: process.env["BUDGET_PUBLIC_API_URL"], budgetJwtSecret: process.env["BUDGET_JWT_SECRET"], childrenKey: process.env["CHILDREN_KEY"], familyEntityKey: process.env["FAMILY_KEY"], diff --git a/src/lib/simulations.ts b/src/lib/simulations.ts index 1722aca0b..da168369e 100644 --- a/src/lib/simulations.ts +++ b/src/lib/simulations.ts @@ -2,6 +2,7 @@ export interface CachedSimulation { date: string hash: string parameters: string[] + public: boolean title: string } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7779a39d7..06825778f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -17,7 +17,7 @@ import { browser } from "$app/environment" import { page } from "$app/stores" - import type { BudgetSimulation, BudgetSimulationCache } from "$lib/budgets" + import type { BudgetSimulation } from "$lib/budgets" import { calculationNames, requestAllTestCasesCalculations, @@ -38,6 +38,7 @@ waterfalls, type EvaluationByName, } from "$lib/decompositions" + import type { DisplayMode } from "$lib/displays" import { entityByKey } from "$lib/entities" import { reformMetadataByName, type ParametricReform } from "$lib/reforms" import { @@ -59,7 +60,6 @@ import type { WebSocketByName, WebSocketOpenByName } from "$lib/websockets" import type { LayoutData } from "./$types" - import type { DisplayMode } from "$lib/displays" export let data: LayoutData @@ -73,9 +73,6 @@ setContext("budgetSimulation", budgetSimulation) let budgetSimulationAbortController = new AbortController() const budgetSimulationByBody: { [body: string]: BudgetSimulation } = {} - const budgetSimulationCache: Writable<BudgetSimulationCache | undefined> = - writable(undefined) - setContext("budgetSimulationCache", budgetSimulationCache) let currentBillName: string | undefined = data.reformName === undefined @@ -103,18 +100,6 @@ const date = writable("2023-01-01") // new Date().toISOString().split("T")[0] setContext("date", date) - const showNulls = writable(false) - setContext("showNulls", showNulls) - - const showTutorial = writable(false) - setContext("showTutorial", showTutorial) - - const vectorLength = writable(1) - setContext("vectorLength", vectorLength) - - let waterfall = writable(waterfalls[0]) - setContext("waterfall", waterfall) - const decompositionByName = writable( // Note: We always use the reform decomposition, because it is more // complete than decomposition before reform. @@ -127,6 +112,9 @@ ) setContext("decompositionByName", decompositionByName) + const displayMode: Writable<DisplayMode | undefined> = writable(undefined) + setContext("displayMode", displayMode) + const evaluationByNameArray = writable( new Array(testCasesCore.length).fill({}) as EvaluationByName[], ) @@ -168,6 +156,21 @@ }) setContext("requestedCalculations", requestedCalculations) + /* + * Search + * */ + const isSearchActive: Writable<boolean> = writable(false) + setContext("isSearchActive", isSearchActive) + + const searchParameterName: Writable<string | undefined> = writable(undefined) + setContext("searchParameterName", searchParameterName) + + const showNulls = writable(false) + setContext("showNulls", showNulls) + + const showTutorial = writable(false) + setContext("showTutorial", showTutorial) + const testCasesValue: Situation[] = testCasesCore // let testCasesValue: Situation[] // if (browser) { @@ -198,8 +201,14 @@ valuesByCalculationNameByVariableNameArray, ) + const vectorLength = writable(1) + setContext("vectorLength", vectorLength) + let vectorIndexes = new Array(testCasesCore.length).fill(0) + let waterfall = writable(waterfalls[0]) + setContext("waterfall", waterfall) + const webSocketByName: Writable<WebSocketByName | undefined> = writable(undefined) setContext("webSocketByName", webSocketByName) @@ -292,9 +301,6 @@ budgetVariableName: string, // budgetCalculationNames: Set<CalculationName>, ): Promise<void> { - if (user === undefined && $budgetSimulation !== undefined) { - return - } budgetSimulationAbortController.abort() budgetSimulationAbortController = new AbortController() const budgetParametricReform = Object.fromEntries( @@ -1096,15 +1102,6 @@ $evaluationByNameArray = updatedEvaluationByNameArray } } - - const isSearchActive: Writable<boolean> = writable(false) - setContext("isSearchActive", isSearchActive) - - const searchParameterName: Writable<string | undefined> = writable(undefined) - setContext("searchParameterName", searchParameterName) - - const displayMode: Writable<DisplayMode | undefined> = writable(undefined) - setContext("displayMode", displayMode) </script> <svelte:head> diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f0d9ce808..0e57ed62f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -11,7 +11,6 @@ auditFunction, auditOptions, } from "@auditors/core" - import deepEqual from "deep-equal" import type { PopulationWithoutId, Waterfall } from "@openfisca/json-model" import introJs from "intro.js" import { getContext, setContext } from "svelte" @@ -22,7 +21,7 @@ import { page } from "$app/stores" import { auditQueryArray, auditQuerySingleton } from "$lib/auditors/queries" import { auditSimulationHash } from "$lib/auditors/hashes" - import type { BudgetSimulation, BudgetSimulationCache } from "$lib/budgets" + import type { BudgetSimulation } from "$lib/budgets" import { requestAllBudgetCalculations, requestAllTestCasesCalculations, @@ -86,9 +85,6 @@ const budgetSimulation = getContext("budgetSimulation") as Writable< BudgetSimulation | undefined > - const budgetSimulationCache = getContext("budgetSimulationCache") as Writable< - BudgetSimulationCache | undefined - > let changeTestCaseIndex: number | undefined let clipboardElement: HTMLElement const date = getContext("date") as Writable<string> @@ -1352,16 +1348,8 @@ > {#if displayMode.budget} {#if displayMode.parametersVariableName !== undefined} - {@const budgetSimulationData = - $budgetSimulationCache !== undefined && - deepEqual( - $budgetSimulationCache?.parametricReform, - $parametricReform, - ) - ? $budgetSimulationCache.budgetSimulation - : $budgetSimulation} <div class="mb-6 flex flex-col px-2 lg:px-5 w-screen md:w-full"> - {#if budgetSimulationData === undefined || budgetSimulationData.errors.length > 0} + {#if $budgetSimulation === undefined || $budgetSimulation.errors.length > 0} {#if displayMode.parametersVariableName !== undefined} <div class="z-10 bg-le-jaune bg-opacity-20"> <Spinner /> @@ -1384,9 +1372,9 @@ <h3 class="mx-4 mb-2 text-2xl font-bold md:mx-0"> Impôt sur le revenu </h3> - <IrBudgetView budgetSimulation={budgetSimulationData} /> + <IrBudgetView budgetSimulation={$budgetSimulation} /> <IrGagnantsPerdantsView - budgetSimulation={budgetSimulationData} + budgetSimulation={$budgetSimulation} /> {:else if displayMode.parametersVariableName === "csg_deductible_salaire" || displayMode.parametersVariableName === "csg_imposable_salaire"} <!-- <a @@ -1408,9 +1396,9 @@ >Imposable et déductible</span > </h3> - <CsgBudgetView budgetSimulation={budgetSimulationData} /> + <CsgBudgetView budgetSimulation={$budgetSimulation} /> <CsgGagnantsPerdantsView - budgetSimulation={budgetSimulationData} + budgetSimulation={$budgetSimulation} /> {/if} </div> @@ -1626,7 +1614,8 @@ Object.keys($parametricReform).filter((parameterName) => budgetEditableParametersName.has(parameterName), ).length > 0 && - !deepEqual($budgetSimulationCache?.parametricReform, $parametricReform)} + Object.keys($parametricReform).length > 0 && + $budgetSimulation?.result.amendement === undefined} <div class="flex flex-col items-center md:block fixed bottom-0 md:bottom-auto md:top-12 2xl:top-14 md:right-3 bg-le-jaune-light z-40 rounded-t-lg md:rounded-t-none md:rounded-b-lg shadow-md mx-5 md:mx-0 inset-x-0 md:inset-x-auto px-3 lg:px-5 pt-3 pb-6 md:pt-6 md:pb-3 lg:pt-8 transition-transform duration-[350ms] ease-out-back md:delay-0" class:translate-y-full={!showButton} @@ -1659,7 +1648,8 @@ displayMode.budget && !displayMode.mobileLaw && displayMode.parametersVariableName !== undefined && - deepEqual($budgetSimulationCache?.parametricReform, $parametricReform)} + Object.keys($parametricReform).length > 0 && + $budgetSimulation?.result.amendement !== undefined} <div class="flex flex-col items-center md:block fixed bottom-0 md:bottom-auto md:top-12 2xl:top-14 md:right-3 bg-le-jaune-light z-40 rounded-t-lg md:rounded-t-none md:rounded-b-lg shadow-md mx-5 md:mx-0 inset-x-0 md:inset-x-auto px-3 lg:px-5 pt-3 pb-6 md:pt-6 md:pb-3 lg:pt-8 transition-transform duration-[350ms] ease-out-back md:delay-0" class:translate-y-full={!showButton} diff --git a/src/routes/budget/+server.ts b/src/routes/budget/+server.ts index f4e4c04ee..c734d133a 100644 --- a/src/routes/budget/+server.ts +++ b/src/routes/budget/+server.ts @@ -1,16 +1,32 @@ -import { error } from "@sveltejs/kit" - -import type { User } from "$lib/users" +import fs from "fs-extra" import jwt from "jsonwebtoken" +import path from "path" +import { error, json } from "@sveltejs/kit" +import type { BudgetSimulation } from "$lib/budgets" +import { hashObject } from "$lib/hash" +import { getParameter, rootParameter } from "$lib/parameters" +import type { ParametricReform } from "$lib/reforms" import config from "$lib/server/config" +import type { CachedSimulation } from "$lib/simulations" +import type { User } from "$lib/users" +import { budgetEditableParametersName } from "$lib/variables" import type { RequestHandler } from "./$types" +const { simulationsBudgetDir } = config + +function getPath(digest: string) { + return path.join( + simulationsBudgetDir, + digest.substring(0, 2), + `${digest}.json`, + ) +} + export const POST: RequestHandler = async ({ fetch, locals, request }) => { if ( config.budgetApiUrl === undefined || - config.budgetPublicApiUrl === undefined || config.budgetJwtSecret === undefined ) { throw error( @@ -21,15 +37,49 @@ export const POST: RequestHandler = async ({ fetch, locals, request }) => { const user = locals.user as User - if (user === undefined) { - return await fetch(config.budgetPublicApiUrl, { - body: await request.arrayBuffer(), - headers: { - Accept: "application/json", - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }) + const indexFilePath = path.join(simulationsBudgetDir, "index.json") + const indexContents: CachedSimulation[] = (await fs.pathExists(indexFilePath)) + ? await fs.readJson(indexFilePath) + : [] + + const parametricReformSorted = Object.entries( + (await request.clone().json()).amendement, + ) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .reduce((filtered: ParametricReform, [parameterName, value]) => { + if (budgetEditableParametersName.has(parameterName)) { + filtered[parameterName] = value + } + return filtered + }, {}) + + const parametricReformDigest = hashObject(parametricReformSorted) + + // Path of the no-reform base cache (hashing empty object) + const baseCachePath = getPath(hashObject({})) + + // Path of the current reform cache + const cachePath = getPath(parametricReformDigest) + + const isSimulationPublic = indexContents.find( + (cachedSimulation) => cachedSimulation.hash === parametricReformDigest, + )?.public + + if ( + (await fs.pathExists(cachePath)) && + (user !== undefined || isSimulationPublic) + ) { + return json({ + errors: [], + result: (await fs.readJson(cachePath)).budgetSimulation, + isPublic: isSimulationPublic, + } as BudgetSimulation) + } else if (user === undefined && (await fs.pathExists(baseCachePath))) { + return json({ + errors: [], + result: (await fs.readJson(baseCachePath)).budgetSimulation, + isPublic: true, + } as BudgetSimulation) } const payload = { @@ -49,5 +99,54 @@ export const POST: RequestHandler = async ({ fetch, locals, request }) => { }, method: "POST", }) + + const responseJson = await response.clone().json() + + responseJson.isPublic = false + + if (response.ok && responseJson.errors?.length === 0) { + const digest = hashObject(parametricReformSorted) + + const modifiedParametersTitles = Object.keys(parametricReformSorted).map( + (parameterName) => getParameter(rootParameter, parameterName)?.title, + ) + + const modifiedParametersNames = Object.keys(parametricReformSorted).map( + (parameterName) => getParameter(rootParameter, parameterName)?.name, + ) + + const simulationDir = path.join( + simulationsBudgetDir, + digest.substring(0, 2), + ) + const simulationFilePath = path.join(simulationDir, `${digest}.json`) + if (!(await fs.pathExists(simulationFilePath))) { + await fs.ensureDir(simulationDir) + await fs.writeFile( + simulationFilePath, + JSON.stringify( + { + budgetSimulation: responseJson.result, + parametricReform: parametricReformSorted, + }, + null, + 2, + ), + ) + } + + const contents: CachedSimulation[] = [ + ...indexContents, + { + date: new Intl.DateTimeFormat("fr-FR").format(new Date()), + hash: digest, + public: false, + parameters: modifiedParametersNames, + title: modifiedParametersTitles.join(" | "), + } as CachedSimulation, + ] + await fs.writeFile(indexFilePath, JSON.stringify(contents, null, 2)) + } + return response } diff --git a/src/routes/simulations_budget/+page.svelte b/src/routes/simulations_budget/+page.svelte index e08e42cc3..4e3c625a1 100644 --- a/src/routes/simulations_budget/+page.svelte +++ b/src/routes/simulations_budget/+page.svelte @@ -14,8 +14,8 @@ console.error(`Error fetching cached simulations`) return } - const { index } = await res.json() - cachedSimulations = index + const { simulations } = await res.json() + cachedSimulations = simulations }) </script> diff --git a/src/routes/simulations_budget/+server.ts b/src/routes/simulations_budget/+server.ts index 99e4ac2c2..f582cda1d 100644 --- a/src/routes/simulations_budget/+server.ts +++ b/src/routes/simulations_budget/+server.ts @@ -1,15 +1,13 @@ import { auditRequire, cleanAudit, type Audit } from "@auditors/core" -import { error, json } from "@sveltejs/kit" import fs from "fs-extra" import path from "path" +import { error, json } from "@sveltejs/kit" -import { getParameter, rootParameter } from "$lib/parameters" -import type { ParametricReform } from "$lib/reforms" +import type { DisplayMode } from "$lib/displays" import config from "$lib/server/config" import type { CachedSimulation } from "$lib/simulations" import type { RequestHandler } from "./$types" -import { hashObject } from "$lib/hash" const { simulationsBudgetDir } = config @@ -25,16 +23,6 @@ function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] { const errors: { [key: string]: unknown } = {} const remainingKeys = new Set(Object.keys(data)) - audit.attribute( - data, - "budgetSimulation", - true, - errors, - remainingKeys, - // TODO - auditRequire, - ) - audit.attribute( data, "displayMode", @@ -47,7 +35,7 @@ function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] { audit.attribute( data, - "parametricReform", + "hash", true, errors, remainingKeys, @@ -58,7 +46,11 @@ function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] { return audit.reduceRemaining(data, errors, remainingKeys) } -export const POST: RequestHandler = async ({ request, url }) => { +export const POST: RequestHandler = async ({ locals, request, url }) => { + if (locals.user === undefined) { + throw error(401, "Unauthorized") + } + const [body, bodyError] = auditBody(cleanAudit, await request.json()) if (bodyError !== null) { console.error( @@ -71,44 +63,40 @@ export const POST: RequestHandler = async ({ request, url }) => { throw error(400, `Invalid body: ${JSON.stringify(bodyError, null, 2)}`) } - const modifiedParametersTitles = Object.keys( - (body as { parametricReform: ParametricReform }).parametricReform, - ).map((parameterName) => getParameter(rootParameter, parameterName)?.title) - - const modifiedParametersNames = Object.keys( - (body as { parametricReform: ParametricReform }).parametricReform, - ).map((parameterName) => getParameter(rootParameter, parameterName)?.name) - - const bodyJson = JSON.stringify(body, null, 2) - - const digest = hashObject( - (body as { parametricReform: ParametricReform }).parametricReform, - ) - - const simulationDir = path.join(simulationsBudgetDir, digest.substring(0, 2)) - const simulationFilePath = path.join(simulationDir, `${digest}.json`) - if (!(await fs.pathExists(simulationFilePath))) { - await fs.ensureDir(simulationDir) - await fs.writeFile(simulationFilePath, bodyJson) + const { displayMode, hash } = body as { + displayMode: DisplayMode + hash: string } - const indexDir = path.join(simulationsBudgetDir, "index.json") - const indexContents: CachedSimulation[] = (await fs.pathExists(indexDir)) - ? await fs.readJson(indexDir) - : [] - const contents: CachedSimulation[] = [ - ...indexContents, - { - date: new Intl.DateTimeFormat("fr-FR").format(new Date()), - hash: digest, - parameters: modifiedParametersNames, - title: modifiedParametersTitles.join(" | "), - } as CachedSimulation, - ].filter( - (value, index, self) => - index === self.findLastIndex((el) => el.hash === value.hash), + /* + * + * Add display mode to public simulation (not needed for private cache) + * + * */ + + const simulationDir = path.join(simulationsBudgetDir, hash.substring(0, 2)) + const simulationFilePath = path.join(simulationDir, `${hash}.json`) + const simulationContents = await fs.readJson(simulationFilePath) + simulationContents.displayMode = displayMode + await fs.writeFile( + simulationFilePath, + JSON.stringify(simulationContents, null, 2), ) - await fs.writeFile(indexDir, JSON.stringify(contents)) - return json({ token: digest }) + /* + * + * Modify index.json file to indicate cache is now public + * + * */ + + const indexFilePath = path.join(simulationsBudgetDir, "index.json") + const indexContents = (await fs.readJson(indexFilePath)) as CachedSimulation[] + indexContents.forEach((cachedSimulation) => { + if (cachedSimulation.hash === hash) { + cachedSimulation.public = true + } + }) + await fs.writeFile(indexFilePath, JSON.stringify(indexContents, null, 2)) + + return json({ hash }) } diff --git a/src/routes/simulations_budget/[simulation]/+page.server.ts b/src/routes/simulations_budget/[simulation]/+page.server.ts index 6417a7066..aeeaa7ac3 100644 --- a/src/routes/simulations_budget/[simulation]/+page.server.ts +++ b/src/routes/simulations_budget/[simulation]/+page.server.ts @@ -15,6 +15,7 @@ import config from "$lib/server/config" import type { PageServerLoad } from "./$types" import type { BudgetCalculationResult } from "$lib/budgets" +import { CachedSimulation } from "$lib/simulations" const { simulationsBudgetDir } = config @@ -47,7 +48,11 @@ function auditParams(audit: Audit, dataUnknown: unknown): [unknown, unknown] { return audit.reduceRemaining(data, errors, remainingKeys) } -export const load: PageServerLoad = async ({ params: requestParams, url }) => { +export const load: PageServerLoad = async ({ + locals, + params: requestParams, + url, +}) => { const [params, paramsError] = auditParams(cleanAudit, requestParams) if (paramsError !== null) { console.error( @@ -61,12 +66,23 @@ export const load: PageServerLoad = async ({ params: requestParams, url }) => { } const { simulation: digest } = params as { simulation: string } + const indexFilePath = path.join(simulationsBudgetDir, "index.json") + const indexContents: CachedSimulation[] = (await fs.pathExists(indexFilePath)) + ? await fs.readJson(indexFilePath) + : [] + const simulationFilePath = path.join( simulationsBudgetDir, digest.substring(0, 2), `${digest}.json`, ) - if (!(await fs.pathExists(simulationFilePath))) { + if ( + !(await fs.pathExists(simulationFilePath)) || + (locals.user === undefined && + !indexContents.find( + (cachedSimulation) => cachedSimulation.hash === digest, + )?.public) + ) { throw error(404, `Simulation ${digest} not found`) } return { diff --git a/src/routes/simulations_budget/[simulation]/+page.svelte b/src/routes/simulations_budget/[simulation]/+page.svelte index b86717590..293e77d7b 100644 --- a/src/routes/simulations_budget/[simulation]/+page.svelte +++ b/src/routes/simulations_budget/[simulation]/+page.svelte @@ -2,7 +2,6 @@ import { getContext, onMount } from "svelte" import type { Writable } from "svelte/store" - import type { BudgetSimulationCache } from "$lib/budgets" import type { ParametricReform } from "$lib/reforms" import type { PageData } from "./$types" @@ -11,9 +10,6 @@ export let data: PageData - const budgetSimulationCache = getContext("budgetSimulationCache") as Writable< - BudgetSimulationCache | undefined - > const parametricReform = getContext( "parametricReform", ) as Writable<ParametricReform> @@ -21,13 +17,6 @@ $: ({ simulation } = data) onMount(() => { - $budgetSimulationCache = { - budgetSimulation: { - result: simulation.budgetSimulation, - errors: [], - }, - parametricReform: simulation.parametricReform, - } $parametricReform = simulation.parametricReform goto( simulation.displayMode === undefined diff --git a/src/routes/simulations_budget/index/+server.ts b/src/routes/simulations_budget/index/+server.ts index b8820b7df..002a69846 100644 --- a/src/routes/simulations_budget/index/+server.ts +++ b/src/routes/simulations_budget/index/+server.ts @@ -12,9 +12,13 @@ const { simulationsBudgetDir } = config export const POST: RequestHandler = async () => { const indexDir = path.join(simulationsBudgetDir, "index.json") + const cachedSimulations = (await fs.readJson(indexDir)) as CachedSimulation[] + return json({ - index: (await fs.pathExists(indexDir)) - ? ((await fs.readJson(indexDir)) as CachedSimulation[]) + simulations: (await fs.pathExists(indexDir)) + ? cachedSimulations.filter( + (simulation) => simulation.public && simulation.parameters.length > 0, + ) : [], }) } diff --git a/src/routes/simulations_budget/server.old.ts b/src/routes/simulations_budget/server.old.ts new file mode 100644 index 000000000..99e4ac2c2 --- /dev/null +++ b/src/routes/simulations_budget/server.old.ts @@ -0,0 +1,114 @@ +import { auditRequire, cleanAudit, type Audit } from "@auditors/core" +import { error, json } from "@sveltejs/kit" +import fs from "fs-extra" +import path from "path" + +import { getParameter, rootParameter } from "$lib/parameters" +import type { ParametricReform } from "$lib/reforms" +import config from "$lib/server/config" +import type { CachedSimulation } from "$lib/simulations" + +import type { RequestHandler } from "./$types" +import { hashObject } from "$lib/hash" + +const { simulationsBudgetDir } = config + +function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] { + if (dataUnknown == null) { + return [dataUnknown, null] + } + if (typeof dataUnknown !== "object") { + return audit.unexpectedType(dataUnknown, "object") + } + + const data = { ...dataUnknown } + const errors: { [key: string]: unknown } = {} + const remainingKeys = new Set(Object.keys(data)) + + audit.attribute( + data, + "budgetSimulation", + true, + errors, + remainingKeys, + // TODO + auditRequire, + ) + + audit.attribute( + data, + "displayMode", + true, + errors, + remainingKeys, + // TODO + auditRequire, + ) + + audit.attribute( + data, + "parametricReform", + true, + errors, + remainingKeys, + // TODO + auditRequire, + ) + + return audit.reduceRemaining(data, errors, remainingKeys) +} + +export const POST: RequestHandler = async ({ request, url }) => { + const [body, bodyError] = auditBody(cleanAudit, await request.json()) + if (bodyError !== null) { + console.error( + `Error in ${url.pathname} body:\n${JSON.stringify( + body, + null, + 2, + )}\n\nError:\n${JSON.stringify(bodyError, null, 2)}`, + ) + throw error(400, `Invalid body: ${JSON.stringify(bodyError, null, 2)}`) + } + + const modifiedParametersTitles = Object.keys( + (body as { parametricReform: ParametricReform }).parametricReform, + ).map((parameterName) => getParameter(rootParameter, parameterName)?.title) + + const modifiedParametersNames = Object.keys( + (body as { parametricReform: ParametricReform }).parametricReform, + ).map((parameterName) => getParameter(rootParameter, parameterName)?.name) + + const bodyJson = JSON.stringify(body, null, 2) + + const digest = hashObject( + (body as { parametricReform: ParametricReform }).parametricReform, + ) + + const simulationDir = path.join(simulationsBudgetDir, digest.substring(0, 2)) + const simulationFilePath = path.join(simulationDir, `${digest}.json`) + if (!(await fs.pathExists(simulationFilePath))) { + await fs.ensureDir(simulationDir) + await fs.writeFile(simulationFilePath, bodyJson) + } + + const indexDir = path.join(simulationsBudgetDir, "index.json") + const indexContents: CachedSimulation[] = (await fs.pathExists(indexDir)) + ? await fs.readJson(indexDir) + : [] + const contents: CachedSimulation[] = [ + ...indexContents, + { + date: new Intl.DateTimeFormat("fr-FR").format(new Date()), + hash: digest, + parameters: modifiedParametersNames, + title: modifiedParametersTitles.join(" | "), + } as CachedSimulation, + ].filter( + (value, index, self) => + index === self.findLastIndex((el) => el.hash === value.hash), + ) + await fs.writeFile(indexDir, JSON.stringify(contents)) + + return json({ token: digest }) +} -- GitLab