diff --git a/example.env b/example.env index 422403a3488c38fab084aa5e8e5a513a7f4818a0..2ca2ab2befc9b67d6c19f9f2af5eed7d22819346 100644 --- a/example.env +++ b/example.env @@ -20,6 +20,9 @@ BASE_URL="http://localhost:5173" # URL of GitLab pipeline to run when a non-authentication user requests a budget simulation # BUDGET_DEMAND_PIPELINE_URL="https://GITLAB.DOMAIN.NAME/api/v4/projects/PROJECT_ID/pipeline?ref=main" +# Token used for calling the budget simulation demand GitLab pipeline +#BUDGET_DEMAND_PIPELINE_TOKEN="SECRET" + # Secret key used to sign JSON web tokens sent to Budget API # BUDGET_JWT_SECRET="SECRET" diff --git a/src/lib/components/BudgetConnexionModal.svelte b/src/lib/components/BudgetConnexionModal.svelte index d33f41104c33539e813e44c20f219036ffd793d7..c85e1614357d6884c6459ec34a9d39955b4e0696 100644 --- a/src/lib/components/BudgetConnexionModal.svelte +++ b/src/lib/components/BudgetConnexionModal.svelte @@ -12,27 +12,29 @@ import { browser } from "$app/environment" import { goto } from "$app/navigation" import { page } from "$app/stores" - import type { DisplayMode } from "$lib/displays" import { trackBudgetPublicSimulation, trackBudgetSignInButton, } from "$lib/matomo" - import type { ParametricReform } from "$lib/reforms" import type { CachedSimulation } from "$lib/simulations" - import { budgetEditableParametersName } from "$lib/variables" - export let displayMode: DisplayMode export let isOpen = false let cachedSimulations: CachedSimulation[] = [] - const parametricReform = getContext( - "parametricReform", - ) as Writable<ParametricReform> + const requestedSimulationEmail = getContext( + "requestedSimulationEmail", + ) as Writable<string | undefined> + let email = "" + let isRequestSent = false $: if (browser && isOpen) { fetchCachedSimulations() } + $: if ($requestedSimulationEmail !== undefined) { + isRequestSent = true + } + async function fetchCachedSimulations() { const res = await fetch("/simulations_budget/index", { method: "POST", @@ -50,33 +52,7 @@ } async function sendSimulationRequest() { - const urlString = "/budget/demande" - const res = await fetch(urlString, { - body: JSON.stringify({ - displayMode, - parametricReform: Object.entries($parametricReform) - .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) - .reduce((filtered: ParametricReform, [parameterName, value]) => { - if (budgetEditableParametersName.has(parameterName)) { - filtered[parameterName] = value - } - return filtered - }, {}), - }), - headers: { - Accept: "application/json", - "Content-Type": "application/json; charset=utf-8", - }, - method: "POST", - }) - if (!res.ok) { - console.error( - `Error ${ - res.status - } while sending a simulation request at ${urlString}\n\n${await res.text()}`, - ) - return - } + $requestedSimulationEmail = email } </script> @@ -188,34 +164,40 @@ statistique, la simulation sera rendue publique. Vous serez alors informé par e-mail : </p> - <span class="font-bold text-sm py-2 pl-10" - >Votre adresse e-mail :</span - > - <div - class="flex md:flex-row flex-col w-full px-0 md:px-10 items-center gap-5" - > + {#if !isRequestSent} + <span class="font-bold text-sm py-2 pl-10" + >Votre adresse e-mail :</span + > <div - class="flex rounded-t-md border-b-2 border-b-black bg-white px-2 grow max-w-lg" + class="flex md:flex-row flex-col w-full px-0 md:px-10 items-center gap-5" > - <input - autocomplete="off" - class="w-full px-3 py-2 border-none bg-transparent text-sm text-gray-900 placeholder-gray-400 !ring-transparent focus:outline-none 2xl:text-base" - id="search" - placeholder="e-mail@email.fr" - type="search" - /> + <div + class="flex rounded-t-md border-b-2 border-b-black bg-white px-2 grow max-w-lg" + > + <input + autocomplete="off" + class="w-full px-3 py-2 border-none bg-transparent text-sm text-gray-900 placeholder-gray-400 !ring-transparent focus:outline-none 2xl:text-base" + placeholder="e-mail@email.fr" + type="email" + bind:value={email} + /> + </div> + <button + class="flex items-center gap-2 py-2 px-5 shadow-lg bg-white hover:bg-gray-100 active:bg-gray-200 rounded-md border-2 border-le-bleu text-le-bleu text-sm font-bold tracking-[0.085em] uppercase" + title="Envoyer votre réforme budgétaire avec cet e-mail" + on:click={sendSimulationRequest} + > + Demander le calcul <iconify-icon + class="ml-2 align-[-0.25rem] text-xl" + icon="ri-send-plane-2-line" + /> + </button> </div> - <button - class="flex items-center gap-2 py-2 px-5 shadow-lg bg-white hover:bg-gray-100 active:bg-gray-200 rounded-md border-2 border-le-bleu text-le-bleu text-sm font-bold tracking-[0.085em] uppercase" - title="Envoyer votre réforme budgétaire avec cet e-mail" - type="submit" - > - Demander le calcul <iconify-icon - class="ml-2 align-[-0.25rem] text-xl" - icon="ri-send-plane-2-line" - /> - </button> - </div> + {:else} + <div class="w-full p-3 text-center bg-yellow-100 font-bold"> + Votre demande de simulation a bien été prise en compte ! + </div> + {/if} </section> {/if} diff --git a/src/lib/server/auditors/config.ts b/src/lib/server/auditors/config.ts index 913dca17a2e6e3eab3cdcd5ba772a16baeafe52f..587ff0c3d8d9f0f7e37ea1fb71231be07f9c1a32 100644 --- a/src/lib/server/auditors/config.ts +++ b/src/lib/server/auditors/config.ts @@ -89,6 +89,7 @@ export function auditConfig( ) } for (const key of [ + "budgetDemandPipelineToken", "budgetJwtSecret", "githubPersonalAccessToken", "reformName", diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index c23d480b08ef892e7cfb345c8351991723616b94..e6a7ebc213dd4b3fe678eacae58bfd6376ea1418 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -11,6 +11,7 @@ export interface Config { baseUrl: string budgetApiUrl?: string budgetDemandPipelineUrl?: string + budgetDemandPipelineToken?: string budgetJwtSecret?: string childrenKey: string familyEntityKey: string @@ -50,6 +51,7 @@ const [validConfig, error] = validateConfig({ baseUrl: process.env["BASE_URL"], budgetApiUrl: process.env["BUDGET_API_URL"], budgetDemandPipelineUrl: process.env["BUDGET_DEMAND_PIPELINE_URL"], + budgetDemandPipelineToken: process.env["BUDGET_DEMAND_PIPELINE_TOKEN"], budgetJwtSecret: process.env["BUDGET_JWT_SECRET"], childrenKey: process.env["CHILDREN_KEY"], familyEntityKey: process.env["FAMILY_KEY"], diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index e92d6ed32b7e3727c57094e1da2f4fe3cdb12f40..ac2dff08c39fa8874933608f46bb5c9372b4e102 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -45,7 +45,9 @@ export const load: LayoutServerLoad = async ( apiWebSocketBaseUrls: config.apiWebSocketBaseUrls, authenticationEnabled: config.openIdConnect !== undefined, baseUrl: config.baseUrl, - canDemandBudgetSimulation: config.budgetDemandPipelineUrl !== undefined, + canDemandBudgetSimulation: + config.budgetDemandPipelineUrl !== undefined && + config.budgetDemandPipelineToken !== undefined, childrenKey: config.childrenKey, familyEntityKey: config.familyEntityKey, hasGithubPersonalAccessToken: diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c8506d0a21fea8803244873682520c7e2650fd94..d2da7959dc9a0792c913661a37ca951ce652e178 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -162,6 +162,10 @@ }) setContext("requestedCalculations", requestedCalculations) + const requestedSimulationEmail: Writable<string | undefined> = + writable(undefined) + setContext("requestedSimulationEmail", requestedSimulationEmail) + /* * Search * */ @@ -253,6 +257,10 @@ calculateBudget(budgetVariableName /*, budgetCalculationNames */) // Don't await } + $: if ($requestedSimulationEmail !== undefined) { + sendBudgetSimulationRequest() + } + $: if ( Object.keys($requestedCalculations.situationIndexByCalculationName).length > 0 @@ -383,6 +391,101 @@ } } + async function sendBudgetSimulationRequest() { + const budgetVariableName = $displayMode?.parametersVariableName + const budgetParametricReform = Object.fromEntries( + Object.entries($parametricReform).filter(([parameterName]) => + budgetEditableParametersName.has(parameterName), + ), + ) + const simulationBody = + budgetVariableName === "irpp_economique" + ? { + amendement: budgetParametricReform, + base: 2024, + metadata, + output_variables: ["rfr_par_part", "irpp"], + quantile_base_variable: ["rfr_par_part"], + quantile_compare_variables: ["irpp"], + quantile_nb: 10, + plf: $billName === undefined ? undefined : 2024, + } + : [ + "csg_deductible_retraite", + "csg_imposable_retraite", + "csg_retraite", + ].includes(budgetVariableName) + ? { + amendement: budgetParametricReform, + base: 2024, + metadata, + output_variables: [ + "rfr_par_part", // "assiette_csg_abattue", + "csg_deductible_retraite", + "csg_imposable_retraite", + ], + quantile_base_variable: ["rfr_par_part"], // ["assiette_csg_abattue"], + quantile_compare_variables: [ + "csg_deductible_retraite", + "csg_imposable_retraite", + ], + quantile_nb: 10, + plf: $billName === undefined ? undefined : 2024, + } + : [ + "csg_deductible_salaire", + "csg_imposable_salaire", + "csg_salaire", + ].includes(budgetVariableName) + ? { + amendement: budgetParametricReform, + base: 2024, + metadata, + output_variables: [ + "rfr_par_part", // "assiette_csg_abattue", + "csg_deductible_salaire", + "csg_imposable_salaire", + ], + quantile_base_variable: ["rfr_par_part"], // ["assiette_csg_abattue"], + quantile_compare_variables: [ + "csg_deductible_salaire", + "csg_imposable_salaire", + ], + quantile_nb: 10, + plf: $billName === undefined ? undefined : 2024, + } + : { + // Should never occur. + } + const urlString = "/budget/demande" + const res = await fetch(urlString, { + body: JSON.stringify( + { + email: $requestedSimulationEmail, + simulation: simulationBody, + displayMode: $displayMode, + }, + null, + 2, + ), + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + method: "POST", + }) + if (!res.ok) { + $requestedSimulationEmail = undefined + console.error( + `Error ${ + res.status + } while sending a simulation request at ${urlString}\n\n${await res.text()}`, + ) + return + } + $requestedSimulationEmail = undefined + } + function calculateSituations( situationIndexByCalculationName: RequestedSituationIndexByCalculationName, ) { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1c1a4276f00e9345c9eaab186bfff8bfe613000f..ebd94e0feb44ebf11fc3faf04b2c96072a2399ab 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1762,10 +1762,7 @@ </div> </div> - <BudgetConnexionModal - bind:isOpen={isBudgetConnexionModalOpen} - {displayMode} - /> + <BudgetConnexionModal bind:isOpen={isBudgetConnexionModalOpen} /> {/if} <!--Bouton flottant "simulation publique" --> diff --git a/src/routes/budget/demande/+server.ts b/src/routes/budget/demande/+server.ts index cd89218186add5f3252498c5ca48a2f51a0c26b1..8754336484a780f1f7a3333759da0f35bdaec249 100644 --- a/src/routes/budget/demande/+server.ts +++ b/src/routes/budget/demande/+server.ts @@ -1,17 +1,10 @@ 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 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 function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] { if (dataUnknown == null) { @@ -27,16 +20,27 @@ function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] { audit.attribute( data, - "displayMode", + "email", true, errors, remainingKeys, // TODO auditRequire, ) + audit.attribute( data, - "parametricReform", + "simulation", + true, + errors, + remainingKeys, + // TODO + auditRequire, + ) + + audit.attribute( + data, + "displayMode", true, errors, remainingKeys, @@ -48,7 +52,7 @@ function auditBody(audit: Audit, dataUnknown: unknown): [unknown, unknown] { } export const POST: RequestHandler = async ({ fetch, request, url }) => { - const [body, bodyError] = auditBody(cleanAudit, await request.json()) + const [body, bodyError] = auditBody(cleanAudit, await request.clone().json()) if (bodyError !== null) { console.error( `Error in ${url.pathname} body:\n${JSON.stringify( @@ -60,45 +64,32 @@ export const POST: RequestHandler = async ({ fetch, 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 response = await fetch() - const bodyJson = JSON.stringify(body, null, 2) + const { email, simulation, displayMode } = body as { + email: string + simulation: any + displayMode: DisplayMode + } - const digest = hashObject( - (body as { parametricReform: ParametricReform }).parametricReform, - ) + const response = await fetch(config.budgetDemandPipelineUrl!, { + body: JSON.stringify({ + variables: [ + { key: "EMAIL", value: email }, + { key: "SIMULATION", value: JSON.stringify(simulation) }, + { key: "DISPLAY_MODE", value: JSON.stringify(displayMode) }, + ], + }), + headers: { + "Content-Type": "application/json; charset=utf-8", + "PRIVATE-TOKEN": config.budgetDemandPipelineToken!, + }, + method: "POST", + }) - 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) + if (!response.ok) { + const message = "Error while demanding a budget simulation calculation" + console.error(message) + throw error(500, message) } - 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 }) + return json({}) }