diff --git a/packages/applications/cli/src/helpers/parse-file.ts b/packages/applications/cli/src/helpers/parse-file.ts index 116f0a5db18..2c032ce92ce 100644 --- a/packages/applications/cli/src/helpers/parse-file.ts +++ b/packages/applications/cli/src/helpers/parse-file.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import { z } from 'zod'; import { Flags } from '@oclif/core'; -import { CsvValidationError, parseCsv, ParseOptions } from '@potentiel-libraries/csv'; +import { CsvLineValidationError, parseCsv, ParseOptions } from '@potentiel-libraries/csv'; export const csvFlags = { delimiter: Flags.string({ @@ -29,9 +29,13 @@ export const parseCsvFile = async ( controller.close(); }, }); - return await parseCsv(readableStream, schema, options); + return await parseCsv({ + fileStream: readableStream, + lineSchema: schema, + parseOptions: options, + }); } catch (error) { - if (error instanceof CsvValidationError) { + if (error instanceof CsvLineValidationError) { console.log(error.errors); } throw error; diff --git a/packages/applications/ssr/src/app/candidatures/corriger-par-lot/CorrigerCandidaturesParLot.action.ts b/packages/applications/ssr/src/app/candidatures/corriger-par-lot/CorrigerCandidaturesParLot.action.ts index 0f7ce8fc753..c9e498609a9 100644 --- a/packages/applications/ssr/src/app/candidatures/corriger-par-lot/CorrigerCandidaturesParLot.action.ts +++ b/packages/applications/ssr/src/app/candidatures/corriger-par-lot/CorrigerCandidaturesParLot.action.ts @@ -23,11 +23,14 @@ export type CorrigerCandidaturesParLotFormKeys = keyof zod.infer; const action: FormAction = async (_, { fichierCorrectionCandidatures }) => withUtilisateur(async (utilisateur) => { - const { parsedData, rawData } = await parseCsv( - fichierCorrectionCandidatures.content, - candidatureCsvSchema, - { encoding: 'win1252', delimiter: ';' }, - ); + const { parsedData, rawData } = await parseCsv({ + fileStream: fichierCorrectionCandidatures.content, + lineSchema: candidatureCsvSchema, + parseOptions: { + encoding: 'win1252', + delimiter: ';', + }, + }); if (parsedData.length === 0) { return { diff --git a/packages/applications/ssr/src/app/candidatures/importer/(csv)/importerCandidaturesParCSV.action.ts b/packages/applications/ssr/src/app/candidatures/importer/(csv)/importerCandidaturesParCSV.action.ts index a680c311261..43bfe15c108 100644 --- a/packages/applications/ssr/src/app/candidatures/importer/(csv)/importerCandidaturesParCSV.action.ts +++ b/packages/applications/ssr/src/app/candidatures/importer/(csv)/importerCandidaturesParCSV.action.ts @@ -14,7 +14,7 @@ import { Routes } from '@potentiel-applications/routes'; import { ActionResult, FormAction, formAction, FormState } from '@/utils/formAction'; import { withUtilisateur } from '@/utils/withUtilisateur'; import { singleDocument } from '@/utils/zod/document/singleDocument'; -import { candidatureCsvSchema } from '@/utils/candidature'; +import { candidatureCsvSchema, candidatureCsvHeadersMapping } from '@/utils/candidature'; import { mapCsvRowToFournisseurs } from '@/utils/candidature/csv/fournisseurCsv'; import { removeEmptyValues } from '@/utils/candidature/removeEmptyValues'; @@ -32,14 +32,15 @@ const action: FormAction = async ( { fichierImportCandidature, appelOffre, periode, modeMultiple }, ) => withUtilisateur(async (utilisateur) => { - const { parsedData, rawData } = await parseCsv( - fichierImportCandidature.content, - candidatureCsvSchema, - { + const { parsedData, rawData } = await parseCsv({ + fileStream: fichierImportCandidature.content, + lineSchema: candidatureCsvSchema, + columnsToVerify: Object.values(candidatureCsvHeadersMapping), + parseOptions: { encoding: 'win1252', delimiter: ';', }, - ); + }); if (parsedData.length === 0) { return { diff --git "a/packages/applications/ssr/src/app/candidatures/importer/(demarche-simplifi\303\251e)/importerCandidaturesParDS.action.ts" "b/packages/applications/ssr/src/app/candidatures/importer/(demarche-simplifi\303\251e)/importerCandidaturesParDS.action.ts" index fc38bc72c7f..b12fdc8058f 100644 --- "a/packages/applications/ssr/src/app/candidatures/importer/(demarche-simplifi\303\251e)/importerCandidaturesParDS.action.ts" +++ "b/packages/applications/ssr/src/app/candidatures/importer/(demarche-simplifi\303\251e)/importerCandidaturesParDS.action.ts" @@ -51,11 +51,11 @@ const action: FormAction = async ( let success: number = 0; const errors: ActionResult['errors'] = []; - const { parsedData: instructions } = await parseCsv( - fichierInstruction.content, - instructionCsvSchema, - { delimiter: ';' }, - ); + const { parsedData: instructions } = await parseCsv({ + fileStream: fichierInstruction.content, + lineSchema: instructionCsvSchema, + parseOptions: { delimiter: ';' }, + }); if (instructions.length === 0) { return { diff --git a/packages/applications/ssr/src/app/reseaux/raccordements/importer/importDatesMiseEnService.action.ts b/packages/applications/ssr/src/app/reseaux/raccordements/importer/importDatesMiseEnService.action.ts index 2e08f6696c4..22fdda17a5b 100644 --- a/packages/applications/ssr/src/app/reseaux/raccordements/importer/importDatesMiseEnService.action.ts +++ b/packages/applications/ssr/src/app/reseaux/raccordements/importer/importDatesMiseEnService.action.ts @@ -48,7 +48,10 @@ const action: FormAction = async ( GestionnaireRéseau.IdentifiantGestionnaireRéseau.convertirEnValueType( identifiantGestionnaireReseau, ); - const { parsedData: lines } = await parseCsv(fichierDatesMiseEnService.content, csvSchema); + const { parsedData: lines } = await parseCsv({ + fileStream: fichierDatesMiseEnService.content, + lineSchema: csvSchema, + }); if (lines.length === 0) { return { diff --git "a/packages/applications/ssr/src/app/reseaux/raccordements/references:corriger/corrigerR\303\251f\303\251rencesDossier.action.ts" "b/packages/applications/ssr/src/app/reseaux/raccordements/references:corriger/corrigerR\303\251f\303\251rencesDossier.action.ts" index 98465aec447..55a86996240 100644 --- "a/packages/applications/ssr/src/app/reseaux/raccordements/references:corriger/corrigerR\303\251f\303\251rencesDossier.action.ts" +++ "b/packages/applications/ssr/src/app/reseaux/raccordements/references:corriger/corrigerR\303\251f\303\251rencesDossier.action.ts" @@ -27,10 +27,14 @@ const csvSchema = zod.object({ const action: FormAction = (_, { fichierCorrections }) => withUtilisateur(async (utilisateur) => { - const { parsedData: lines } = await parseCsv(fichierCorrections.content, csvSchema, { - // on conserve les espaces, car c'est potentiellement l'erreur à corriger - ltrim: false, - rtrim: false, + const { parsedData: lines } = await parseCsv({ + fileStream: fichierCorrections.content, + lineSchema: csvSchema, + parseOptions: { + // on conserve les espaces, car c'est potentiellement l'erreur à corriger + ltrim: false, + rtrim: false, + }, }); if (lines.length === 0) { diff --git a/packages/applications/ssr/src/components/atoms/form/Form.tsx b/packages/applications/ssr/src/components/atoms/form/Form.tsx index 9afa231b4f1..2937be7eeed 100644 --- a/packages/applications/ssr/src/components/atoms/form/Form.tsx +++ b/packages/applications/ssr/src/components/atoms/form/Form.tsx @@ -8,10 +8,11 @@ import { formAction, ValidationErrors } from '@/utils/formAction'; import { Heading2 } from '../headings'; -import { FormFeedback } from './FormFeedback'; +import { FormFeedback } from './form-feedback/FormFeedback'; import { FormPendingModal, FormPendingModalProps } from './FormPendingModal'; -import { FormFeedbackCsvErrors } from './FormFeedbackCsvErrors'; import { FormActionButtons, FormActionButtonsProps } from './FormActionButtons'; +import { FormFeedbackCsvLineErrors } from './form-feedback/FormFeedbackCsvLineErrors'; +import { FormFeedbackCsvColumnErrors } from './form-feedback/FormFeedbackCsvColumnErrors'; export type FormProps = { id?: string; @@ -99,7 +100,8 @@ export const Form: FC = ({ )} - + {state.status === 'csv-line-error' && } + {state.status === 'csv-column-error' && } ); }; diff --git a/packages/applications/ssr/src/components/atoms/form/FormFeedback.tsx b/packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedback.tsx similarity index 77% rename from packages/applications/ssr/src/components/atoms/form/FormFeedback.tsx rename to packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedback.tsx index 83482d070fb..b52b27bd649 100644 --- a/packages/applications/ssr/src/components/atoms/form/FormFeedback.tsx +++ b/packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedback.tsx @@ -4,10 +4,11 @@ import { FC } from 'react'; import Alert from '@codegouvfr/react-dsfr/Alert'; import { useFormStatus } from 'react-dom'; import Link from 'next/link'; +import { match, P } from 'ts-pattern'; import { FormState } from '@/utils/formAction'; -import { FormAlertError } from './FormAlertError'; +import { FormAlertError } from '../FormAlertError'; export type FormFeedbackProps = { formState: FormState; @@ -20,8 +21,8 @@ export const FormFeedback: FC = ({ formState }) => { return undefined; } - switch (formState.status) { - case 'success': + return match(formState) + .with({ status: 'success' }, (formState) => { if (formState.result) { const { result: { successMessage, errors, link }, @@ -70,20 +71,17 @@ export const FormFeedback: FC = ({ formState }) => { } return ; - - case 'rate-limit-error': - case 'domain-error': + }) + .with({ status: P.union('rate-limit-error', 'domain-error') }, (formState) => { return ; + }) - case 'unknown-error': - return ; - - case 'validation-error': - return ( - - ); + .with({ status: 'validation-error' }, () => ( + + )) - default: - return null; - } + .with({ status: 'unknown-error' }, () => ( + + )) + .otherwise(() => null); }; diff --git a/packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedbackCsvColumnErrors.tsx b/packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedbackCsvColumnErrors.tsx new file mode 100644 index 00000000000..0c06147641b --- /dev/null +++ b/packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedbackCsvColumnErrors.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import Alert from '@codegouvfr/react-dsfr/Alert'; +import { fr } from '@codegouvfr/react-dsfr'; +import { useFormStatus } from 'react-dom'; + +import { FormStateCsvColumnError } from '@/utils/formAction'; + +type FormFeedbackCsvColumnErrorsProps = { + formState: FormStateCsvColumnError; +}; + +export const FormFeedbackCsvColumnErrors: FC = ({ + formState, +}) => { + const { pending } = useFormStatus(); + + if (pending) { + return undefined; + } + + return ( + + {formState.errors.map((error, index) => ( +
  • {error.column}
  • + ))} + + } + /> + ); +}; diff --git a/packages/applications/ssr/src/components/atoms/form/FormFeedbackCsvErrors.tsx b/packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedbackCsvLineErrors.tsx similarity index 72% rename from packages/applications/ssr/src/components/atoms/form/FormFeedbackCsvErrors.tsx rename to packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedbackCsvLineErrors.tsx index ae3da14ffa6..11299c409be 100644 --- a/packages/applications/ssr/src/components/atoms/form/FormFeedbackCsvErrors.tsx +++ b/packages/applications/ssr/src/components/atoms/form/form-feedback/FormFeedbackCsvLineErrors.tsx @@ -4,22 +4,22 @@ import Accordion from '@codegouvfr/react-dsfr/Accordion'; import { fr } from '@codegouvfr/react-dsfr'; import { useFormStatus } from 'react-dom'; -import { CsvError } from '@potentiel-libraries/csv'; +import { CsvLineError } from '@potentiel-libraries/csv'; -import { FormState } from '@/utils/formAction'; +import { FormStateCsvLineError } from '@/utils/formAction'; -type FormFeedbackCsvErrorsProps = { - formState: FormState; +type FormFeedbackCsvLineErrorsProps = { + formState: FormStateCsvLineError; }; -export const FormFeedbackCsvErrors: FC = ({ formState }) => { +export const FormFeedbackCsvLineErrors: FC = ({ formState }) => { const { pending } = useFormStatus(); - if (pending || formState.status !== 'csv-error') { + if (pending) { return undefined; } - const regroupedErrors = formState.errors.reduce( + const regroupeCsvLineErrors = formState.errors.reduce( (acc, error) => { if (!acc[error.line]) { acc[error.line] = []; @@ -27,7 +27,7 @@ export const FormFeedbackCsvErrors: FC = ({ formStat acc[error.line].push(error); return acc; }, - {} as Record, + {} as Record, ); return ( @@ -38,7 +38,7 @@ export const FormFeedbackCsvErrors: FC = ({ formStat className="mt-6" description={
    - {Object.entries(regroupedErrors).map(([ligne, erreurs]) => ( + {Object.entries(regroupeCsvLineErrors).map(([ligne, erreurs]) => (
      {erreurs.map((erreur) => ( diff --git a/packages/applications/ssr/src/utils/candidature/csv/candidatureCsv.schema.ts b/packages/applications/ssr/src/utils/candidature/csv/candidatureCsv.schema.ts index 466612b3e74..8049262da0b 100644 --- a/packages/applications/ssr/src/utils/candidature/csv/candidatureCsv.schema.ts +++ b/packages/applications/ssr/src/utils/candidature/csv/candidatureCsv.schema.ts @@ -79,7 +79,7 @@ const technologie = { } satisfies Record; // Les colonnes du fichier Csv -const colonnes = { +export const candidatureCsvHeadersMapping = { appelOffre: `Appel d'offres`, période: 'Période', famille: 'Famille', @@ -129,114 +129,159 @@ const colonnes = { const candidatureCsvRowSchema = z .object({ notifiedOn: notifiedOnCsvSchema, - [colonnes.appelOffre]: appelOffreSchema, - [colonnes.période]: périodeSchema, - [colonnes.famille]: familleSchema, - [colonnes.numéroCRE]: numéroCRESchema, - [colonnes.nomProjet]: nomProjetSchema, - [colonnes.sociétéMère]: sociétéMèreSchema, - [colonnes.nomCandidat]: nomCandidatSchema, - [colonnes.puissanceProductionAnnuelle]: puissanceOuPuissanceDeSiteSchema, - [colonnes.prixReference]: prixRéférenceSchema, - [colonnes.noteTotale]: noteTotaleSchema, - [colonnes.nomReprésentantLégal]: nomReprésentantLégalSchema, - [colonnes.emailContact]: emailContactSchema, - [colonnes.adresse1]: adresse1CsvSchema, - [colonnes.adresse2]: adresse2Schema, // see refine below - [colonnes.codePostal]: codePostalCsvSchema, - [colonnes.commune]: communeSchema, - [colonnes.statut]: statutCsvSchema, - [colonnes.puissanceALaPointe]: puissanceALaPointeCsvSchema, - [colonnes.evaluationCarboneSimplifiée]: évaluationCarboneSimplifiéeCsvSchema, - [colonnes.technologie]: technologieCsvSchema, - [colonnes.financementCollectif]: financementCollectifCsvSchema, - [colonnes.gouvernancePartagée]: gouvernancePartagéeCsvSchema, - [colonnes.historiqueAbandon]: historiqueAbandonCsvSchema, - [colonnes.coefficientKChoisi]: choixCoefficientKCsvSchema, - [colonnes.puissanceDeSite]: optionalPuissanceOuPuissanceDeSiteSchema, - [colonnes.typeInstallationsAgrivoltaïques]: installationsAgrivoltaïquesCsvSchema, - [colonnes.élémentsSousOmbrière]: élémentsSousOmbrièreCsvSchema, - [colonnes.typologieDeBâtiment]: typologieDeBâtimentCsvSchema, - [colonnes.obligationDeSolarisation]: obligationDeSolarisationCsvSchema, - [colonnes.dateDAutorisationDUrbanisme]: dateDAutorisationDUrbanismeCsvSchema, - [colonnes.numéroDAutorisationDUrbanisme]: numéroDAutorisationDUrbanismeSchema, - [colonnes.installateur]: installateurSchema, - [colonnes.installationAvecDispositifDeStockage]: installationAvecDispositifDeStockageCsvSchema, - [colonnes.puissanceDuDispositifDeStockageEnKW]: puissanceDuDispositifDeStockageSchema, - [colonnes.capacitéDuDispositifDeStockageEnKWh]: capacitéDuDispositifDeStockageSchema, - [colonnes.natureDeLExploitation]: natureDeLExploitationCsvSchema, - [colonnes.tauxPrévisionnelACI]: optionalPercentageSchema, + [candidatureCsvHeadersMapping.appelOffre]: appelOffreSchema, + [candidatureCsvHeadersMapping.période]: périodeSchema, + [candidatureCsvHeadersMapping.famille]: familleSchema, + [candidatureCsvHeadersMapping.numéroCRE]: numéroCRESchema, + [candidatureCsvHeadersMapping.nomProjet]: nomProjetSchema, + [candidatureCsvHeadersMapping.sociétéMère]: sociétéMèreSchema, + [candidatureCsvHeadersMapping.nomCandidat]: nomCandidatSchema, + [candidatureCsvHeadersMapping.puissanceProductionAnnuelle]: puissanceOuPuissanceDeSiteSchema, + [candidatureCsvHeadersMapping.prixReference]: prixRéférenceSchema, + [candidatureCsvHeadersMapping.noteTotale]: noteTotaleSchema, + [candidatureCsvHeadersMapping.nomReprésentantLégal]: nomReprésentantLégalSchema, + [candidatureCsvHeadersMapping.emailContact]: emailContactSchema, + [candidatureCsvHeadersMapping.adresse1]: adresse1CsvSchema, + [candidatureCsvHeadersMapping.adresse2]: adresse2Schema, // see refine below + [candidatureCsvHeadersMapping.codePostal]: codePostalCsvSchema, + [candidatureCsvHeadersMapping.commune]: communeSchema, + [candidatureCsvHeadersMapping.statut]: statutCsvSchema, + [candidatureCsvHeadersMapping.puissanceALaPointe]: puissanceALaPointeCsvSchema, + [candidatureCsvHeadersMapping.evaluationCarboneSimplifiée]: + évaluationCarboneSimplifiéeCsvSchema, + [candidatureCsvHeadersMapping.technologie]: technologieCsvSchema, + [candidatureCsvHeadersMapping.financementCollectif]: financementCollectifCsvSchema, + [candidatureCsvHeadersMapping.gouvernancePartagée]: gouvernancePartagéeCsvSchema, + [candidatureCsvHeadersMapping.historiqueAbandon]: historiqueAbandonCsvSchema, + [candidatureCsvHeadersMapping.coefficientKChoisi]: choixCoefficientKCsvSchema, + [candidatureCsvHeadersMapping.puissanceDeSite]: optionalPuissanceOuPuissanceDeSiteSchema, + [candidatureCsvHeadersMapping.typeInstallationsAgrivoltaïques]: + installationsAgrivoltaïquesCsvSchema, + [candidatureCsvHeadersMapping.élémentsSousOmbrière]: élémentsSousOmbrièreCsvSchema, + [candidatureCsvHeadersMapping.typologieDeBâtiment]: typologieDeBâtimentCsvSchema, + [candidatureCsvHeadersMapping.obligationDeSolarisation]: obligationDeSolarisationCsvSchema, + [candidatureCsvHeadersMapping.dateDAutorisationDUrbanisme]: + dateDAutorisationDUrbanismeCsvSchema, + [candidatureCsvHeadersMapping.numéroDAutorisationDUrbanisme]: + numéroDAutorisationDUrbanismeSchema, + [candidatureCsvHeadersMapping.installateur]: installateurSchema, + [candidatureCsvHeadersMapping.installationAvecDispositifDeStockage]: + installationAvecDispositifDeStockageCsvSchema, + [candidatureCsvHeadersMapping.puissanceDuDispositifDeStockageEnKW]: + puissanceDuDispositifDeStockageSchema, + [candidatureCsvHeadersMapping.capacitéDuDispositifDeStockageEnKWh]: + capacitéDuDispositifDeStockageSchema, + [candidatureCsvHeadersMapping.natureDeLExploitation]: natureDeLExploitationCsvSchema, + [candidatureCsvHeadersMapping.tauxPrévisionnelACI]: optionalPercentageSchema, // columns with refines, see refines below - [colonnes.motifÉlimination]: motifEliminationSchema, // see refine below - [colonnes.typeGarantiesFinancières]: typeGarantiesFinancieresCsvSchema, // see refine below - [colonnes.dateÉchéanceGf]: dateEchéanceGfCsvSchema, // see refine below - [colonnes.territoireProjet]: territoireProjetSchema, // see refines below + [candidatureCsvHeadersMapping.motifÉlimination]: motifEliminationSchema, // see refine below + [candidatureCsvHeadersMapping.typeGarantiesFinancières]: typeGarantiesFinancieresCsvSchema, // see refine below + [candidatureCsvHeadersMapping.dateÉchéanceGf]: dateEchéanceGfCsvSchema, // see refine below + [candidatureCsvHeadersMapping.territoireProjet]: territoireProjetSchema, // see refines below }) // le motif d'élimination est obligatoire si la candidature est éliminée .superRefine((obj, ctx) => { - const actualStatut = obj[colonnes.statut]; - if (actualStatut === 'éliminé' && !obj[colonnes.motifÉlimination]) { + const actualStatut = obj[candidatureCsvHeadersMapping.statut]; + if (actualStatut === 'éliminé' && !obj[candidatureCsvHeadersMapping.motifÉlimination]) { ctx.addIssue( - conditionalRequiredError(colonnes.motifÉlimination, colonnes.statut, actualStatut), + conditionalRequiredError( + candidatureCsvHeadersMapping.motifÉlimination, + candidatureCsvHeadersMapping.statut, + actualStatut, + ), ); } }) // le type de GF est obligatoire si la candidature est classée .superRefine((obj, ctx) => { - const actualStatut = obj[colonnes.statut]; - const ao = obj[colonnes.appelOffre]; + const actualStatut = obj[candidatureCsvHeadersMapping.statut]; + const ao = obj[candidatureCsvHeadersMapping.appelOffre]; const isPPE2 = ao.startsWith('PPE2'); - if (isPPE2 && actualStatut === 'classé' && !obj[colonnes.typeGarantiesFinancières]) { + if ( + isPPE2 && + actualStatut === 'classé' && + !obj[candidatureCsvHeadersMapping.typeGarantiesFinancières] + ) { ctx.addIssue( - conditionalRequiredError(colonnes.typeGarantiesFinancières, colonnes.statut, actualStatut), + conditionalRequiredError( + candidatureCsvHeadersMapping.typeGarantiesFinancières, + candidatureCsvHeadersMapping.statut, + actualStatut, + ), ); } }) // la date d'échéance est obligatoire si les GF sont de type "avec date d'échéance" .superRefine((obj, ctx) => { - const actualStatut = obj[colonnes.statut]; + const actualStatut = obj[candidatureCsvHeadersMapping.statut]; if (actualStatut === 'éliminé') return; - const actualTypeGf = obj[colonnes.typeGarantiesFinancières] - ? typeGf[Number(obj[colonnes.typeGarantiesFinancières]) - 1] + const actualTypeGf = obj[candidatureCsvHeadersMapping.typeGarantiesFinancières] + ? typeGf[Number(obj[candidatureCsvHeadersMapping.typeGarantiesFinancières]) - 1] : undefined; - if (actualTypeGf === 'avec-date-échéance' && !obj[colonnes.dateÉchéanceGf]) { + if ( + actualTypeGf === 'avec-date-échéance' && + !obj[candidatureCsvHeadersMapping.dateÉchéanceGf] + ) { ctx.addIssue( - conditionalRequiredError(colonnes.dateÉchéanceGf, colonnes.typeGarantiesFinancières, '2'), + conditionalRequiredError( + candidatureCsvHeadersMapping.dateÉchéanceGf, + candidatureCsvHeadersMapping.typeGarantiesFinancières, + '2', + ), ); } }) // les CRE4 - ZNI nécessitent un territoire projet .superRefine((obj, ctx) => { const isZNI = - obj[colonnes.appelOffre] === 'CRE4 - ZNI' || obj[colonnes.appelOffre] === 'CRE4 - ZNI 2017'; - if (isZNI && !obj[colonnes.territoireProjet]) { + obj[candidatureCsvHeadersMapping.appelOffre] === 'CRE4 - ZNI' || + obj[candidatureCsvHeadersMapping.appelOffre] === 'CRE4 - ZNI 2017'; + if (isZNI && !obj[candidatureCsvHeadersMapping.territoireProjet]) { ctx.addIssue( - conditionalRequiredError(colonnes.territoireProjet, colonnes.appelOffre, 'CRE4 - ZNI'), + conditionalRequiredError( + candidatureCsvHeadersMapping.territoireProjet, + candidatureCsvHeadersMapping.appelOffre, + 'CRE4 - ZNI', + ), ); } }) // on ne peut pas avoir financement collectif et gouvernance partagée - .refine((val) => !(val[colonnes.financementCollectif] && val[colonnes.gouvernancePartagée]), { - message: `Seule l'une des deux colonnes "${colonnes.financementCollectif}" et "${colonnes.gouvernancePartagée}" peut avoir la valeur "Oui"`, - path: [colonnes.financementCollectif, colonnes.gouvernancePartagée], - }) + .refine( + (val) => + !( + val[candidatureCsvHeadersMapping.financementCollectif] && + val[candidatureCsvHeadersMapping.gouvernancePartagée] + ), + { + message: `Seule l'une des deux colonnes "${candidatureCsvHeadersMapping.financementCollectif}" et "${candidatureCsvHeadersMapping.gouvernancePartagée}" peut avoir la valeur "Oui"`, + path: [ + candidatureCsvHeadersMapping.financementCollectif, + candidatureCsvHeadersMapping.gouvernancePartagée, + ], + }, + ) // on doit avoir au minimum adresse1 ou adresse2 - .refine((val) => !!val[colonnes.adresse1] || !!val[colonnes.adresse2], { - message: `L'une des deux colonnes "${colonnes.adresse1}" et "${colonnes.adresse2}" doit être renseignée`, - path: [colonnes.adresse1, colonnes.adresse2], - }) + .refine( + (val) => + !!val[candidatureCsvHeadersMapping.adresse1] || !!val[candidatureCsvHeadersMapping.adresse2], + { + message: `L'une des deux colonnes "${candidatureCsvHeadersMapping.adresse1}" et "${candidatureCsvHeadersMapping.adresse2}" doit être renseignée`, + path: [candidatureCsvHeadersMapping.adresse1, candidatureCsvHeadersMapping.adresse2], + }, + ) // si l'installation est couplée à un dispositif de stockage, on doit en avoir la capacité et la puissance .refine( (val) => - (val[colonnes.installationAvecDispositifDeStockage] && - val[colonnes.capacitéDuDispositifDeStockageEnKWh] !== undefined && - val[colonnes.puissanceDuDispositifDeStockageEnKW] !== undefined) || - !val[colonnes.installationAvecDispositifDeStockage], + (val[candidatureCsvHeadersMapping.installationAvecDispositifDeStockage] && + val[candidatureCsvHeadersMapping.capacitéDuDispositifDeStockageEnKWh] !== undefined && + val[candidatureCsvHeadersMapping.puissanceDuDispositifDeStockageEnKW] !== undefined) || + !val[candidatureCsvHeadersMapping.installationAvecDispositifDeStockage], { message: 'La capacité et la puissance du dispositif de stockage sont requis', path: [ - colonnes.capacitéDuDispositifDeStockageEnKWh, - colonnes.puissanceDuDispositifDeStockageEnKW, + candidatureCsvHeadersMapping.capacitéDuDispositifDeStockageEnKWh, + candidatureCsvHeadersMapping.puissanceDuDispositifDeStockageEnKW, ], }, ); @@ -245,10 +290,12 @@ export const candidatureCsvSchema = candidatureCsvRowSchema // Transforme les noms des clés de la ligne en valeurs plus simples à manipuler .transform((val) => { type CandidatureShape = { - [P in keyof typeof colonnes]: (typeof val)[(typeof colonnes)[P]]; + [P in keyof typeof candidatureCsvHeadersMapping]: (typeof val)[(typeof candidatureCsvHeadersMapping)[P]]; }; - return (Object.keys(colonnes) as (keyof typeof colonnes)[]).reduce( - (prev, curr) => ({ ...prev, [curr]: val[colonnes[curr]] }), + return ( + Object.keys(candidatureCsvHeadersMapping) as (keyof typeof candidatureCsvHeadersMapping)[] + ).reduce( + (prev, curr) => ({ ...prev, [curr]: val[candidatureCsvHeadersMapping[curr]] }), {} as unknown as CandidatureShape, ); }) diff --git a/packages/applications/ssr/src/utils/formAction.ts b/packages/applications/ssr/src/utils/formAction.ts index be2f015b33a..33e8047ab1b 100644 --- a/packages/applications/ssr/src/utils/formAction.ts +++ b/packages/applications/ssr/src/utils/formAction.ts @@ -7,7 +7,12 @@ import { isRedirectError } from 'next/dist/client/components/redirect'; import { isNotFoundError } from 'next/dist/client/components/not-found'; import { DomainError } from '@potentiel-domain/core'; -import { CsvError, CsvValidationError } from '@potentiel-libraries/csv'; +import { + CsvLineError, + CsvColumnError, + CsvLineValidationError, + CsvColumnValidationError, +} from '@potentiel-libraries/csv'; import { getLogger } from '@potentiel-libraries/monitoring'; import { unflatten } from '@potentiel-libraries/flat'; @@ -30,35 +35,53 @@ export type ActionResult = { export type ValidationErrors = Partial>; +type FormStateSuccess = { + status: 'success' | undefined; + result?: ActionResult; + redirection?: { + url: string; + message?: string; + linkUrl?: { url: string; label: string }; + }; +}; + +type FormStateValidationError = { + status: 'validation-error'; + errors: ValidationErrors; +}; + +type FormStateRateLimitError = { + status: 'rate-limit-error'; + message: string; +}; + +type FormStateDomainError = { + status: 'domain-error'; + message: string; +}; + +export type FormStateCsvLineError = { + status: 'csv-line-error'; + errors: Array; +}; + +export type FormStateCsvColumnError = { + status: 'csv-column-error'; + errors: Array; +}; + +type FormStateUnknownError = { + status: 'unknown-error'; +}; + export type FormState = - | { - status: 'success' | undefined; - result?: ActionResult; - redirection?: { - url: string; - message?: string; - linkUrl?: { url: string; label: string }; - }; - } - | { - status: 'validation-error'; - errors: ValidationErrors; - } - | { - status: 'rate-limit-error'; - message: string; - } - | { - status: 'domain-error'; - message: string; - } - | { - status: 'csv-error'; - errors: Array; - } - | { - status: 'unknown-error'; - }; + | FormStateSuccess + | FormStateValidationError + | FormStateRateLimitError + | FormStateDomainError + | FormStateCsvLineError + | FormStateCsvColumnError + | FormStateUnknownError; export type FormAction = ( previousState: TState, @@ -118,9 +141,15 @@ export const formAction = if (isRedirectError(e) || isNotFoundError(e)) { throw e; } - if (e instanceof CsvValidationError) { + if (e instanceof CsvLineValidationError) { + return { + status: 'csv-line-error' as const, + errors: e.errors, + }; + } + if (e instanceof CsvColumnValidationError) { return { - status: 'csv-error' as const, + status: 'csv-column-error' as const, errors: e.errors, }; } diff --git a/packages/libraries/csv/src/index.ts b/packages/libraries/csv/src/index.ts index 29f6b1dd5ab..1710e0fa896 100644 --- a/packages/libraries/csv/src/index.ts +++ b/packages/libraries/csv/src/index.ts @@ -1 +1,2 @@ -export { parseCsv, CsvError, CsvValidationError, ParseOptions } from './parseCsv'; +export { parseCsv, CsvLineError, CsvLineValidationError, ParseOptions } from './parseCsv'; +export { CsvColumnError, CsvColumnValidationError } from './verifyColumns'; diff --git a/packages/libraries/csv/src/loadCsv.ts b/packages/libraries/csv/src/loadCsv.ts new file mode 100644 index 00000000000..1f1ccf119df --- /dev/null +++ b/packages/libraries/csv/src/loadCsv.ts @@ -0,0 +1,22 @@ +import iconv from 'iconv-lite'; +import { parse } from 'csv-parse'; + +import { getEncoding } from './getEncoding'; +import { defaultParseOptions, ParseOptions } from './parseCsv'; +import { streamToArrayBuffer } from './streamToArrayBuffer'; + +export const loadCsv = async (fileStream: ReadableStream, parseOptions: Partial) => { + const { encoding: encodingOption, ...options } = { ...defaultParseOptions, ...parseOptions }; + const arrayBuffer = await streamToArrayBuffer(fileStream); + const encoding = getEncoding(arrayBuffer, encodingOption); + const decoded = iconv.decode(Buffer.from(arrayBuffer), encoding); + const rows = await new Promise[]>((resolve, reject) => + parse(decoded, options, (err, records) => { + if (err) reject(err); + else { + resolve(records); + } + }), + ); + return rows; +}; diff --git a/packages/libraries/csv/src/parseCsv.test.ts b/packages/libraries/csv/src/parseCsv.test.ts index 7c107f55c80..955f4333692 100644 --- a/packages/libraries/csv/src/parseCsv.test.ts +++ b/packages/libraries/csv/src/parseCsv.test.ts @@ -42,7 +42,10 @@ const readFixture = (name: string) => { }, ]; - const { parsedData: actual } = await parseCsv(readableStream, schema); + const { parsedData: actual } = await parseCsv({ + fileStream: readableStream, + lineSchema: schema, + }); expect(actual).to.deep.eq(expected); }); @@ -55,8 +58,12 @@ test(`Étant donné un fichier au format utf8 const { parsedData: [actual], - } = await parseCsv(readableStream, schema, { - encoding: 'win1252', + } = await parseCsv({ + fileStream: readableStream, + lineSchema: schema, + parseOptions: { + encoding: 'win1252', + }, }); const expected = { @@ -85,7 +92,7 @@ test(`Étant donné un fichier séparé par des virgules }, ]; - const { parsedData: actual } = await parseCsv(readableStream, schema); + const { parsedData: actual } = await parseCsv({ fileStream: readableStream, lineSchema: schema }); expect(actual).to.deep.eq(expected); }); @@ -107,7 +114,7 @@ test(`Étant donné un fichier séparé par des tabulations }, ]; - const { parsedData: actual } = await parseCsv(readableStream, schema); + const { parsedData: actual } = await parseCsv({ fileStream: readableStream, lineSchema: schema }); expect(actual).to.deep.eq(expected); }); @@ -118,7 +125,11 @@ test(`Étant donné un fichier séparé par des points-virgules const readableStream = readFixture(`utf8.csv`); try { - await parseCsv(readableStream, schema, { delimiter: ',' }); + await parseCsv({ + fileStream: readableStream, + lineSchema: schema, + parseOptions: { delimiter: ',' }, + }); expect.fail('did not throw'); } catch (e) { expect(e).to.be.instanceOf(Error); diff --git a/packages/libraries/csv/src/parseCsv.ts b/packages/libraries/csv/src/parseCsv.ts index f3dbdb1b25e..76b8c3f2741 100644 --- a/packages/libraries/csv/src/parseCsv.ts +++ b/packages/libraries/csv/src/parseCsv.ts @@ -1,23 +1,21 @@ -import iconv from 'iconv-lite'; -import { parse } from 'csv-parse'; import * as zod from 'zod'; -import { streamToArrayBuffer } from './streamToArrayBuffer'; -import { getEncoding } from './getEncoding'; +import { loadCsv } from './loadCsv'; +import { verifyColumns } from './verifyColumns'; -export type CsvError = { +export type CsvLineError = { line: string; field: string; message: string; }; -export class CsvValidationError extends Error { - constructor(public errors: Array) { +export class CsvLineValidationError extends Error { + constructor(public errors: Array) { super('Erreur lors de la validation du fichier CSV'); } } -const defaultParseOptions = { +export const defaultParseOptions = { delimiter: [',', ';', '\t'] as string[] | string, columns: true, ltrim: true, @@ -30,53 +28,41 @@ export type ParseOptions = typeof defaultParseOptions & { encoding?: 'utf8' | 'win1252'; }; -type ParseCsv = ( - fileStream: ReadableStream, - lineSchema: TSchema, - parseOptions?: Partial, -) => Promise<{ +type ParseCsv = (args: { + fileStream: ReadableStream; + lineSchema: TSchema; + parseOptions?: Partial; + columnsToVerify?: ReadonlyArray; +}) => Promise<{ parsedData: ReadonlyArray>; rawData: ReadonlyArray>; }>; -export const parseCsv: ParseCsv = async ( +export const parseCsv: ParseCsv = async ({ fileStream, lineSchema, - parseOptions: Partial = {}, -) => { - const rawData = await loadCsv(fileStream, parseOptions); - + parseOptions, + columnsToVerify, +}) => { try { + const rawData = await loadCsv(fileStream, parseOptions ?? {}); + + verifyColumns(rawData, columnsToVerify ?? []); + return { parsedData: zod.array(lineSchema).parse(rawData), rawData }; } catch (error) { if (error instanceof zod.ZodError) { - const csvErrors = error.issues.map(({ path: [ligne, key], message }) => { - return { + const csvErrors: Array = error.issues.map( + ({ path: [ligne, key], message }) => ({ line: (Number(ligne) + 1).toString(), field: key.toString(), message, - }; - }); + }), + ); - throw new CsvValidationError(csvErrors); + throw new CsvLineValidationError(csvErrors); } throw error; } }; - -const loadCsv = async (fileStream: ReadableStream, parseOptions: Partial) => { - const { encoding: encodingOption, ...options } = { ...defaultParseOptions, ...parseOptions }; - const arrayBuffer = await streamToArrayBuffer(fileStream); - const encoding = getEncoding(arrayBuffer, encodingOption); - const decoded = iconv.decode(Buffer.from(arrayBuffer), encoding); - const rows = await new Promise[]>((resolve, reject) => - parse(decoded, options, (err, records) => { - if (err) reject(err); - else { - resolve(records); - } - }), - ); - return rows; -}; diff --git a/packages/libraries/csv/src/verifyColumns.ts b/packages/libraries/csv/src/verifyColumns.ts new file mode 100644 index 00000000000..6ae93df185d --- /dev/null +++ b/packages/libraries/csv/src/verifyColumns.ts @@ -0,0 +1,26 @@ +export type CsvColumnError = { + column: string; +}; + +export class CsvColumnValidationError extends Error { + constructor(public errors: Array) { + super('Des colonnes sont manquantes dans le fichier CSV'); + } +} + +export const verifyColumns = ( + rawData: ReadonlyArray>, + columnsToVerify: ReadonlyArray, +) => { + if (columnsToVerify.length === 0 || rawData.length === 0) { + return; + } + + const missingColumns = columnsToVerify.filter((col) => !Object.keys(rawData[0]).includes(col)); + if (missingColumns.length > 0) { + const errors: CsvColumnError[] = missingColumns.map((column) => ({ + column, + })); + throw new CsvColumnValidationError(errors); + } +};