From 726f34162d7ef1b4e05a4ae8915e9420b7ce4318 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Tue, 24 Sep 2024 09:18:44 +0200 Subject: [PATCH 001/114] mod(updateAppendix2) : use quantityAccepted for comparison with quantityGrouped --- .../__tests__/markAsProcessed.integration.ts | 88 ++++++++++++++++++- back/src/forms/updateAppendix2.ts | 10 ++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/back/src/forms/resolvers/mutations/__tests__/markAsProcessed.integration.ts b/back/src/forms/resolvers/mutations/__tests__/markAsProcessed.integration.ts index 54b3653399..97b184d819 100644 --- a/back/src/forms/resolvers/mutations/__tests__/markAsProcessed.integration.ts +++ b/back/src/forms/resolvers/mutations/__tests__/markAsProcessed.integration.ts @@ -1259,7 +1259,9 @@ describe("mutation.markAsProcessed", () => { status: "GROUPED", quantityReceived: 0.02 }, - forwardedInOpts: { quantityReceived: 0.007 } + forwardedInOpts: { + quantityReceived: 0.007 + } }); const form = await formFactory({ @@ -1317,6 +1319,90 @@ describe("mutation.markAsProcessed", () => { expect(updatedGroupedForm2.status).toEqual("PROCESSED"); }); + it("should mark partially accepted appendix2 forms as processed", async () => { + const { user, company } = await userWithCompanyFactory("ADMIN"); + + const groupedForm1 = await formFactory({ + ownerId: user.id, + opt: { + status: "GROUPED", + // quantité acceptée = 0.52 + quantityReceived: 1, + quantityRefused: 0.48, + wasteAcceptationStatus: "PARTIALLY_REFUSED" + } + }); + + // it should also work for BSD with temporary storage + const groupedForm2 = await formWithTempStorageFactory({ + ownerId: user.id, + opt: { + status: "GROUPED", + quantityReceived: 1 + }, + forwardedInOpts: { + // quantité acceptée = 0.52 + quantityReceived: 1, + quantityRefused: 0.48, + wasteAcceptationStatus: "PARTIALLY_REFUSED" + } + }); + + const form = await formFactory({ + ownerId: user.id, + opt: { + status: "ACCEPTED", + emitterType: "APPENDIX2", + recipientCompanyName: company.name, + recipientCompanySiret: company.siret, + grouping: { + create: [ + { + initialFormId: groupedForm1.id, + quantity: 0.52 + }, + { + initialFormId: groupedForm2.id, + quantity: 0.52 + } + ] + } + } + }); + + const { mutate } = makeClient(user); + + const mutateFn = () => + mutate(MARK_AS_PROCESSED, { + variables: { + id: form.id, + processedInfo: { + processingOperationDescription: "Une description", + processingOperationDone: "D 1", + destinationOperationMode: OperationMode.ELIMINATION, + processedBy: "A simple bot", + processedAt: "2018-12-11T00:00:00.000Z" + } + } + }); + + await waitForJobsCompletion({ + fn: mutateFn, + queue: updateAppendix2Queue, + expectedJobCount: 2 + }); + + const updatedGroupedForm1 = await prisma.form.findUniqueOrThrow({ + where: { id: groupedForm1.id } + }); + expect(updatedGroupedForm1.status).toEqual("PROCESSED"); + + const updatedGroupedForm2 = await prisma.form.findUniqueOrThrow({ + where: { id: groupedForm2.id } + }); + expect(updatedGroupedForm2.status).toEqual("PROCESSED"); + }); + it("should mark appendix2 forms as processed recursively", async () => { const { user, company } = await userWithCompanyFactory("ADMIN"); diff --git a/back/src/forms/updateAppendix2.ts b/back/src/forms/updateAppendix2.ts index 6d8362b01b..1e9575b822 100644 --- a/back/src/forms/updateAppendix2.ts +++ b/back/src/forms/updateAppendix2.ts @@ -3,6 +3,7 @@ import { EmitterType, Status } from "@prisma/client"; import Decimal from "decimal.js"; import { enqueueUpdateAppendix2Job } from "../queue/producers/updateAppendix2"; import { getFormRepository } from "./repository"; +import { bsddWasteQuantities } from "./helpers/bsddWasteQuantities"; const DECIMAL_WEIGHT_PRECISION = 6; // gramme @@ -55,10 +56,15 @@ export async function updateAppendix2Fn(args: UpdateAppendix2FnArgs) { .filter(grp => grp.initialFormId === form.id) .map(g => g.nextForm); + const wasteQuantities = bsddWasteQuantities(form.forwardedIn ?? form); + const quantityReceived = form.forwardedIn ? form.forwardedIn.quantityReceived : form.quantityReceived; + const quantityAccepted = + wasteQuantities?.quantityAccepted ?? quantityReceived; + const quantityGrouped = new Decimal( groupements .filter(grp => grp.initialFormId === form.id) @@ -68,9 +74,9 @@ export async function updateAppendix2Fn(args: UpdateAppendix2FnArgs) { // on a quelques quantityReceived avec des décimales au delà du gramme const groupedInTotality = - quantityReceived && + quantityAccepted && quantityGrouped.greaterThanOrEqualTo( - new Decimal(quantityReceived).toDecimalPlaces(DECIMAL_WEIGHT_PRECISION) // limit precision to circumvent rogue decimal digits + quantityAccepted.toDecimalPlaces(DECIMAL_WEIGHT_PRECISION) // limit precision to circumvent rogue decimal digits ); // case > should not happen const allSealed = From cc7e3a894e1789cf483d8c9e58890e517ecd5de4 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Tue, 24 Sep 2024 15:40:27 +0200 Subject: [PATCH 002/114] perf: update appendix 2 only if status or quantityGrouped has changed --- back/src/forms/updateAppendix2.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/back/src/forms/updateAppendix2.ts b/back/src/forms/updateAppendix2.ts index 1e9575b822..12d2e9ba34 100644 --- a/back/src/forms/updateAppendix2.ts +++ b/back/src/forms/updateAppendix2.ts @@ -109,19 +109,23 @@ export async function updateAppendix2Fn(args: UpdateAppendix2FnArgs) { const { update: updateForm, findGroupedFormsById } = getFormRepository(user); - await updateForm( - { id: form.id }, - { quantityGrouped: quantityGrouped.toNumber(), status: nextStatus } - ); - - if (form.emitterType === EmitterType.APPENDIX2) { - const groupedForms = await findGroupedFormsById(form.id); - for (const formId of groupedForms.map(f => f.id)) { - await enqueueUpdateAppendix2Job({ - formId, - userId: user.id, - auth: user.auth - }); + if ( + nextStatus !== form.status || + !quantityGrouped.equals(new Decimal(form.quantityGrouped)) + ) { + await updateForm( + { id: form.id }, + { quantityGrouped: quantityGrouped.toNumber(), status: nextStatus } + ); + if (form.emitterType === EmitterType.APPENDIX2) { + const groupedForms = await findGroupedFormsById(form.id); + for (const formId of groupedForms.map(f => f.id)) { + await enqueueUpdateAppendix2Job({ + formId, + userId: user.id, + auth: user.auth + }); + } } } } From 04b33afc4486405ef5fc93770a912c4058b484be Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Tue, 24 Sep 2024 15:57:57 +0200 Subject: [PATCH 003/114] test(markAsSealed) : partially accepted appendix2 forms should be mark as grouped --- .../__tests__/markAsSealed.integration.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/back/src/forms/resolvers/mutations/__tests__/markAsSealed.integration.ts b/back/src/forms/resolvers/mutations/__tests__/markAsSealed.integration.ts index 03a0b9e518..fcf88b08a2 100644 --- a/back/src/forms/resolvers/mutations/__tests__/markAsSealed.integration.ts +++ b/back/src/forms/resolvers/mutations/__tests__/markAsSealed.integration.ts @@ -735,6 +735,80 @@ describe("Mutation.markAsSealed", () => { expect(updatedGroupedForm2.status).toEqual("GROUPED"); }); + it("should mark partially accepted appendix2 forms as grouped", async () => { + const { user, company } = await userWithCompanyFactory("MEMBER"); + const destination = await destinationFactory(); + const groupedForm1 = await formFactory({ + ownerId: user.id, + opt: { + status: "AWAITING_GROUP", + // quantité acceptée = 0.52 + quantityReceived: 1, + quantityRefused: 0.48, + wasteAcceptationStatus: "PARTIALLY_REFUSED" + } + }); + + // it should also work for BSD with temporary storage + const groupedForm2 = await formWithTempStorageFactory({ + ownerId: user.id, + opt: { + status: "GROUPED", + quantityReceived: 0.02 + }, + forwardedInOpts: { + // quantité acceptée = 0.52 + quantityReceived: 1, + quantityRefused: 0.48, + wasteAcceptationStatus: "PARTIALLY_REFUSED" + } + }); + + const form = await formFactory({ + ownerId: user.id, + opt: { + status: "DRAFT", + emitterType: "APPENDIX2", + emitterCompanySiret: company.siret, + recipientCompanySiret: destination.siret, + grouping: { + create: [ + { + initialFormId: groupedForm1.id, + quantity: 0.52 + }, + { + initialFormId: groupedForm2.id, + quantity: 0.52 + } + ] + } + } + }); + + const { mutate } = makeClient(user); + + const mutateFn = () => + mutate(MARK_AS_SEALED, { + variables: { id: form.id } + }); + + await waitForJobsCompletion({ + fn: mutateFn, + queue: updateAppendix2Queue, + expectedJobCount: 2 + }); + + const updatedGroupedForm1 = await prisma.form.findUniqueOrThrow({ + where: { id: groupedForm1.id } + }); + expect(updatedGroupedForm1.status).toEqual("GROUPED"); + const updatedGroupedForm2 = await prisma.form.findUniqueOrThrow({ + where: { id: groupedForm2.id } + }); + expect(updatedGroupedForm2.status).toEqual("GROUPED"); + }); + it("should mark appendix2 forms as grouped despite rogue decimal digits", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const destination = await destinationFactory(); From bd7fe4064375f52c22b022b9eb3aa50561354aec Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Tue, 24 Sep 2024 15:59:49 +0200 Subject: [PATCH 004/114] perf(updateAppendix2) : recursivity should only apply when nextStatus is PROCESSED --- back/src/forms/updateAppendix2.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/back/src/forms/updateAppendix2.ts b/back/src/forms/updateAppendix2.ts index 12d2e9ba34..17862fcaa5 100644 --- a/back/src/forms/updateAppendix2.ts +++ b/back/src/forms/updateAppendix2.ts @@ -117,7 +117,10 @@ export async function updateAppendix2Fn(args: UpdateAppendix2FnArgs) { { id: form.id }, { quantityGrouped: quantityGrouped.toNumber(), status: nextStatus } ); - if (form.emitterType === EmitterType.APPENDIX2) { + if ( + form.emitterType === EmitterType.APPENDIX2 && + nextStatus === "PROCESSED" + ) { const groupedForms = await findGroupedFormsById(form.id); for (const formId of groupedForms.map(f => f.id)) { await enqueueUpdateAppendix2Job({ From 4db1b56301a5aba68dc1a246ae972fc6acb4dedc Mon Sep 17 00:00:00 2001 From: silto Date: Wed, 25 Sep 2024 17:25:44 +0200 Subject: [PATCH 005/114] [TRA 14703] - Ajout de chemins d'erreurs sur les erreurs hors rules (#3600) * feat(BSVHU/BSPAOH): add paths in refinement errors to display error tabs on frontend * fix(BSVHU/BSPAOH): return full paths on common validation --- .../bspaoh/validation/dynamicRefinements.ts | 16 ++++++++ back/src/bsvhu/validation/refinements.ts | 4 ++ back/src/common/validation/zod/refinement.ts | 12 +++++- back/src/common/validation/zod/schema.ts | 40 +++++++++++++++++-- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/back/src/bspaoh/validation/dynamicRefinements.ts b/back/src/bspaoh/validation/dynamicRefinements.ts index 3dbb64747c..a00c00cf59 100644 --- a/back/src/bspaoh/validation/dynamicRefinements.ts +++ b/back/src/bspaoh/validation/dynamicRefinements.ts @@ -10,6 +10,10 @@ import { isTransporterRefinement, refineSiretAndGetCompany } from "../../common/validation/zod/refinement"; +import { + CompanyRole, + pathFromCompanyRole +} from "../../common/validation/zod/schema"; const { VERIFY_COMPANY } = process.env; @@ -72,6 +76,7 @@ function validatePackagingsReception( if (!!receptionPackagingsIds.length && duplicateReceptionIds) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "acceptation", "packagings"], message: `Les informations d'acceptation de packagings comportent des identifiants en doublon` }); } @@ -83,6 +88,7 @@ function validatePackagingsReception( if (!!receptionPackagingsIds.length && !allReceptionPackagingsIdsExists) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "acceptation", "packagings"], message: `Les informations d'acceptation de packagings ne correspondent pas aux packagings` }); } @@ -97,6 +103,7 @@ function validatePackagingsReception( if (receptionPackagingsIds.length > packagingsIds.length) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "acceptation", "packagings"], message: `Les informations d'acceptation de packagings ne correspondent pas aux packagings` }); } @@ -110,6 +117,7 @@ function validatePackagingsReception( if (!allIdsPresents || remainingPending) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "acceptation", "status"], message: `Le statut d'acceptation de tous les packagings doit être précisé` }); return; @@ -125,6 +133,7 @@ function validatePackagingsReception( if (destinationReceptionAcceptationStatus === "ACCEPTED" && !allAccepted) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "acceptation", "status"], message: `Le bordereau ne peut être accepté que si tous les packagings sont acceptés` }); return; @@ -132,6 +141,7 @@ function validatePackagingsReception( if (destinationReceptionAcceptationStatus === "REFUSED" && !allRefused) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "acceptation", "status"], message: `Le bordereau ne peut être refusé si tous les packagings ne sont pas refusés` }); return; @@ -142,6 +152,7 @@ function validatePackagingsReception( ) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "acceptation", "status"], message: `Le bordereau ne peut être partiellement refusé si tous les packagings sont refusés ou acceptés` }); } @@ -159,6 +170,7 @@ function validateWeightFields( ) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "detail", "receivedWeight"], message: `Le poids reçu est requis pour renseigner le poid refusé` }); } @@ -169,6 +181,7 @@ function validateWeightFields( ) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "detail", "refusedWeight"], message: `Le poids refusé ne eut être supérieur au poids accepté` }); } @@ -180,6 +193,7 @@ function validateWeightFields( ) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "detail", "refusedWeight"], message: `Le poids refusé ne peut être renseigné si le PAOH est accepté` }); } @@ -189,6 +203,7 @@ export async function isCrematoriumRefinement(siret: string, ctx) { if (company && !hasCremationProfile(company)) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Destination), message: `L'entreprise avec le SIRET "${siret}" n'est pas inscrite` + ` sur Trackdéchets en tant que crématorium. Cette installation ne peut` + @@ -203,6 +218,7 @@ export async function isCrematoriumRefinement(siret: string, ctx) { ) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Destination), message: `Le compte de l'installation du crématorium` + ` avec le SIRET ${siret} n'a pas encore été vérifié. Cette installation ne peut pas être visée sur le bordereau.` diff --git a/back/src/bsvhu/validation/refinements.ts b/back/src/bsvhu/validation/refinements.ts index 25fa4d2cd8..acd1373602 100644 --- a/back/src/bsvhu/validation/refinements.ts +++ b/back/src/bsvhu/validation/refinements.ts @@ -69,6 +69,7 @@ export const checkWeights: Refinement = ( // On pourra à terme passer de .nonnegative à .positive directement dans le schéma zod.} addIssue({ code: z.ZodIssueCode.custom, + path: ["weight", "value"], message: "Le poids doit être supérieur à 0" }); } @@ -97,6 +98,7 @@ export const checkReceptionWeight: Refinement = ( ) { addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "weight"], message: "destinationReceptionWeight : le poids doit être égal à 0 lorsque le déchet est refusé" }); @@ -110,6 +112,7 @@ export const checkReceptionWeight: Refinement = ( ) { addIssue({ code: z.ZodIssueCode.custom, + path: ["destination", "reception", "weight"], message: "destinationReceptionWeight : le poids doit être supérieur à 0 lorsque le déchet est accepté ou accepté partiellement" }); @@ -125,6 +128,7 @@ export const checkEmitterSituation: Refinement = ( // Le seul cas où l'émetteur peut ne pas avoir de SIRET est si il est en situation irrégulière addIssue({ code: z.ZodIssueCode.custom, + path: ["emitter", "irregularSituation"], message: "emitterIrregularSituation : L'émetteur doit obligatoirement avoir un numéro de SIRET si il n'est pas en situation irrégulière" }); diff --git a/back/src/common/validation/zod/refinement.ts b/back/src/common/validation/zod/refinement.ts index 0fc8e9ca14..f34986fd7f 100644 --- a/back/src/common/validation/zod/refinement.ts +++ b/back/src/common/validation/zod/refinement.ts @@ -9,7 +9,7 @@ import { import { prisma } from "@td/prisma"; import { Company, CompanyVerificationStatus } from "@prisma/client"; import { getOperationModesFromOperationCode } from "../../operationModes"; -import { CompanyRole } from "./schema"; +import { CompanyRole, pathFromCompanyRole } from "./schema"; const { VERIFY_COMPANY } = process.env; @@ -34,6 +34,7 @@ export async function isTransporterRefinement( if (company && !isTransporter(company)) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Transporter), message: `Le transporteur saisi sur le bordereau (SIRET: ${siret}) n'est pas inscrit sur Trackdéchets` + ` en tant qu'entreprise de transport. Cette entreprise ne peut donc pas être visée sur le bordereau.` + @@ -55,6 +56,7 @@ export async function refineSiretAndGetCompany( if (company === null) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(companyRole), message: `${ companyRole ? `${companyRole} : ` : "" }L'établissement avec le SIRET ${siret} n'est pas inscrit sur Trackdéchets` @@ -64,6 +66,7 @@ export async function refineSiretAndGetCompany( if (company?.isDormantSince) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(companyRole), message: `L'établissement avec le SIRET ${siret} est en sommeil sur Trackdéchets, il n'est pas possible de le mentionner sur un bordereau` }); } @@ -81,12 +84,14 @@ export const isRegisteredVatNumberRefinement = async ( if (company === null) { return ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Transporter), message: `Le transporteur avec le n°de TVA ${vatNumber} n'est pas inscrit sur Trackdéchets` }); } if (!isTransporter(company)) { return ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Transporter), message: `Le transporteur saisi sur le bordereau (numéro de TVA: ${vatNumber}) n'est pas inscrit sur Trackdéchets` + ` en tant qu'entreprise de transport. Cette entreprise ne peut donc pas être visée sur le bordereau.` + @@ -110,6 +115,7 @@ export async function isDestinationRefinement( if (company && role === "WASTE_VEHICLES" && !isWasteVehicles(company)) { return ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Destination), message: `L'installation de destination avec le SIRET "${siret}" n'est pas inscrite` + ` sur Trackdéchets en tant qu'installation de traitement de VHU. Cette installation ne peut` + @@ -125,6 +131,7 @@ export async function isDestinationRefinement( ) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Destination), message: `L'installation de destination ou d’entreposage ou de reconditionnement avec le SIRET "${siret}" n'est pas inscrite` + ` sur Trackdéchets en tant qu'installation de traitement ou de tri transit regroupement. Cette installation ne peut` + @@ -144,6 +151,7 @@ export async function isDestinationRefinement( ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Destination), message: `Le compte de l'installation de destination ou d’entreposage ou de reconditionnement prévue` + ` avec le SIRET ${siret} n'a pas encore été vérifié. Cette installation ne peut pas être visée sur le bordereau.` @@ -179,6 +187,7 @@ export function destinationOperationModeRefinement( if (modes.length && !destinationOperationMode) { return ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Destination), message: "Vous devez préciser un mode de traitement" }); } else if ( @@ -189,6 +198,7 @@ export function destinationOperationModeRefinement( ) { return ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Destination), message: "Le mode de traitement n'est pas compatible avec l'opération de traitement choisie" }); diff --git a/back/src/common/validation/zod/schema.ts b/back/src/common/validation/zod/schema.ts index 20d9413e70..b853a23e27 100644 --- a/back/src/common/validation/zod/schema.ts +++ b/back/src/common/validation/zod/schema.ts @@ -13,6 +13,29 @@ export enum CompanyRole { NextDestination = "Éxutoire" } +export const pathFromCompanyRole = (companyRole?: CompanyRole): string[] => { + switch (companyRole) { + case CompanyRole.Emitter: + return ["emitter", "company", "siret"]; + case CompanyRole.Transporter: + return ["transporter", "company", "siret"]; + case CompanyRole.Destination: + return ["destination", "company", "siret"]; + case CompanyRole.EcoOrganisme: + return ["ecoOrganisme", "company", "siret"]; + case CompanyRole.Broker: + return ["broker", "company", "siret"]; + case CompanyRole.Worker: + return ["worker", "company", "siret"]; + case CompanyRole.Intermediary: + return ["intermediaries", "company", "siret"]; + case CompanyRole.NextDestination: + return ["nextDestination", "company", "siret"]; + default: + return []; + } +}; + export const siretSchema = (expectedCompanyRole?: CompanyRole) => z .string({ @@ -28,6 +51,7 @@ export const siretSchema = (expectedCompanyRole?: CompanyRole) => return isSiret(value); }, val => ({ + path: pathFromCompanyRole(expectedCompanyRole), message: `${ expectedCompanyRole ? `${expectedCompanyRole} : ` : "" }${val} n'est pas un numéro de SIRET valide` @@ -43,10 +67,18 @@ export const vatNumberSchema = z.string().refine( val => ({ message: `${val} n'est pas un numéro de TVA valide` }) ); export const foreignVatNumberSchema = (expectedCompanyRole?: CompanyRole) => - vatNumberSchema.refine(value => { - if (!value) return true; - return isForeignVat(value); - }, `${expectedCompanyRole ? `${expectedCompanyRole} : ` : ""}Impossible d'utiliser le numéro de TVA pour un établissement français, veuillez renseigner son SIRET uniquement`); + vatNumberSchema.refine( + value => { + if (!value) return true; + return isForeignVat(value); + }, + { + path: pathFromCompanyRole(expectedCompanyRole), + message: `${ + expectedCompanyRole ? `${expectedCompanyRole} : ` : "" + }Impossible d'utiliser le numéro de TVA pour un établissement français, veuillez renseigner son SIRET uniquement` + } + ); export const rawTransporterSchema = z.object({ id: z.string().nullish(), From 2887f49cece90080a488e44f38231200fe5d92b7 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Thu, 26 Sep 2024 09:03:37 +0200 Subject: [PATCH 006/114] mod(doc) : use file path instead of URL path for internal links --- apps/doc/docs/guides/playground.md | 4 ++-- apps/doc/docs/guides/registre.md | 14 +++++------ apps/doc/docs/guides/sirene.md | 8 +++---- apps/doc/docs/guides/webhooks.md | 4 ++-- apps/doc/docs/reference/authentification.md | 2 +- apps/doc/docs/reference/statuts/bsda.mdx | 2 +- apps/doc/docs/reference/statuts/bsdasri.mdx | 2 +- apps/doc/docs/reference/statuts/bsdd.mdx | 24 +++++++++---------- apps/doc/docs/reference/statuts/bsff.mdx | 2 +- apps/doc/docs/reference/statuts/bspaoh.mdx | 2 +- .../tutoriels/courant/query-bordereaux.md | 4 ++-- .../docs/tutoriels/quickstart/first-query.md | 2 +- .../docs/tutoriels/quickstart/introduction.md | 2 +- 13 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/doc/docs/guides/playground.md b/apps/doc/docs/guides/playground.md index 96ff93ee9a..da1eb6a3aa 100644 --- a/apps/doc/docs/guides/playground.md +++ b/apps/doc/docs/guides/playground.md @@ -23,7 +23,7 @@ Le playground GraphQL est composé de différentes zones : ## Renseigner son token -Le token (voir [Authentification](../reference/authentification)) doit être renseigné dans l'onglet "HTTP Headers" de la façon suivante : +Le token (voir [Authentification](../reference/authentification.md)) doit être renseigné dans l'onglet "HTTP Headers" de la façon suivante : ![playground-token](../../static/img/playground-token.png) ## Exécuter une requête GraphQL @@ -44,7 +44,7 @@ Vous pouvez également utiliser l'onglet "Variables" pour injecter les variables ## Parcourir la documentation de l'API -L'onglet de droite "Docs" vous permet de parcourir la référence de l'API. Vous y retrouverez les différentes Query et Mutation disponibles ainsi que les variables et les types de retours. La référence de l'API est également disponible dans la section [Référence API](../reference/api-reference/bsdd/queries) +L'onglet de droite "Docs" vous permet de parcourir la référence de l'API. Vous y retrouverez les différentes Query et Mutation disponibles ainsi que les variables et les types de retours. La référence de l'API est également disponible dans la section [Référence API](../reference/api-reference/bsdd/queries.md) ![playground-docs](../../static/img/playground-docs.png) diff --git a/apps/doc/docs/guides/registre.md b/apps/doc/docs/guides/registre.md index 220e553ef6..570fd9b4c5 100644 --- a/apps/doc/docs/guides/registre.md +++ b/apps/doc/docs/guides/registre.md @@ -7,13 +7,13 @@ L'[arrêté du 31 mai 2021](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT00004 ## Export JSON Les `queries` permettant d'exporter les données registre sont les suivantes : -- [`incomingWastes`](../reference/api-reference/registre/queries#incomingwastes) : registre de déchets entrants -- [`outgoingWastes`](../reference/api-reference/registre/queries#outgoingwastes) : registre de déchets sortants -- [`transporteWastes`](../reference/api-reference/registre/queries#transportedwastes): registre de déchets transportés -- [`managedWastes`](../reference/api-reference/registre/queries#managedwastes) : registre de déchets gérés (courtage ou négoce) -- [`allWastes`](../reference/api-reference/registre/queries#allwastes) : registre permettant d'exporter toutes les données de bordereaux pour un ou plusieurs établissements +- [`incomingWastes`](../reference/api-reference/registre/queries.md#incomingwastes) : registre de déchets entrants +- [`outgoingWastes`](../reference/api-reference/registre/queries.md#outgoingwastes) : registre de déchets sortants +- [`transporteWastes`](../reference/api-reference/registre/queries.md#transportedwastes): registre de déchets transportés +- [`managedWastes`](../reference/api-reference/registre/queries.md#managedwastes) : registre de déchets gérés (courtage ou négoce) +- [`allWastes`](../reference/api-reference/registre/queries.md#allwastes) : registre permettant d'exporter toutes les données de bordereaux pour un ou plusieurs établissements -Des filtres avancés peuvent être appliqués pour restreindre les données exportés par code déchet, quantité, date d'expédition, etc, et son décrits dans l'objet [RegisterWhere](../reference/api-reference/registre/inputObjects#wasteregistrywhere). +Des filtres avancés peuvent être appliqués pour restreindre les données exportés par code déchet, quantité, date d'expédition, etc, et son décrits dans l'objet [RegisterWhere](../reference/api-reference/registre/inputObjects.md#wasteregistrywhere). Exemple de requête : @@ -102,7 +102,7 @@ Les résultats sont paginés. Pour récupérer tous les déchets : Les données peuvent également être téléchargées au format `CSV` ou Excel (`XLXS`). -Pour ce faire vous devez utiliser la query [`wastesRegistryCsv`](../reference/api-reference/registre/queries#wastesregistrycsv) ou [`wastesRegistryXls`](../reference/api-reference/registre/queries#wastesregistryxls) de la façon suivante : +Pour ce faire vous devez utiliser la query [`wastesRegistryCsv`](../reference/api-reference/registre/queries.md#wastesregistrycsv) ou [`wastesRegistryXls`](../reference/api-reference/registre/queries.md#wastesregistryxls) de la façon suivante : ```graphql query { diff --git a/apps/doc/docs/guides/sirene.md b/apps/doc/docs/guides/sirene.md index 54c75262aa..3bd3db79f9 100644 --- a/apps/doc/docs/guides/sirene.md +++ b/apps/doc/docs/guides/sirene.md @@ -4,11 +4,11 @@ title: Rechercher un établissement partenaire sur l'API Trackdéchets # Par son nom, ou par n° SIRET ou par son n°TVA intracommunautaire pour les entreprises européennes. -Nous exposons une query [`searchCompanies`](../reference/api-reference/user-company/queries#searchcompanies) qui interroge la base SIRENE (via [les données ouvertes de l'INSEE](https://files.data.gouv.fr/insee-sirene/)), ou la base VIES (via [le service la commission européenne](https://ec.europa.eu/taxation_customs/vies/)) la base des installations classées pour la protection de l'environnement (ICPE) et la base Trackdéchets pour obtenir des informations sur un établissement à partir de son numéro SIRET, sa raison sociale, ou son numéro de TVA intra-communautaire. +Nous exposons une query [`searchCompanies`](../reference/api-reference/user-company/queries.md#searchcompanies) qui interroge la base SIRENE (via [les données ouvertes de l'INSEE](https://files.data.gouv.fr/insee-sirene/)), ou la base VIES (via [le service la commission européenne](https://ec.europa.eu/taxation_customs/vies/)) la base des installations classées pour la protection de l'environnement (ICPE) et la base Trackdéchets pour obtenir des informations sur un établissement à partir de son numéro SIRET, sa raison sociale, ou son numéro de TVA intra-communautaire. Elle requiert un token d'API Trackdéchets et permet d'accéder à toutes les informations d'un établissement sur Trackdéchet et à jour des bases de l'INSEE ou de VIES (via [le service la commission européenne](https://ec.europa.eu/taxation_customs/vies/)). -La query renvoie un objet de type [`CompanySearchResult`](../reference/api-reference/user-company/objects#companysearchresult) et permet notamment de savoir si un établissement est inscrit sur Trackdéchets grâce au champ `isRegistered`, mais aussi les coordonnées de l'établissement, comme le type d'établissement sur Trackdéchets. +La query renvoie un objet de type [`CompanySearchResult`](../reference/api-reference/user-company/objects.md#companysearchresult) et permet notamment de savoir si un établissement est inscrit sur Trackdéchets grâce au champ `isRegistered`, mais aussi les coordonnées de l'établissement, comme le type d'établissement sur Trackdéchets. Pour retourner les établissements étrangers quand on cherche par `clue : "NUMERODETVA"`, il faut activer à `true` le paramètre `allowForeignCompanies`. @@ -47,9 +47,9 @@ query { # Par son n° SIRET pour les entreprises françaises ou par son n°TVA intracommunautaire pour les entreprises européennes. -Nous exposons une query [`companyInfos`](../reference/api-reference/user-company/queries#companyinfos) qui interroge la base SIRENE (via [les données ouvertes de l'INSEE](https://files.data.gouv.fr/insee-sirene/)), ou la base VIES (via [le service la commission européenne](https://ec.europa.eu/taxation_customs/vies/)) la base des installations classées pour la protection de l'environnement (ICPE) et la base Trackdéchets pour obtenir des informations sur un établissement à partir de son numéro SIRET. +Nous exposons une query [`companyInfos`](../reference/api-reference/user-company/queries.md#companyinfos) qui interroge la base SIRENE (via [les données ouvertes de l'INSEE](https://files.data.gouv.fr/insee-sirene/)), ou la base VIES (via [le service la commission européenne](https://ec.europa.eu/taxation_customs/vies/)) la base des installations classées pour la protection de l'environnement (ICPE) et la base Trackdéchets pour obtenir des informations sur un établissement à partir de son numéro SIRET. -La requête renvoie un objet de type [`CompanyPublic`](../reference/api-reference/user-company/objects#companypublic) et permet notamment de savoir si un établissement est inscrit sur Trackdéchets grâce au champ `isRegistered`. Si l'établissement demandé est enregistré auprès de l'INSEE comme "non-diffusible" ou "protégé" (c'est-à-dire si `statutDiffusionEtablissement` ne renvoie pas "O"), nous ne les révèlerons pas dans cette requête `companyInfos`. Il faudra utiliser la requête authentifiée `searchCompanies` documentée sur cette page en passant le siret comme valeur de la variable `clue`. +La requête renvoie un objet de type [`CompanyPublic`](../reference/api-reference/user-company/objects.md#companypublic) et permet notamment de savoir si un établissement est inscrit sur Trackdéchets grâce au champ `isRegistered`. Si l'établissement demandé est enregistré auprès de l'INSEE comme "non-diffusible" ou "protégé" (c'est-à-dire si `statutDiffusionEtablissement` ne renvoie pas "O"), nous ne les révèlerons pas dans cette requête `companyInfos`. Il faudra utiliser la requête authentifiée `searchCompanies` documentée sur cette page en passant le siret comme valeur de la variable `clue`. Exemple d'utilisation: diff --git a/apps/doc/docs/guides/webhooks.md b/apps/doc/docs/guides/webhooks.md index 2f26e4b508..1d3c6ad205 100644 --- a/apps/doc/docs/guides/webhooks.md +++ b/apps/doc/docs/guides/webhooks.md @@ -11,7 +11,7 @@ L'utilisation des Webhooks permet aux Systèmes d'Information de réduire la né Pour recevoir des Webhooks, vous devez suivre ces étapes : -1. Configurer les Webhooks en utilisant l'API des ["WebhookSettings"](../reference/api-reference/webhooks/mutations#createwebhooksetting). +1. Configurer les Webhooks en utilisant l'API des ["WebhookSettings"](../reference/api-reference/webhooks/mutations.md#createwebhooksetting). 2. Autoriser votre SI à recevoir des requêtes HTTP de Trackdéchets. 3. Effectuer des modifications sur les bordereaux contenant les établissements correspondants aux "WebhookSettings" ou attendre que d'autres acteurs le fassent. 4. Recevez la requête de notification de mise à jour sur l'URL de votre SI configuré comme Webhook. @@ -21,7 +21,7 @@ Pour recevoir des Webhooks, vous devez suivre ces étapes : L'API des "WebhookSettings" vous permet de configurer l'envoi des Webhooks avec les principes suivants : - L'API est accessible uniquement aux utilisateurs ADMIN. -- Un seul Webhook est autorisé par établissement (identifié par [`companyId`](../reference/api-reference/webhooks/inputObjects#webhooksettingcreateinput)). +- Un seul Webhook est autorisé par établissement (identifié par [`companyId`](../reference/api-reference/webhooks/inputObjects.md#webhooksettingcreateinput)). - Une URL de notification sur votre SI est associée à chaque Webhook. - Un token d'au moins 20 caractères est requis lors de la création du "WebhookSetting" et sera transmis en tant qu'en-tête de la requête du Webhook (Authorization: Bearer: token). - Le token n'est pas visible dans les requêtes, mais vous pouvez le mettre à jour à tout moment. diff --git a/apps/doc/docs/reference/authentification.md b/apps/doc/docs/reference/authentification.md index 7b4812b82c..a0c2938c4b 100644 --- a/apps/doc/docs/reference/authentification.md +++ b/apps/doc/docs/reference/authentification.md @@ -21,4 +21,4 @@ Un token est lié à un utilisateur. Les permissions du token découle donc dire ## Authentification pour le compte de tiers -Pour les logiciels (ex : logiciel SaaS déchets) désirant se connecter à l'API Trackdéchets pour le compte d'utilisateurs tiers, nous recommandons d'utiliser le protocole OAuth2 : [Créer une application OAuth2](../guides/oauth2) \ No newline at end of file +Pour les logiciels (ex : logiciel SaaS déchets) désirant se connecter à l'API Trackdéchets pour le compte d'utilisateurs tiers, nous recommandons d'utiliser le protocole OAuth2 : [Créer une application OAuth2](../guides/oauth2.md) \ No newline at end of file diff --git a/apps/doc/docs/reference/statuts/bsda.mdx b/apps/doc/docs/reference/statuts/bsda.mdx index d30694c710..8d2aefafad 100644 --- a/apps/doc/docs/reference/statuts/bsda.mdx +++ b/apps/doc/docs/reference/statuts/bsda.mdx @@ -4,7 +4,7 @@ title: BSDA import Mermaid from '../../../src/components/Mermaid'; -Au cours de son cycle de vie, le BSDA peut passer par différents états décrits [ici](../api-reference/bsdasri/enums#bsdasristatus). +Au cours de son cycle de vie, le BSDA peut passer par différents états décrits [ici](../api-reference/bsdasri/enums.md#bsdasristatus). - `INITIAL` (initial) : C'est l'état dans lequel le Bsda est créé. `readableId` est affecté. diff --git a/apps/doc/docs/reference/statuts/bsdasri.mdx b/apps/doc/docs/reference/statuts/bsdasri.mdx index 2d6e6e315d..0e5c267f77 100644 --- a/apps/doc/docs/reference/statuts/bsdasri.mdx +++ b/apps/doc/docs/reference/statuts/bsdasri.mdx @@ -4,7 +4,7 @@ title: BSDASRI import Mermaid from '../../../src/components/Mermaid'; -Au cours de son cycle de vie, le BSDASRI peut passer par différents états décrits [ici](../api-reference/bsdasri/enums#bsdasristatus). +Au cours de son cycle de vie, le BSDASRI peut passer par différents états décrits [ici](../api-reference/bsdasri/enums.md#bsdasristatus). - `INITIAL` (initial) : C'est l'état dans lequel le dasri est créé. `readableId` est affecté. diff --git a/apps/doc/docs/reference/statuts/bsdd.mdx b/apps/doc/docs/reference/statuts/bsdd.mdx index 7e773d0ae2..299e9a7483 100644 --- a/apps/doc/docs/reference/statuts/bsdd.mdx +++ b/apps/doc/docs/reference/statuts/bsdd.mdx @@ -29,19 +29,19 @@ Chaque changement d'état s'effectue grâce à une mutation. | Mutation | Transition | Données | Permissions | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `createForm` | `-> DRAFT`
| [FormInput](../api-reference/bsdd/inputObjects#forminput) |
  • émetteur
  • destinataire
  • transporteur
  • négociant
  • éco-organisme
| -| `updateForm` |
  • `DRAFT -> DRAFT`
  • `SEALED -> SEALED`
| [FormInput](../api-reference/bsdd/inputObjects#forminput) |
  • émetteur
  • destinataire
  • transporteur
  • négociant
  • éco-organisme
| +| `createForm` | `-> DRAFT`
| [FormInput](../api-reference/bsdd/inputObjects.md#forminput) |
  • émetteur
  • destinataire
  • transporteur
  • négociant
  • éco-organisme
| +| `updateForm` |
  • `DRAFT -> DRAFT`
  • `SEALED -> SEALED`
| [FormInput](../api-reference/bsdd/inputObjects.md#forminput) |
  • émetteur
  • destinataire
  • transporteur
  • négociant
  • éco-organisme
| | `markAsSealed` | `DRAFT -> SEALED` | |
  • émetteur
  • destinataire
  • transporteur
  • négociant
  • éco-organisme
| -| `signEmissionForm` |
  • `SEALED -> SIGNED_BY_PRODUCER`
  • `RESEALED -> SIGNED_BY_TEMP_STORER`
| [SignEmissionFormInput](../api-reference/bsdd/inputObjects#signemissionforminput) |
  • émetteur / entreposage provisoire (authentifié ou via son code de signature)
  • éco-organisme (authentifié ou via son code de signature)
| -| `signTransportForm` |
  • `SIGNED_BY_PRODUCER -> SENT`
  • `SIGNED_BY_TEMP_STORER -> RESENT`
| [SignTransportFormInput](../api-reference/bsdd/inputObjects#signtransportforminput) |
  • transporteur (authentifié ou via son code de signature)
| -| `markAsReceived` |
  • `SENT -> ACCEPTED`
  • `SENT -> RECEIVED`
  • `SENT -> REFUSED`
| [ReceivedFormInput](../api-reference/bsdd/inputObjects#receivedforminput) | Uniquement le destinataire du BSD | -| `markAsAccepted` | `RECEIVED -> ACCEPTED` | [AcceptedFormInput](../api-reference/bsdd/inputObjects#acceptedforminput) | Uniquement le destinataire du BSD | -| `markAsProcessed` |
  • `RECEIVED -> PROCESSED`
  • `RECEIVED -> NO_TRACEABILITY`
  • `RECEIVED -> AWAITING_GROUP`
  • `RECEIVED -> FOLLOWED_WITH_PNTTD`
| [ProcessedFormInput](../api-reference/bsdd/inputObjects#processedforminput) | Uniquement le destinataire du BSD | -| `markAsTempStored` |
  • `SENT -> TEMP_STORER_ACCEPTED`
  • `SENT -> TEMP_STORED`
  • `SENT -> REFUSED`
| [TempStoredFormInput](../api-reference/bsdd/inputObjects#tempstoredforminput) | Uniquement le site d'entreposage temporaire ou de reconditionnement | -| `markAsTempStorerAccepted` | `TEMP_STORED -> TEMP_STORER_ACCEPTED` | [TempStorerAcceptedFormInput](../api-reference/bsdd/inputObjects#tempstoreracceptedforminput) | Uniquement le site d'entreposage temporaire ou de reconditionnement | -| `markAsResealed` |
  • `TEMP_STORED -> RESEALED`
  • `RESEALED -> RESEALED`
| [ResealedFormInput](../api-reference/bsdd/inputObjects#resealedforminput) | Uniquement le site d'entreposage temporaire ou de reconditionnement | -| `importPaperForm` | `SEALED -> PROCESSED` | [ImportPaperFormInput](../api-reference/bsdd/inputObjects#importpaperforminput) | Uniquement l'entreprise de destination | -| `createFormRevisionRequest` | `CANCELED` | [CreateFormRevisionRequestInput](../api-reference/bsdd/inputObjects#createformrevisionrequestinput) |
  • émetteur
  • destinataire
| +| `signEmissionForm` |
  • `SEALED -> SIGNED_BY_PRODUCER`
  • `RESEALED -> SIGNED_BY_TEMP_STORER`
| [SignEmissionFormInput](../api-reference/bsdd/inputObjects.md#signemissionforminput) |
  • émetteur / entreposage provisoire (authentifié ou via son code de signature)
  • éco-organisme (authentifié ou via son code de signature)
| +| `signTransportForm` |
  • `SIGNED_BY_PRODUCER -> SENT`
  • `SIGNED_BY_TEMP_STORER -> RESENT`
| [SignTransportFormInput](../api-reference/bsdd/inputObjects.md#signtransportforminput) |
  • transporteur (authentifié ou via son code de signature)
| +| `markAsReceived` |
  • `SENT -> ACCEPTED`
  • `SENT -> RECEIVED`
  • `SENT -> REFUSED`
| [ReceivedFormInput](../api-reference/bsdd/inputObjects.md#receivedforminput) | Uniquement le destinataire du BSD | +| `markAsAccepted` | `RECEIVED -> ACCEPTED` | [AcceptedFormInput](../api-reference/bsdd/inputObjects.md#acceptedforminput) | Uniquement le destinataire du BSD | +| `markAsProcessed` |
  • `RECEIVED -> PROCESSED`
  • `RECEIVED -> NO_TRACEABILITY`
  • `RECEIVED -> AWAITING_GROUP`
  • `RECEIVED -> FOLLOWED_WITH_PNTTD`
| [ProcessedFormInput](../api-reference/bsdd/inputObjects.md#processedforminput) | Uniquement le destinataire du BSD | +| `markAsTempStored` |
  • `SENT -> TEMP_STORER_ACCEPTED`
  • `SENT -> TEMP_STORED`
  • `SENT -> REFUSED`
| [TempStoredFormInput](../api-reference/bsdd/inputObjects.md#tempstoredforminput) | Uniquement le site d'entreposage temporaire ou de reconditionnement | +| `markAsTempStorerAccepted` | `TEMP_STORED -> TEMP_STORER_ACCEPTED` | [TempStorerAcceptedFormInput](../api-reference/bsdd/inputObjects.md#tempstoreracceptedforminput) | Uniquement le site d'entreposage temporaire ou de reconditionnement | +| `markAsResealed` |
  • `TEMP_STORED -> RESEALED`
  • `RESEALED -> RESEALED`
| [ResealedFormInput](../api-reference/bsdd/inputObjects.md#resealedforminput) | Uniquement le site d'entreposage temporaire ou de reconditionnement | +| `importPaperForm` | `SEALED -> PROCESSED` | [ImportPaperFormInput](../api-reference/bsdd/inputObjects.md#importpaperforminput) | Uniquement l'entreprise de destination | +| `createFormRevisionRequest` | `CANCELED` | [CreateFormRevisionRequestInput](../api-reference/bsdd/inputObjects.md#createformrevisionrequestinput) |
  • émetteur
  • destinataire
| Le diagramme ci dessous retrace le cycle de vie d'un BSD dans Trackdéchets: diff --git a/apps/doc/docs/reference/statuts/bsff.mdx b/apps/doc/docs/reference/statuts/bsff.mdx index 4426346067..33315265a2 100644 --- a/apps/doc/docs/reference/statuts/bsff.mdx +++ b/apps/doc/docs/reference/statuts/bsff.mdx @@ -4,7 +4,7 @@ title: BSFF import Mermaid from "../../../src/components/Mermaid"; -Au cours de son cycle de vie, le BSFF passe par différents statuts décrits [ici](../api-reference/bsff/enums#bsffstatus). +Au cours de son cycle de vie, le BSFF passe par différents statuts décrits [ici](../api-reference/bsff/enums.md#bsffstatus). Le diagramme ci dessous retrace le cycle de vie d'un BSFF dans Trackdéchets : diff --git a/apps/doc/docs/reference/statuts/bspaoh.mdx b/apps/doc/docs/reference/statuts/bspaoh.mdx index 4f77152036..45cd1e9abe 100644 --- a/apps/doc/docs/reference/statuts/bspaoh.mdx +++ b/apps/doc/docs/reference/statuts/bspaoh.mdx @@ -4,7 +4,7 @@ title: BSPAOH import Mermaid from '../../../src/components/Mermaid'; -Au cours de son cycle de vie, le BSPAOH peut passer par différents états décrits [ici](../api-reference/bspaoh/enums/#bspaohstatus). +Au cours de son cycle de vie, le BSPAOH peut passer par différents états décrits [ici](../api-reference/bspaoh/enums.md#bspaohstatus). - `INITIAL` (initial) : C'est l'état dans lequel le PAOH est créé. diff --git a/apps/doc/docs/tutoriels/courant/query-bordereaux.md b/apps/doc/docs/tutoriels/courant/query-bordereaux.md index 2677b69a91..e0b435d92e 100644 --- a/apps/doc/docs/tutoriels/courant/query-bordereaux.md +++ b/apps/doc/docs/tutoriels/courant/query-bordereaux.md @@ -6,7 +6,7 @@ Les bordereaux Bsda, Bsdasri, Bsff et Bsvhu, ont bénéficié des retours utiili Veuillez noter que les Bsdd (requête forms) ne disposent pas des mêmes filtres. -Pour une documentation exhaustive, veuillez consulter la référence des requêtes de chaque bordereau, par exemple [la requête bsdasri](../../reference/api-reference/bsdasri/queries#bsdasris). +Pour une documentation exhaustive, veuillez consulter la référence des requêtes de chaque bordereau, par exemple [la requête bsdasri](../../reference/api-reference/bsdasri/queries.md#bsdasris). Les exemples suivants portent sur les dasris, mais sont aisément transposables aux autres bordereaux. Ils ne prétendent pas avoir un intérêt métier particulier, mais simplement expliciter la syntaxe de requête. @@ -65,7 +65,7 @@ query { ``` #### Filtres temporels -Les opérateurs et formats de date acceptés sont documentés dans [la référence de DateFilter](../../reference/api-reference/bsdasri/inputObjects#datefilter). +Les opérateurs et formats de date acceptés sont documentés dans [la référence de DateFilter](../../reference/api-reference/bsdasri/inputObjects.md#datefilter). Renvoie les dasris dont la date de création est égale ou postérieure au 23/11/2021. diff --git a/apps/doc/docs/tutoriels/quickstart/first-query.md b/apps/doc/docs/tutoriels/quickstart/first-query.md index ff5339db7a..238d470f61 100644 --- a/apps/doc/docs/tutoriels/quickstart/first-query.md +++ b/apps/doc/docs/tutoriels/quickstart/first-query.md @@ -60,7 +60,7 @@ puis exécuter la requête à l'aide du bouton "Play" au milieu. Vous devrez rec Bravo, vous venez d'effectuer votre première requête à l'API Trackdéchets 🎉. En terminologie GraphQL, la requête ci-dessous est une `query`. Ce genre de requête se comporte comme un `GET` dans le standard REST, c'est à dire qu'elle permet de lire des données mais pas d'en modifier. Il existe aussi un autre type de requête appelée `mutation` qui va nous permettre de créer et modifier des ressources à l'instar d'un `POST` / `PUT` / `PATCH` en standard `REST`. C'est ce que nous allons voir à l'étape suivante pour la création de votre premier bordereau. :::tip -Les arguments et le type de retour de chaque `query` ou `mutation` sont documentés dans la référence de l'API. Exemple avec [la requête que nous venons d'effectuer](../../reference/api-reference/user-company/queries#me). +Les arguments et le type de retour de chaque `query` ou `mutation` sont documentés dans la référence de l'API. Exemple avec [la requête que nous venons d'effectuer](../../reference/api-reference/user-company/queries.md#me). ::: diff --git a/apps/doc/docs/tutoriels/quickstart/introduction.md b/apps/doc/docs/tutoriels/quickstart/introduction.md index 1830ee035d..f5801d75cc 100644 --- a/apps/doc/docs/tutoriels/quickstart/introduction.md +++ b/apps/doc/docs/tutoriels/quickstart/introduction.md @@ -14,7 +14,7 @@ Vous y apprendrez comment réaliser des requêtes GraphQL depuis le playground w 4. [Créer votre premier bordereau](tutoriels/quickstart/first-bsd) :::info -Une connaissance approfondie du standard GraphQL utilisée par l'API n'est pas requise mais vous pouvez au besoin vous référer à [Introduction à GraphQL](../../concepts/graphql). +Une connaissance approfondie du standard GraphQL utilisée par l'API n'est pas requise mais vous pouvez au besoin vous référer à [Introduction à GraphQL](../../concepts/graphql.md). ::: From 6ebc8c8a9cd588611101dd8b9960674c7edb1e78 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Thu, 26 Sep 2024 10:58:16 +0200 Subject: [PATCH 007/114] chore : update changelog --- Changelog.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Changelog.md b/Changelog.md index 03fc0fb303..12114dc5a5 100644 --- a/Changelog.md +++ b/Changelog.md @@ -5,6 +5,12 @@ Les changements importants de Trackdéchets sont documentés dans ce fichier. Le format est basé sur [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), et le projet suit un schéma de versionning inspiré de [Calendar Versioning](https://calver.org/). +# [2024.10.1] 22/10/2024 + +#### :bug: Corrections de bugs + +- Documentation API Developers : Page Not Found, si on n'y accède pas via l'arborescence [PR 3621](https://github.com/MTES-MCT/trackdechets/pull/3621) + # [2024.9.1] 24/09/2024 #### :rocket: Nouvelles fonctionnalités From 0f10808baeb9f691d70de53c5502b3468de121d2 Mon Sep 17 00:00:00 2001 From: silto Date: Thu, 26 Sep 2024 17:54:21 +0200 Subject: [PATCH 008/114] fix(Github action): fix 404 error on label removal (#3625) --- .github/workflows/add-ready-to-merge-label.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/add-ready-to-merge-label.yml b/.github/workflows/add-ready-to-merge-label.yml index 756c38150e..4792813020 100644 --- a/.github/workflows/add-ready-to-merge-label.yml +++ b/.github/workflows/add-ready-to-merge-label.yml @@ -40,4 +40,10 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, name: "ready to merge" + }).catch(function(error) { + if (error.status === 404) { + return; + } else { + throw error; + } }) \ No newline at end of file From 6c0b483b227d99e50650d80faa3ae35ea208e890 Mon Sep 17 00:00:00 2001 From: JulianaJM <37509748+JulianaJM@users.noreply.github.com> Date: Thu, 3 Oct 2024 15:17:02 +0200 Subject: [PATCH 009/114] =?UTF-8?q?[tra-14716]Ajouter=20l'immatriculation?= =?UTF-8?q?=20et=20le=20n=C2=B0=20de=20CAP=20sur=20le=20tableau=20de=20bor?= =?UTF-8?q?d=20g=C3=A9n=C3=A9ral,=20pour=20tous=20les=20acteurs=20(#3624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dashboard/Components/BsdCard/BsdCard.tsx | 25 ++++++++++--------- .../Components/InfoWithIcon/infoWithIcon.scss | 3 ++- .../InfoWithIcon/infoWithIconTypes.ts | 3 ++- .../InfoWithIcon/infoWithIconUtils.ts | 4 ++- .../src/Apps/common/queries/fragments/bsda.ts | 1 + .../src/Apps/common/queries/fragments/bsdd.ts | 1 + .../src/Apps/common/queries/fragments/bsff.ts | 1 + 7 files changed, 23 insertions(+), 15 deletions(-) diff --git a/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx b/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx index 66945c25b6..bb7f4e24b8 100644 --- a/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx +++ b/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx @@ -183,18 +183,18 @@ function BsdCard({ ? getPrimaryActionsReviewsLabel(bsdDisplay, currentSiret) : ""; + const isTransportTabs = isToCollectTab || isCollectedTab; + const currentTransporterInfos = useMemo(() => { - if (!isToCollectTab && !isCollectedTab) { - return null; - } return getCurrentTransporterInfos(bsd, currentSiret, isToCollectTab); - }, [bsd, currentSiret, isToCollectTab, isCollectedTab]); + }, [bsd, currentSiret, isToCollectTab]); // display the transporter's custom info if: // - we are in the "To Collect" tab // OR // - we are in the "Collected" tab and there is a custom info const displayTransporterCustomInfo = + isTransportTabs && !!currentTransporterInfos && (isToCollectTab || (isCollectedTab && @@ -202,10 +202,6 @@ function BsdCard({ // display the transporter's number plate if: // - the mode of transport is ROAD - // AND - // - we are in the "To Collect" tab - // OR - // - we are in the "Collected" tab and there is a number plate const displayTransporterNumberPlate = !!currentTransporterInfos && (currentTransporterInfos.transporterMode === TransportMode.Road || @@ -213,9 +209,7 @@ function BsdCard({ // qui ne rend pas le mode de transport obligatoire à la signature transporteur // en attente de correction Cf ticket tra-14517 !currentTransporterInfos.transporterMode) && - (isToCollectTab || - (isCollectedTab && - !!currentTransporterInfos?.transporterNumberPlate?.length)); + !!currentTransporterInfos?.transporterNumberPlate?.length; const handleValidationClick = ( _: React.MouseEvent @@ -399,7 +393,8 @@ function BsdCard({ transporterNumberPlate: currentTransporterInfos?.transporterNumberPlate }} - hasEditableInfos + hasEditableInfos={isToCollectTab} + info={currentTransporterInfos?.transporterNumberPlate?.toString()} isDisabled={ isCollectedTab || !permissions.includes(UserPermission.BsdCanUpdate) @@ -407,6 +402,12 @@ function BsdCard({ onClick={handleEditableInfoClick} /> )} + {bsdDisplay?.destination?.["cap"] && ( + + )} {isMobile &&
}
diff --git a/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIcon.scss b/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIcon.scss index fd862b4657..d3caedd33d 100644 --- a/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIcon.scss +++ b/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIcon.scss @@ -96,7 +96,8 @@ flex-shrink: 0; } } - &__CustomId { + &__CustomId, + &__Cap { &:before { display: inline-block; content: ""; diff --git a/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIconTypes.ts b/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIconTypes.ts index d047c60a02..96aa5ed847 100644 --- a/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIconTypes.ts +++ b/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIconTypes.ts @@ -8,15 +8,16 @@ export enum InfoIconCode { TransporterNumberPlate = "TransporterNumberPlate", PickupSite = "PickupSite", CustomId = "CustomId", + Cap = "Cap", default = "" } export enum InfoIconValue { TempStorage = "Entreposage provisoire", LastModificationDate = "Modifié le", CustomInfo = "Champ libre", - TransporterNumberPlate = "Plaque d'immatriculation", PickupSite = "Adresse chantier", CustomId = "N° libre : ", + Cap = "CAP : ", default = "" } diff --git a/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIconUtils.ts b/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIconUtils.ts index ac76c3f02d..b0e8418907 100644 --- a/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIconUtils.ts +++ b/front/src/Apps/Dashboard/Components/InfoWithIcon/infoWithIconUtils.ts @@ -11,9 +11,11 @@ export const getLabelValue = (code: InfoIconCode): InfoIconValue => { case InfoIconCode.CustomInfo: return InfoIconValue.CustomInfo; case InfoIconCode.TransporterNumberPlate: - return InfoIconValue.TransporterNumberPlate; + return InfoIconValue.default; case InfoIconCode.CustomId: return InfoIconValue.CustomId; + case InfoIconCode.Cap: + return InfoIconValue.Cap; default: return InfoIconValue.default; } diff --git a/front/src/Apps/common/queries/fragments/bsda.ts b/front/src/Apps/common/queries/fragments/bsda.ts index edc6547943..ba34615668 100644 --- a/front/src/Apps/common/queries/fragments/bsda.ts +++ b/front/src/Apps/common/queries/fragments/bsda.ts @@ -60,6 +60,7 @@ export const bsdaFragment = gql` } } } + cap } worker { isDisabled diff --git a/front/src/Apps/common/queries/fragments/bsdd.ts b/front/src/Apps/common/queries/fragments/bsdd.ts index 129831112d..4baf7946b8 100644 --- a/front/src/Apps/common/queries/fragments/bsdd.ts +++ b/front/src/Apps/common/queries/fragments/bsdd.ts @@ -438,6 +438,7 @@ export const dashboardFormFragment = gql` name } isTempStorage + cap } transporter { id diff --git a/front/src/Apps/common/queries/fragments/bsff.ts b/front/src/Apps/common/queries/fragments/bsff.ts index ee6e2961e7..48cd6b4146 100644 --- a/front/src/Apps/common/queries/fragments/bsff.ts +++ b/front/src/Apps/common/queries/fragments/bsff.ts @@ -57,6 +57,7 @@ export const dashboardBsffFragment = gql` name } plannedOperationCode + cap } waste { code From 6957b7714bab519cfaa23cd8ff55f170fed24390 Mon Sep 17 00:00:00 2001 From: GaelFerrand <45355989+GaelFerrand@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:50:02 +0200 Subject: [PATCH 010/114] =?UTF-8?q?[TRA-14702]=20Correction=20du=20poids?= =?UTF-8?q?=20d'une=20annexe=201=20apr=C3=A8s=20la=20r=C3=A9vision=20du=20?= =?UTF-8?q?poids=20d'un=20BSD=20enfant=20(#3631)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fixed back & front to take weight change into account * fix: fixed sample number as well * fix: small frontend fix --- back/src/common/helpers.ts | 8 + .../acceptRevisionRequestApproval.ts | 43 +++ ...FormRevisionRequestApproval.integration.ts | 316 ++++++++++++++++++ .../Components/Revision/revisionMapper.ts | 4 +- 4 files changed, 369 insertions(+), 2 deletions(-) diff --git a/back/src/common/helpers.ts b/back/src/common/helpers.ts index 8823aff37f..c817dc2a8c 100644 --- a/back/src/common/helpers.ts +++ b/back/src/common/helpers.ts @@ -19,3 +19,11 @@ export const dateToXMonthAtHHMM = (date: Date = new Date()): string => { export const isDefined = (obj: any) => { return obj !== null && obj !== undefined; }; + +/** + * Tests if a list of objects are ALL defined. 0 will be considered as defined + */ +export const areDefined = (...obj: any[]) => { + if (obj.some(o => !isDefined(o))) return false; + return true; +}; diff --git a/back/src/forms/repository/formRevisionRequest/acceptRevisionRequestApproval.ts b/back/src/forms/repository/formRevisionRequest/acceptRevisionRequestApproval.ts index ed605d47b1..c5df93706a 100644 --- a/back/src/forms/repository/formRevisionRequest/acceptRevisionRequestApproval.ts +++ b/back/src/forms/repository/formRevisionRequest/acceptRevisionRequestApproval.ts @@ -26,6 +26,7 @@ import { distinct } from "../../../common/arrays"; import { ForbiddenError } from "../../../common/errors"; import { isFinalOperationCode } from "../../../common/operationCodes"; import { operationHook } from "../../operationHook"; +import { areDefined } from "../../../common/helpers"; export type AcceptRevisionRequestApprovalFn = ( revisionRequestApprovalId: string, @@ -176,6 +177,8 @@ async function getUpdateFromFormRevisionRequest( ...(revisionRequest.wasteDetailsPop !== null && { wasteDetailsPop: revisionRequest.wasteDetailsPop }), + wasteDetailsQuantity: revisionRequest.wasteDetailsQuantity, + wasteDetailsSampleNumber: revisionRequest.wasteDetailsSampleNumber, ...(revisionRequest.wasteDetailsPackagingInfos && { wasteDetailsPackagingInfos: revisionRequest.wasteDetailsPackagingInfos }), @@ -363,6 +366,46 @@ export async function approveAndApplyRevisionRequest( } } + // Revision targeted an ANNEXE_1 (child). We need to update its parent + if (updatedBsdd.emitterType === EmitterType.APPENDIX1_PRODUCER) { + // Quantity changed. We need to fix parent's quantity as well + if ( + areDefined( + bsddBeforeRevision.wasteDetailsQuantity, + revisionRequest.wasteDetailsQuantity + ) + ) { + // Calculate revision diff + const diff = bsddBeforeRevision + .wasteDetailsQuantity!.minus(revisionRequest.wasteDetailsQuantity!) + .toDecimalPlaces(6); + const { nextForm: parent } = await prisma.formGroupement.findFirstOrThrow( + { + where: { initialFormId: bsddBeforeRevision.id }, + include: { + nextForm: true + } + } + ); + + // Calculate parent new quantity using diff + const parentNewQuantity = parent.wasteDetailsQuantity + ?.minus(diff) + .toDecimalPlaces(6); + + // Update & re-index parent + await prisma.form.update({ + where: { id: parent.id }, + data: { + wasteDetailsQuantity: parentNewQuantity + } + }); + prisma.addAfterCommitCallback?.(() => { + enqueueUpdatedBsdToIndex(parent.readableId); + }); + } + } + if (updatedBsdd.emitterType === EmitterType.APPENDIX1) { const { wasteDetailsCode, wasteDetailsName, wasteDetailsPop } = revisionRequest; diff --git a/back/src/forms/resolvers/mutations/__tests__/submitFormRevisionRequestApproval.integration.ts b/back/src/forms/resolvers/mutations/__tests__/submitFormRevisionRequestApproval.integration.ts index a7e5e68b7d..b6346f503e 100644 --- a/back/src/forms/resolvers/mutations/__tests__/submitFormRevisionRequestApproval.integration.ts +++ b/back/src/forms/resolvers/mutations/__tests__/submitFormRevisionRequestApproval.integration.ts @@ -16,6 +16,7 @@ import makeClient from "../../../../__tests__/testClient"; import { CompanyType, EmitterType, + RevisionRequestStatus, Status, UserRole, WasteAcceptationStatus @@ -960,6 +961,321 @@ describe("Mutation.submitFormRevisionRequestApproval", () => { expect(updatedAppendix1.wasteDetailsCode).toBe(newWasteCode); }); + it("should udate appendix 1 parent's quantity if revision modified child's quantity", async () => { + // Given + const { company: childAppendixEmitter } = await userWithCompanyFactory( + "ADMIN" + ); + const { user: parentAppendixUser, company: parentAppendixEmitter } = + await userWithCompanyFactory("ADMIN"); + const { company: transporter } = await userWithCompanyFactory("ADMIN"); + + const appendix1Child = await prisma.form.create({ + data: { + readableId: getReadableId(), + status: Status.RECEIVED, + emitterType: EmitterType.APPENDIX1_PRODUCER, + emitterCompanySiret: childAppendixEmitter.siret, + wasteDetailsCode: "15 01 10*", + wasteDetailsQuantity: 10, + owner: { connect: { id: parentAppendixUser.id } }, + transporters: { + create: { + number: 1, + transporterCompanySiret: transporter.siret + } + } + } + }); + + const appendix1Parent = await formFactory({ + ownerId: parentAppendixUser.id, + opt: { + status: Status.SENT, + emitterType: EmitterType.APPENDIX1, + emitterCompanySiret: parentAppendixEmitter.siret, + emitterCompanyName: parentAppendixEmitter.name, + recipientCompanySiret: parentAppendixEmitter.siret, + wasteDetailsQuantity: 10, + grouping: { + create: { initialFormId: appendix1Child.id, quantity: 0 } + }, + transporters: { + create: { + number: 1, + transporterCompanySiret: transporter.siret + } + } + } + }); + + // When + const revisionRequest = await prisma.bsddRevisionRequest.create({ + data: { + bsddId: appendix1Child.id, + authoringCompanyId: childAppendixEmitter.id, + approvals: { create: { approverSiret: parentAppendixEmitter.siret! } }, + wasteDetailsQuantity: 6.8, + comment: "Changing quantity from 10 to 6.8" + } + }); + + const { mutate } = makeClient(parentAppendixUser); + const { data, errors } = await mutate< + Pick, + MutationSubmitFormRevisionRequestApprovalArgs + >(SUBMIT_BSDD_REVISION_REQUEST_APPROVAL, { + variables: { + id: revisionRequest.id, + isApproved: true + } + }); + + // Then + expect(errors).toBeUndefined(); + expect(data.submitFormRevisionRequestApproval.status).toBe( + RevisionRequestStatus.ACCEPTED + ); + + const updatedAppendix1Child = await prisma.form.findUniqueOrThrow({ + where: { id: appendix1Child.id } + }); + expect(updatedAppendix1Child.wasteDetailsQuantity?.toNumber()).toEqual(6.8); + + const updatedAppendix1Parent = await prisma.form.findUniqueOrThrow({ + where: { id: appendix1Parent.id } + }); + expect(updatedAppendix1Parent.wasteDetailsQuantity?.toNumber()).toEqual( + 6.8 + ); + }); + + it("should udate appendix 1 parent's quantity if revision modified child's quantity (multiple children)", async () => { + // Given + const { company: childAppendixEmitter } = await userWithCompanyFactory( + "ADMIN" + ); + const { user: parentAppendixUser, company: parentAppendixEmitter } = + await userWithCompanyFactory("ADMIN"); + const { company: transporter } = await userWithCompanyFactory("ADMIN"); + + const appendix1Child1 = await prisma.form.create({ + data: { + readableId: getReadableId(), + status: Status.RECEIVED, + emitterType: EmitterType.APPENDIX1_PRODUCER, + emitterCompanySiret: childAppendixEmitter.siret, + wasteDetailsCode: "15 01 10*", + wasteDetailsQuantity: 10, + owner: { connect: { id: parentAppendixUser.id } }, + transporters: { + create: { + number: 1, + transporterCompanySiret: transporter.siret + } + } + } + }); + + const appendix1Child2 = await prisma.form.create({ + data: { + readableId: getReadableId(), + status: Status.RECEIVED, + emitterType: EmitterType.APPENDIX1_PRODUCER, + emitterCompanySiret: childAppendixEmitter.siret, + wasteDetailsCode: "15 01 10*", + wasteDetailsQuantity: 5.5, + owner: { connect: { id: parentAppendixUser.id } }, + transporters: { + create: { + number: 1, + transporterCompanySiret: transporter.siret + } + } + } + }); + + const appendix1Parent = await formFactory({ + ownerId: parentAppendixUser.id, + opt: { + status: Status.SENT, + emitterType: EmitterType.APPENDIX1, + emitterCompanySiret: parentAppendixEmitter.siret, + emitterCompanyName: parentAppendixEmitter.name, + recipientCompanySiret: parentAppendixEmitter.siret, + wasteDetailsQuantity: 15.5, + grouping: { + create: [ + { initialFormId: appendix1Child1.id, quantity: 0 }, + { initialFormId: appendix1Child2.id, quantity: 0 } + ] + }, + transporters: { + create: { + number: 1, + transporterCompanySiret: transporter.siret + } + } + } + }); + + // When + const revisionRequest = await prisma.bsddRevisionRequest.create({ + data: { + bsddId: appendix1Child1.id, + authoringCompanyId: childAppendixEmitter.id, + approvals: { create: { approverSiret: parentAppendixEmitter.siret! } }, + wasteDetailsQuantity: 6.8, + comment: "Changing quantity from 10 to 6.8" + } + }); + + const { mutate } = makeClient(parentAppendixUser); + const { data, errors } = await mutate< + Pick, + MutationSubmitFormRevisionRequestApprovalArgs + >(SUBMIT_BSDD_REVISION_REQUEST_APPROVAL, { + variables: { + id: revisionRequest.id, + isApproved: true + } + }); + + // Then + expect(errors).toBeUndefined(); + expect(data.submitFormRevisionRequestApproval.status).toBe( + RevisionRequestStatus.ACCEPTED + ); + + const updatedAppendix1Child = await prisma.form.findUniqueOrThrow({ + where: { id: appendix1Child1.id } + }); + expect(updatedAppendix1Child.wasteDetailsQuantity?.toNumber()).toBe(6.8); + + const updatedAppendix1Parent = await prisma.form.findUniqueOrThrow({ + where: { id: appendix1Parent.id } + }); + expect(updatedAppendix1Parent.wasteDetailsQuantity?.toNumber()).toBe(12.3); + }); + + it("should udate bsdd sample number", async () => { + // Given + const { user, company: emitterCompany } = await userWithCompanyFactory( + "ADMIN" + ); + const { user: transporter, company: transporterCompany } = + await userWithCompanyFactory("ADMIN"); + + const bsdd = await prisma.form.create({ + data: { + readableId: getReadableId(), + status: Status.RECEIVED, + emitterCompanySiret: emitterCompany.siret, + wasteDetailsCode: "15 01 10*", + wasteDetailsSampleNumber: "sample number 1", + owner: { connect: { id: user.id } }, + transporters: { + create: { + number: 1, + transporterCompanySiret: transporterCompany.siret + } + } + } + }); + + // When + const revisionRequest = await prisma.bsddRevisionRequest.create({ + data: { + bsddId: bsdd.id, + authoringCompanyId: emitterCompany.id, + approvals: { create: { approverSiret: transporterCompany.siret! } }, + wasteDetailsSampleNumber: "sample number 2", + comment: "Changing sample number from 1 to 2" + } + }); + + const { mutate } = makeClient(transporter); + const { data, errors } = await mutate< + Pick, + MutationSubmitFormRevisionRequestApprovalArgs + >(SUBMIT_BSDD_REVISION_REQUEST_APPROVAL, { + variables: { + id: revisionRequest.id, + isApproved: true + } + }); + + // Then + expect(errors).toBeUndefined(); + expect(data.submitFormRevisionRequestApproval.status).toBe( + RevisionRequestStatus.ACCEPTED + ); + + const updatedBsdd = await prisma.form.findUniqueOrThrow({ + where: { id: bsdd.id } + }); + expect(updatedBsdd.wasteDetailsSampleNumber).toEqual("sample number 2"); + }); + + it("should udate bsdd wasteDetailsQuantity", async () => { + // Given + const { user, company: emitterCompany } = await userWithCompanyFactory( + "ADMIN" + ); + const { user: transporter, company: transporterCompany } = + await userWithCompanyFactory("ADMIN"); + + const bsdd = await prisma.form.create({ + data: { + readableId: getReadableId(), + status: Status.RECEIVED, + emitterCompanySiret: emitterCompany.siret, + wasteDetailsCode: "15 01 10*", + wasteDetailsQuantity: 10, + owner: { connect: { id: user.id } }, + transporters: { + create: { + number: 1, + transporterCompanySiret: transporterCompany.siret + } + } + } + }); + + // When + const revisionRequest = await prisma.bsddRevisionRequest.create({ + data: { + bsddId: bsdd.id, + authoringCompanyId: emitterCompany.id, + approvals: { create: { approverSiret: transporterCompany.siret! } }, + wasteDetailsQuantity: 6.5, + comment: "Changing wasteDetailsQuantity from 10 to 6.5" + } + }); + + const { mutate } = makeClient(transporter); + const { data, errors } = await mutate< + Pick, + MutationSubmitFormRevisionRequestApprovalArgs + >(SUBMIT_BSDD_REVISION_REQUEST_APPROVAL, { + variables: { + id: revisionRequest.id, + isApproved: true + } + }); + + // Then + expect(errors).toBeUndefined(); + expect(data.submitFormRevisionRequestApproval.status).toBe( + RevisionRequestStatus.ACCEPTED + ); + + const updatedBsdd = await prisma.form.findUniqueOrThrow({ + where: { id: bsdd.id } + }); + expect(updatedBsdd.wasteDetailsQuantity?.toNumber()).toEqual(6.5); + }); + it("should change the operation code & mode", async () => { const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" diff --git a/front/src/Apps/Dashboard/Components/Revision/revisionMapper.ts b/front/src/Apps/Dashboard/Components/Revision/revisionMapper.ts index 77a8641a52..97d2ba3c95 100644 --- a/front/src/Apps/Dashboard/Components/Revision/revisionMapper.ts +++ b/front/src/Apps/Dashboard/Components/Revision/revisionMapper.ts @@ -235,12 +235,12 @@ export const mapRevision = ( }, { dataName: DataNameEnum.QTY_ESTIMATED, - dataOldValue: review?.[bsdName]?.content?.wasteDetails?.quantity, + dataOldValue: review?.[bsdName].wasteDetails.quantity, dataNewValue: review?.content?.wasteDetails?.quantity }, { dataName: DataNameEnum.SAMPLE_NUMBER, - dataOldValue: review?.[bsdName]?.content?.wasteDetails?.sampleNumber, + dataOldValue: review?.[bsdName]?.wasteDetails?.sampleNumber, dataNewValue: review?.content?.wasteDetails?.sampleNumber }, { From 6ec71c1ae178a11766923487f09c6e661f63b189 Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Mon, 7 Oct 2024 10:05:55 +0200 Subject: [PATCH 011/114] Get rid of gerico feature flag --- back/src/companydigest/developper_doc.md | 2 +- .../companyDigestCreate.integration.ts | 36 ++----------------- .../resolvers/mutations/create.ts | 11 ------ front/index.html | 2 +- front/src/Apps/Companies/CompanyDetails.tsx | 24 +++++-------- 5 files changed, 14 insertions(+), 61 deletions(-) diff --git a/back/src/companydigest/developper_doc.md b/back/src/companydigest/developper_doc.md index 5a3e89ec27..15745ac68b 100644 --- a/back/src/companydigest/developper_doc.md +++ b/back/src/companydigest/developper_doc.md @@ -29,7 +29,7 @@ Chaque établissement est autorisé par un featureFlag. ### Base de données Le modèle CompanyDigest centralise les données persistés necessaires à cette fonctionalité. -Le modèle Company reçoit une nouvelle colonne featureFlags (string[]) dans lequel la présence de la chaine `COMPANY_DIGEST` détermine l'accès à la fonctionalité. +L'accès à la fcontionalité est ouvert à tous les établissementts depuis le 22/10/2024. CompanyDigest comporte user pour suivre l'utilisateur à l'origine de la demande ### Webhook diff --git a/back/src/companydigest/resolvers/mutations/__tests__/companyDigestCreate.integration.ts b/back/src/companydigest/resolvers/mutations/__tests__/companyDigestCreate.integration.ts index 32e8b741c2..b3c7bbcb0a 100644 --- a/back/src/companydigest/resolvers/mutations/__tests__/companyDigestCreate.integration.ts +++ b/back/src/companydigest/resolvers/mutations/__tests__/companyDigestCreate.integration.ts @@ -70,34 +70,8 @@ describe("Mutation.createCompanyDigest", () => { ]); }); - it("should raise an error if company has no relevant featureFlag", async () => { - const { user, company } = await userWithCompanyFactory("MEMBER"); - - const { mutate } = makeClient(user); - const { errors } = await mutate>( - CREATE_COMPANY_DIGEST, - { - variables: { - input: { orgId: company.siret, year: new Date().getFullYear() } - } - } - ); - - expect(errors).toEqual([ - expect.objectContaining({ - message: - "Vous nêtes pas autorisé à utiliser cette fonctionalité sur cet établissement.", - extensions: expect.objectContaining({ - code: ErrorCode.BAD_USER_INPUT - }) - }) - ]); - }); - it("should raise an error if year is too old", async () => { - const { user, company } = await userWithCompanyFactory("MEMBER", { - featureFlags: ["COMPANY_DIGEST"] - }); + const { user, company } = await userWithCompanyFactory("MEMBER"); const { mutate } = makeClient(user); const year = new Date().getFullYear() - 2; @@ -122,9 +96,7 @@ describe("Mutation.createCompanyDigest", () => { }); it("should create a companyDigest for current year", async () => { - const { user, company } = await userWithCompanyFactory("MEMBER", { - featureFlags: ["COMPANY_DIGEST"] - }); + const { user, company } = await userWithCompanyFactory("MEMBER"); const year = new Date().getFullYear(); const { mutate } = makeClient(user); @@ -142,9 +114,7 @@ describe("Mutation.createCompanyDigest", () => { }); it("should create a companyDigest for last year", async () => { - const { user, company } = await userWithCompanyFactory("MEMBER", { - featureFlags: ["COMPANY_DIGEST"] - }); + const { user, company } = await userWithCompanyFactory("MEMBER"); const year = new Date().getFullYear() - 1; const { mutate } = makeClient(user); diff --git a/back/src/companydigest/resolvers/mutations/create.ts b/back/src/companydigest/resolvers/mutations/create.ts index fc102962ce..27d78f84b1 100644 --- a/back/src/companydigest/resolvers/mutations/create.ts +++ b/back/src/companydigest/resolvers/mutations/create.ts @@ -11,7 +11,6 @@ import { prisma } from "@td/prisma"; import { sendGericoApiRequest } from "../../../queue/producers/gerico"; import { applyAuthStrategies, AuthType } from "../../../auth"; import { CompanyDigestStatus } from "@prisma/client"; -const COMPANY_DIGEST_FLAG = "COMPANY_DIGEST"; const createCompanyDigestResolver = async ( _: ResolversParentTypes["Mutation"], @@ -54,16 +53,6 @@ const createCompanyDigestResolver = async ( ); } - const companyHasFeatureFlag = await prisma.company.findFirst({ - where: { orgId, featureFlags: { has: COMPANY_DIGEST_FLAG } }, - select: { id: true } - }); - - if (!companyHasFeatureFlag) { - throw new UserInputError( - "Vous nêtes pas autorisé à utiliser cette fonctionalité sur cet établissement." - ); - } const companyDigest = await prisma.companyDigest.create({ data: { orgId, diff --git a/front/index.html b/front/index.html index 3ba11d2bac..177299f9ae 100644 --- a/front/index.html +++ b/front/index.html @@ -24,7 +24,7 @@ >; } => { const isAdmin = company.userRole === UserRole.Admin; - const isMember = company.userRole === UserRole.Member; - // Admin and member can access the gerico tab - const canViewCompanyDigestTab = - company.featureFlags.includes(COMPANY_DIGEST_FLAG) && (isAdmin || isMember); + const iconId = "fr-icon-checkbox-line" as FrIconClassName; const tabs = [ { @@ -61,22 +56,21 @@ const buildTabs = ( tabId: "tab4", label: "Contact", iconId + }, + { + tabId: "tab5", + label: "Fiche", + iconId } ]; const tabsContent = { tab1: CompanyInfo, tab2: CompanySignature, tab3: CompanyMembers, - tab4: CompanyContactForm + tab4: CompanyContactForm, + tab5: CompanyDigestSheetForm }; - if (canViewCompanyDigestTab) { - tabs.push({ - tabId: "tab5", - label: "Fiche", - iconId - }); - tabsContent["tab5"] = CompanyDigestSheetForm; - } + if (isAdmin) { tabs.push({ tabId: "tab6", From 9c0ae502dfe9618f4abd168d60a563daab429bf1 Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Tue, 8 Oct 2024 08:58:41 +0200 Subject: [PATCH 012/114] Update back/src/companydigest/developper_doc.md Co-authored-by: Riron --- back/src/companydigest/developper_doc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/back/src/companydigest/developper_doc.md b/back/src/companydigest/developper_doc.md index 15745ac68b..ec997b19dd 100644 --- a/back/src/companydigest/developper_doc.md +++ b/back/src/companydigest/developper_doc.md @@ -29,7 +29,7 @@ Chaque établissement est autorisé par un featureFlag. ### Base de données Le modèle CompanyDigest centralise les données persistés necessaires à cette fonctionalité. -L'accès à la fcontionalité est ouvert à tous les établissementts depuis le 22/10/2024. +L'accès à la fonctionnalité est ouvert à tous les établissementts depuis le 22/10/2024. CompanyDigest comporte user pour suivre l'utilisateur à l'origine de la demande ### Webhook From 926ff46d84464ad2a55ba7d8bf7a1db4acbbe6a3 Mon Sep 17 00:00:00 2001 From: JulianaJM <37509748+JulianaJM@users.noreply.github.com> Date: Tue, 8 Oct 2024 10:35:50 +0200 Subject: [PATCH 013/114] =?UTF-8?q?[tra-14929]Ajouter=20des=20liens=20d'?= =?UTF-8?q?=C3=A9vitement=20Menu=20principal=20/=20secondaire=20/=20conten?= =?UTF-8?q?u=20(Aller=20=C3=A0)=20(#3626)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/src/App.tsx | 2 + front/src/Apps/Account/Account.tsx | 2 +- .../Companies/CompaniesList/CompaniesList.tsx | 2 +- front/src/Apps/Companies/CompanyDetails.tsx | 16 +- .../DashboardTabs/DashboardTabs.tsx | 2 +- .../A11ySkipLinks/A11ySkipLinks.tsx | 171 ++++++++++++++++++ .../Components/DropdownMenu/DropdownMenu.tsx | 15 +- .../common/Components/SideBar/SideBar.tsx | 6 +- .../Apps/common/Components/layout/Header.tsx | 14 +- front/src/admin/Admin.tsx | 6 +- front/src/login/Signup.tsx | 3 +- 11 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 front/src/Apps/common/Components/A11ySkipLinks/A11ySkipLinks.tsx diff --git a/front/src/App.tsx b/front/src/App.tsx index 41d51cc854..bac92855b5 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -8,6 +8,7 @@ import BrowserDetect from "./BrowserDetect"; import ErrorBoundary from "./ErrorBoundary"; import { FeatureFlagsProvider } from "./common/contexts/FeatureFlagsContext"; import { PermissionsProvider } from "./common/contexts/PermissionsContext"; +import A11ySkipLinks from "./Apps/common/Components/A11ySkipLinks/A11ySkipLinks"; // Defines app-wide french error messages for yup // See https://github.com/jquense/yup#using-a-custom-locale-dictionary @@ -22,6 +23,7 @@ export default function App() {
+
diff --git a/front/src/Apps/Account/Account.tsx b/front/src/Apps/Account/Account.tsx index 46ed73c423..15de76eaff 100644 --- a/front/src/Apps/Account/Account.tsx +++ b/front/src/Apps/Account/Account.tsx @@ -41,7 +41,7 @@ export default function Account() { return (
{!isMobile && } -
+
} /> diff --git a/front/src/Apps/Companies/CompaniesList/CompaniesList.tsx b/front/src/Apps/Companies/CompaniesList/CompaniesList.tsx index 237fef45b0..90c87d37c9 100644 --- a/front/src/Apps/Companies/CompaniesList/CompaniesList.tsx +++ b/front/src/Apps/Companies/CompaniesList/CompaniesList.tsx @@ -134,7 +134,7 @@ export default function CompaniesList() { additional={ <> - Créer un établissement + Créer un établissement } > - - - +
+ + + +
); } diff --git a/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.tsx b/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.tsx index f2bea3f342..a61b0c6810 100644 --- a/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.tsx +++ b/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.tsx @@ -115,7 +115,7 @@ const DashboardTabs = ({ currentCompany, companies }: DashboardTabsProps) => { return (
-
+
{ + const location = useLocation(); + const links = useRef<{ title; callback }[]>([]); + const isMobile = useMedia(`(max-width: ${MEDIA_QUERIES.handHeld})`); + + const matchDashboard = matchPath( + { + path: routes.dashboard.index, + caseSensitive: false, + end: false + }, + location.pathname + ); + + const matchCompanies = matchPath( + { + path: routes.companies.index, + caseSensitive: false, + end: true + }, + location.pathname + ); + const matchCompanyDetails = matchPath( + { + path: routes.companies.details, + caseSensitive: false, + end: false + }, + location.pathname + ); + const matchRegistry = matchPath( + { + path: routes.registry, + caseSensitive: false, + end: false + }, + location.pathname + ); + const matchAccount = matchPath( + { + path: routes.account.info, + caseSensitive: false, + end: false + }, + location.pathname + ); + const matchAdmin = matchPath( + { + path: routes.admin.verification, + caseSensitive: false, + end: false + }, + location.pathname + ); + + const matchSignIn = matchPath( + { + path: routes.login, + caseSensitive: false, + end: false + }, + location.pathname + ); + + const matchSignUp = matchPath( + { + path: routes.signup.index, + caseSensitive: false, + end: false + }, + location.pathname + ); + const matchPasswordResetRequest = matchPath( + { + path: routes.passwordResetRequest, + caseSensitive: false, + end: false + }, + location.pathname + ); + + if (matchSignIn || matchSignUp || matchPasswordResetRequest) { + links.current = [ + { + title: "Contenu", + callback: () => { + matchSignUp + ? document.getElementById("fullnameSignUp")?.focus() + : document.getElementsByName("email")?.[0]?.focus(); + } + } + ]; + } else { + links.current = [ + { + title: "Menu principal", + callback: () => { + // @ts-ignore + document.getElementById("header-all-bsds-link")?.firstChild?.focus(); + } + }, + { + title: "Menu secondaire", + callback: () => { + if (matchDashboard) { + //dashboard company switcher + document + .getElementById("company-dashboard-select") + //@ts-ignore + ?.firstChild?.firstChild?.focus(); + } else { + //first accordion element + document + .getElementById("td-sidebar") + //@ts-ignore + ?.firstChild?.firstChild?.firstChild?.focus(); + } + } + }, + { + title: "Contenu", + callback: () => { + if (matchDashboard) { + document.getElementById("create-bsd-btn")?.focus(); + } + if (matchCompanies) { + // @ts-ignore + document.getElementById("create-company-link")?.parentNode?.focus(); + } + if (matchCompanyDetails) { + document.getElementById("company-tab-content")?.focus(); + } + if (matchRegistry) { + document.getElementsByName("exportType")?.[0]?.focus(); + } + if (matchAccount) { + document.getElementById("account-info")?.focus(); + } + if (matchAdmin) { + document.getElementById("admin-content")?.focus(); + } + } + } + ]; + } + if (isMobile) return null; + + return ( +
+ +
+ ); +}; + +export default A11ySkipLinks; diff --git a/front/src/Apps/common/Components/DropdownMenu/DropdownMenu.tsx b/front/src/Apps/common/Components/DropdownMenu/DropdownMenu.tsx index 81f09cfcec..c7c3404238 100644 --- a/front/src/Apps/common/Components/DropdownMenu/DropdownMenu.tsx +++ b/front/src/Apps/common/Components/DropdownMenu/DropdownMenu.tsx @@ -36,6 +36,7 @@ const DropdownMenu = ({ })} > @@ -71,10 +73,14 @@ const DropdownMenu = ({ }} > {link.icon && ( - + {link.icon} )} + {menuTitle} {link.title} ) : ( @@ -88,15 +94,20 @@ const DropdownMenu = ({ state={link.state && { ...link.state }} > {link.icon && ( - + {link.icon} )} {link.iconId && ( )} + {menuTitle} {link.title} )} diff --git a/front/src/Apps/common/Components/SideBar/SideBar.tsx b/front/src/Apps/common/Components/SideBar/SideBar.tsx index f1dbf935a8..a247fb09de 100644 --- a/front/src/Apps/common/Components/SideBar/SideBar.tsx +++ b/front/src/Apps/common/Components/SideBar/SideBar.tsx @@ -6,6 +6,10 @@ interface SideBarProps { } const SideBar = ({ children }: SideBarProps) => { - return ; + return ( + + ); }; export default React.memo(SideBar); diff --git a/front/src/Apps/common/Components/layout/Header.tsx b/front/src/Apps/common/Components/layout/Header.tsx index 432ea9804a..48b2d94c83 100644 --- a/front/src/Apps/common/Components/layout/Header.tsx +++ b/front/src/Apps/common/Components/layout/Header.tsx @@ -426,6 +426,7 @@ function MobileSubNav({ currentCompany }) {
); } +const allBsdsMenuEntryLbl = "Mes bordereaux"; const getDesktopMenuEntries = ( isAuthenticated, @@ -448,10 +449,9 @@ const getDesktopMenuEntries = ( navlink: true } ]; - const connected = [ { - caption: "Mes bordereaux", + caption: allBsdsMenuEntryLbl, href: currentSiret ? generatePath(routes.dashboard.index, { siret: currentSiret @@ -766,7 +766,15 @@ export default function Header({ style={{ margin: "initial", maxWidth: "initial" }} > {menuEntries.map((e, idx) => ( -
  • +
  • ))} diff --git a/front/src/admin/Admin.tsx b/front/src/admin/Admin.tsx index faf71118f2..e44cd51027 100644 --- a/front/src/admin/Admin.tsx +++ b/front/src/admin/Admin.tsx @@ -152,7 +152,11 @@ export default function Admin() { -
    +
    setNameValue(e.target.value) + onChange: e => setNameValue(e.target.value), + id: "fullnameSignUp" }} /> Date: Tue, 8 Oct 2024 15:30:29 +0200 Subject: [PATCH 014/114] Add action to push changes to the brgm (#3610) --- .github/workflows/brgm.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/brgm.yml diff --git a/.github/workflows/brgm.yml b/.github/workflows/brgm.yml new file mode 100644 index 0000000000..f57f6c3148 --- /dev/null +++ b/.github/workflows/brgm.yml @@ -0,0 +1,24 @@ +name: Push to the BRGM GitLab repository + +on: + schedule: + - cron: '0 2 * * *' # Run every day at 2 AM + workflow_dispatch: # Allow manual trigger + +jobs: + push-to-gitlab: + runs-on: ubuntu-latest + + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + + - name: Set up Git + run: | + git config --global user.name "TD GH Action" + git config --global user.email "action@github.com" + + - name: Add remote & push + run: | + git remote add brgm ${{ secrets.BRGM_GITLAB_URL }} + git push brgm --all From e4912fd6efd11b21aa96da0ad93ce69b923c4ff2 Mon Sep 17 00:00:00 2001 From: silto Date: Tue, 8 Oct 2024 18:11:33 +0200 Subject: [PATCH 015/114] fix(BSVHU): fix bsvhu emitter flags returning undefined through the API (#3636) --- back/src/bsvhu/converter.ts | 4 ++-- .../20241003231226_set_bsvhu_emitter_defaults/migration.sql | 3 +++ libs/back/prisma/src/schema.prisma | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 libs/back/prisma/src/migrations/20241003231226_set_bsvhu_emitter_defaults/migration.sql diff --git a/back/src/bsvhu/converter.ts b/back/src/bsvhu/converter.ts index a48992b79d..6909954f48 100644 --- a/back/src/bsvhu/converter.ts +++ b/back/src/bsvhu/converter.ts @@ -57,8 +57,8 @@ export function expandVhuFormFromDb(form: PrismaVhuForm): GraphqlVhuForm { status: form.status, emitter: nullIfNoValues({ agrementNumber: form.emitterAgrementNumber, - irregularSituation: form.emitterIrregularSituation, - noSiret: form.emitterNoSiret, + irregularSituation: form.emitterIrregularSituation ?? false, + noSiret: form.emitterNoSiret ?? false, company: nullIfNoValues({ name: form.emitterCompanyName, siret: form.emitterCompanySiret, diff --git a/libs/back/prisma/src/migrations/20241003231226_set_bsvhu_emitter_defaults/migration.sql b/libs/back/prisma/src/migrations/20241003231226_set_bsvhu_emitter_defaults/migration.sql new file mode 100644 index 0000000000..eac5bf2a36 --- /dev/null +++ b/libs/back/prisma/src/migrations/20241003231226_set_bsvhu_emitter_defaults/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Bsvhu" ALTER COLUMN "emitterIrregularSituation" SET DEFAULT false, +ALTER COLUMN "emitterNoSiret" SET DEFAULT false; diff --git a/libs/back/prisma/src/schema.prisma b/libs/back/prisma/src/schema.prisma index c31781756f..81a46eda36 100644 --- a/libs/back/prisma/src/schema.prisma +++ b/libs/back/prisma/src/schema.prisma @@ -995,8 +995,8 @@ model Bsvhu { status BsvhuStatus @default(INITIAL) - emitterIrregularSituation Boolean? - emitterNoSiret Boolean? + emitterIrregularSituation Boolean? @default(false) + emitterNoSiret Boolean? @default(false) emitterAgrementNumber String? @db.VarChar(100) emitterCompanyName String? emitterCompanySiret String? @db.VarChar(17) From 789ef6985cb83e3417a21ce14229d3e277be5128 Mon Sep 17 00:00:00 2001 From: silto Date: Tue, 8 Oct 2024 18:26:54 +0200 Subject: [PATCH 016/114] [TRA 14644] - Ajout d'Eco-organisme sur BSVHU (#3619) * feat(BSVHU): add support for eco organisme on BSVHU * fix(BSVHU/BSDA/BSDD/BSDASRI): add/fix ecoorganisme sirenification * test(BSVHU): add tests for eco-organisme, remove changes on sirenify that where getting infos from EcoOrganisme table (useless) * fix(BSVHU): refine paths on refinements * fix(Refinements): make not dormant refinement explicitly for emitters * feat(Libs): add object creation script * fix(BSVHU/BSDASRI): fix tests * fix(BSDs): fix test, make sirenify not overwrite eco organisme name if the company doesn't exist * fix(BSVHU): fix tests * fix(Object-creator): change object creator to compile * docs(Changelog): update Changelog --- CONTRIBUTING.md | 6 ++ Changelog.md | 4 + back/src/__tests__/factories.ts | 24 +++++- back/src/__tests__/testWorkflow.ts | 5 +- back/src/bsda/validation/refinements.ts | 42 +++-------- back/src/bsda/validation/sirenify.ts | 74 +++++++++++++------ .../createBsdasriEcoOrganisme.integration.ts | 12 ++- .../__tests__/updateBsdasri.integration.ts | 12 ++- back/src/bsdasris/sirenify.ts | 22 ++++-- back/src/bsffs/validation/bsff/refinements.ts | 4 +- back/src/bsvhu/__tests__/bsvhuEdition.test.ts | 12 ++- .../bsvhu/__tests__/elastic.integration.ts | 6 ++ back/src/bsvhu/__tests__/factories.vhu.ts | 16 +++- .../bsvhu/__tests__/registry.integration.ts | 28 ++++++- back/src/bsvhu/converter.ts | 17 ++++- back/src/bsvhu/elastic.ts | 8 +- back/src/bsvhu/pdf/components/BsvhuPdf.tsx | 10 +++ back/src/bsvhu/permissions.ts | 9 +++ back/src/bsvhu/registry.ts | 8 +- .../__tests__/createBsvhu.integration.ts | 72 +++++++++++++++++- .../__tests__/duplicateBsvhu.integration.ts | 15 +++- .../queries/__tests__/bsvhu.integration.ts | 4 + .../__tests__/bsvhumetadata.integration.ts | 2 +- back/src/bsvhu/typeDefs/bsvhu.inputs.graphql | 8 ++ back/src/bsvhu/typeDefs/bsvhu.objects.graphql | 9 +++ .../__tests__/validation.integration.ts | 40 +++++++++- back/src/bsvhu/validation/helpers.ts | 5 +- back/src/bsvhu/validation/refinements.ts | 12 ++- back/src/bsvhu/validation/rules.ts | 14 ++++ back/src/bsvhu/validation/schema.ts | 2 + back/src/bsvhu/validation/sirenify.ts | 9 +++ back/src/bsvhu/validation/types.ts | 1 + back/src/common/validation/zod/refinement.ts | 50 ++++++++++++- back/src/common/validation/zod/schema.ts | 2 +- back/src/companies/sirenify.ts | 42 ++++++----- .../typeDefs/company.objects.graphql | 1 + back/src/forms/sirenify.ts | 23 ++++-- libs/back/object-creator/.eslintrc.json | 18 +++++ libs/back/object-creator/project.json | 54 ++++++++++++++ libs/back/object-creator/src/main.ts | 54 ++++++++++++++ libs/back/object-creator/src/objects.ts | 26 +++++++ libs/back/object-creator/tsconfig.app.json | 9 +++ libs/back/object-creator/tsconfig.json | 14 ++++ .../migration.sql | 9 +++ .../migration.sql | 2 + libs/back/prisma/src/schema.prisma | 64 ++++++++-------- 46 files changed, 727 insertions(+), 153 deletions(-) create mode 100644 libs/back/object-creator/.eslintrc.json create mode 100644 libs/back/object-creator/project.json create mode 100644 libs/back/object-creator/src/main.ts create mode 100644 libs/back/object-creator/src/objects.ts create mode 100644 libs/back/object-creator/tsconfig.app.json create mode 100644 libs/back/object-creator/tsconfig.json create mode 100644 libs/back/prisma/src/migrations/20240924172106_bsvhu_ecoorganisme/migration.sql create mode 100644 libs/back/prisma/src/migrations/20240924183924_ecoorganisme_handle_bsvhu/migration.sql diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d59b3eb1b4..3fe9285591 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -740,6 +740,12 @@ Pour palier à ce problème, il est possible de nourrir la base de donnée Prism 3.2 Dans le container `td-api`: `npx prisma db push --preview-feature` pour recréer les tables 4. Dans le container `td-api`: `npx prisma db seed --preview-feature` pour nourrir la base de données. +### Ajouter un objet spécifique dans la base de données + +Au cas où il serait nécessaire d'ajouter un objet à la base de données, vous pouvez utiliser le script "object-creator". Pour celà, modifiez le fichier `libs/back/object-creator/src/objects.ts` en ajoutant des objets en respectant le format démontré en exemple. + +Vous pouvez ensuite utiliser `npx nx run object-creator:run` et si tout se passe bien, les objets seront créés dans la base de donnée spécifiée dans la variable d'environnement "DATABASE_URL". + ### Ajouter une nouvelle icône Les icônes utilisées dans l'application front viennent de https://streamlineicons.com/. diff --git a/Changelog.md b/Changelog.md index 12114dc5a5..c1499e108c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,10 @@ et le projet suit un schéma de versionning inspiré de [Calendar Versioning](ht # [2024.10.1] 22/10/2024 +#### :rocket: Nouvelles fonctionnalités + +- Ajout d'Eco-organisme sur BSVHU [PR 3619](https://github.com/MTES-MCT/trackdechets/pull/3619) + #### :bug: Corrections de bugs - Documentation API Developers : Page Not Found, si on n'y accède pas via l'arborescence [PR 3621](https://github.com/MTES-MCT/trackdechets/pull/3621) diff --git a/back/src/__tests__/factories.ts b/back/src/__tests__/factories.ts index 742e666601..e88bf2f82e 100644 --- a/back/src/__tests__/factories.ts +++ b/back/src/__tests__/factories.ts @@ -521,20 +521,36 @@ export const applicationFactory = async (openIdEnabled?: boolean) => { export const ecoOrganismeFactory = async ({ siret, - handleBsdasri = false + handle, + createAssociatedCompany }: { siret?: string; - handleBsdasri?: boolean; + handle?: { + handleBsdasri?: boolean; + handleBsda?: boolean; + handleBsvhu?: boolean; + }; + createAssociatedCompany?: boolean; }) => { + const { handleBsdasri, handleBsda, handleBsvhu } = handle ?? {}; const ecoOrganismeIndex = (await prisma.ecoOrganisme.count()) + 1; const ecoOrganisme = await prisma.ecoOrganisme.create({ data: { address: "", name: `Eco-Organisme ${ecoOrganismeIndex}`, - siret: siret ?? siretify(ecoOrganismeIndex), - handleBsdasri + siret: siret ?? siretify(), + handleBsdasri, + handleBsda, + handleBsvhu } }); + if (createAssociatedCompany) { + // create the related company so sirenify works as expected + await companyFactory({ + siret: ecoOrganisme.siret, + name: `Eco-Organisme ${ecoOrganismeIndex}` + }); + } return ecoOrganisme; }; diff --git a/back/src/__tests__/testWorkflow.ts b/back/src/__tests__/testWorkflow.ts index 2678586d3f..4317e28461 100644 --- a/back/src/__tests__/testWorkflow.ts +++ b/back/src/__tests__/testWorkflow.ts @@ -19,7 +19,10 @@ async function testWorkflow(workflow: Workflow) { }); if (workflowCompany.companyTypes.includes("ECO_ORGANISME")) { // create ecoOrganisme to allow its user to perform api calls - await ecoOrganismeFactory({ siret: company.siret!, handleBsdasri: true }); + await ecoOrganismeFactory({ + siret: company.siret!, + handle: { handleBsdasri: true } + }); } if ( workflowCompany.companyTypes.includes("TRANSPORTER") && diff --git a/back/src/bsda/validation/refinements.ts b/back/src/bsda/validation/refinements.ts index cc0364f0d1..5398f9e8f0 100644 --- a/back/src/bsda/validation/refinements.ts +++ b/back/src/bsda/validation/refinements.ts @@ -15,6 +15,7 @@ import { Bsda, BsdaStatus, BsdaType, + BsdType, Company, CompanyType } from "@prisma/client"; @@ -26,7 +27,8 @@ import { prisma } from "@td/prisma"; import { isWorker } from "../../companies/validation"; import { isDestinationRefinement, - isNotDormantRefinement, + isEcoOrganismeRefinement, + isEmitterNotDormantRefinement, isRegisteredVatNumberRefinement, isTransporterRefinement, refineSiretAndGetCompany @@ -259,36 +261,6 @@ export const checkRequiredFields: ( }; }; -async function refineAndGetEcoOrganisme(siret: string | null | undefined, ctx) { - if (!siret) return null; - const ecoOrganisme = await prisma.ecoOrganisme.findUnique({ - where: { siret } - }); - - if (ecoOrganisme === null) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `L'éco-organisme avec le SIRET ${siret} n'est pas référencé sur Trackdéchets` - }); - } - - return ecoOrganisme; -} - -async function isBsdaEcoOrganismeRefinement( - siret: string | null | undefined, - ctx: RefinementCtx -) { - const ecoOrganisme = await refineAndGetEcoOrganisme(siret, ctx); - - if (ecoOrganisme && !ecoOrganisme?.handleBsda) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `L'éco-organisme avec le SIRET ${siret} n'est pas autorisé à apparaitre sur un BSDA` - }); - } -} - async function checkEmitterIsNotEcoOrganisme( siret: string | null | undefined, ctx: RefinementCtx @@ -327,7 +299,7 @@ export const checkCompanies: Refinement = async ( ); }; - await isNotDormantRefinement(bsda.emitterCompanySiret, zodContext); + await isEmitterNotDormantRefinement(bsda.emitterCompanySiret, zodContext); await isDestinationRefinement( bsda.destinationCompanySiret, zodContext, @@ -355,7 +327,11 @@ export const checkCompanies: Refinement = async ( ); } await isWorkerRefinement(bsda.workerCompanySiret, zodContext); - await isBsdaEcoOrganismeRefinement(bsda.ecoOrganismeSiret, zodContext); + await isEcoOrganismeRefinement( + bsda.ecoOrganismeSiret, + BsdType.BSDA, + zodContext + ); await checkEmitterIsNotEcoOrganisme(bsda.emitterCompanySiret, zodContext); }; diff --git a/back/src/bsda/validation/sirenify.ts b/back/src/bsda/validation/sirenify.ts index 2711bb483f..fcbc0b56b0 100644 --- a/back/src/bsda/validation/sirenify.ts +++ b/back/src/bsda/validation/sirenify.ts @@ -1,6 +1,9 @@ import { ParsedZodBsda, ParsedZodBsdaTransporter } from "./schema"; import { CompanyInput } from "../../generated/graphql/types"; -import { nextBuildSirenify } from "../../companies/sirenify"; +import { + nextBuildSirenify, + NextCompanyInputAccessor +} from "../../companies/sirenify"; import { getSealedFields } from "./rules"; import { BsdaValidationContext, @@ -11,11 +14,11 @@ import { const sirenifyBsdaAccessors = ( bsda: ParsedZodBsda, sealedFields: string[] // Tranformations should not be run on sealed fields -) => [ +): NextCompanyInputAccessor[] => [ { siret: bsda?.emitterCompanySiret, skip: sealedFields.includes("emitterCompanySiret"), - setter: (input, companyInput: CompanyInput) => { + setter: (input, companyInput) => { input.emitterCompanyName = companyInput.name; input.emitterCompanyAddress = companyInput.address; } @@ -23,7 +26,7 @@ const sirenifyBsdaAccessors = ( { siret: bsda?.destinationCompanySiret, skip: sealedFields.includes("destinationCompanySiret"), - setter: (input, companyInput: CompanyInput) => { + setter: (input, companyInput) => { input.destinationCompanyName = companyInput.name; input.destinationCompanyAddress = companyInput.address; } @@ -31,7 +34,7 @@ const sirenifyBsdaAccessors = ( { siret: bsda?.workerCompanySiret, skip: sealedFields.includes("workerCompanySiret"), - setter: (input, companyInput: CompanyInput) => { + setter: (input, companyInput) => { input.workerCompanyName = companyInput.name; input.workerCompanyAddress = companyInput.address; } @@ -39,30 +42,53 @@ const sirenifyBsdaAccessors = ( { siret: bsda?.brokerCompanySiret, skip: sealedFields.includes("brokerCompanySiret"), - setter: (input, companyInput: CompanyInput) => { + setter: (input, companyInput) => { input.brokerCompanyName = companyInput.name; input.brokerCompanyAddress = companyInput.address; } }, - ...(bsda.intermediaries ?? []).map((_, idx) => ({ - siret: bsda.intermediaries![idx].siret, - skip: sealedFields.includes("intermediaries"), - setter: (input, companyInput: CompanyInput) => { - const intermediary = input.intermediaries[idx]; - intermediary.name = companyInput.name; - intermediary.address = companyInput.address; - } - })), - ...(bsda.transporters ?? []).map((_, idx) => ({ - siret: bsda.transporters![idx].transporterCompanySiret, - // FIXME skip conditionnaly based on transporter signatures - skip: false, - setter: (input, companyInput: CompanyInput) => { - const transporter = input.transporters[idx]; - transporter.transporterCompanyName = companyInput.name; - transporter.transporterCompanyAddress = companyInput.address; + { + siret: bsda?.ecoOrganismeSiret, + skip: sealedFields.includes("ecoOrganismeSiret"), + setter: (input, companyInput) => { + if (companyInput.name) { + input.ecoOrganismeName = companyInput.name; + } } - })) + }, + ...(bsda.intermediaries ?? []).map( + (_, idx) => + ({ + siret: bsda.intermediaries![idx].siret, + skip: sealedFields.includes("intermediaries"), + setter: (input, companyInput) => { + const intermediary = input.intermediaries![idx]; + if (companyInput.name) { + intermediary!.name = companyInput.name; + } + if (companyInput.address) { + intermediary!.address = companyInput.address; + } + } + } as NextCompanyInputAccessor) + ), + ...(bsda.transporters ?? []).map( + (_, idx) => + ({ + siret: bsda.transporters![idx].transporterCompanySiret, + // FIXME skip conditionnaly based on transporter signatures + skip: false, + setter: (input, companyInput) => { + const transporter = input.transporters![idx]; + if (companyInput.name) { + transporter!.transporterCompanyName = companyInput.name; + } + if (companyInput.address) { + transporter!.transporterCompanyAddress = companyInput.address; + } + } + } as NextCompanyInputAccessor) + ) ]; export const sirenifyBsda: ( diff --git a/back/src/bsdasris/resolvers/mutations/__tests__/createBsdasriEcoOrganisme.integration.ts b/back/src/bsdasris/resolvers/mutations/__tests__/createBsdasriEcoOrganisme.integration.ts index 9a5493f98e..163261ffa0 100644 --- a/back/src/bsdasris/resolvers/mutations/__tests__/createBsdasriEcoOrganisme.integration.ts +++ b/back/src/bsdasris/resolvers/mutations/__tests__/createBsdasriEcoOrganisme.integration.ts @@ -129,7 +129,9 @@ describe("Mutation.createDasri", () => { ]); }); it("create a dasri with an eco-organisme (eco-org user)", async () => { - const ecoOrg = await ecoOrganismeFactory({ handleBsdasri: true }); + const ecoOrg = await ecoOrganismeFactory({ + handle: { handleBsdasri: true } + }); const { user } = await userWithCompanyFactory("MEMBER", { siret: ecoOrg.siret }); @@ -178,7 +180,9 @@ describe("Mutation.createDasri", () => { expect(data.createBsdasri.ecoOrganisme?.siret).toEqual(ecoOrg.siret); }); it("create a dasri with an eco-organisme and an unregistered emitter(eco-org user)", async () => { - const ecoOrg = await ecoOrganismeFactory({ handleBsdasri: true }); + const ecoOrg = await ecoOrganismeFactory({ + handle: { handleBsdasri: true } + }); const { user } = await userWithCompanyFactory("MEMBER", { siret: ecoOrg.siret }); @@ -229,7 +233,9 @@ describe("Mutation.createDasri", () => { expect(data.createBsdasri.emitter?.company?.siret).toEqual(siret); }); it("create a dasri with an eco-organism (emitter user)", async () => { - const ecoOrg = await ecoOrganismeFactory({ handleBsdasri: true }); + const ecoOrg = await ecoOrganismeFactory({ + handle: { handleBsdasri: true } + }); const { company: ecoOrgCompany } = await userWithCompanyFactory("MEMBER", { siret: ecoOrg.siret }); diff --git a/back/src/bsdasris/resolvers/mutations/__tests__/updateBsdasri.integration.ts b/back/src/bsdasris/resolvers/mutations/__tests__/updateBsdasri.integration.ts index 2d9cb7ca88..f5855a8e93 100644 --- a/back/src/bsdasris/resolvers/mutations/__tests__/updateBsdasri.integration.ts +++ b/back/src/bsdasris/resolvers/mutations/__tests__/updateBsdasri.integration.ts @@ -323,7 +323,9 @@ describe("Mutation.updateBsdasri", () => { it("should allow eco organisme fields update for INITIAL bsdasris", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); - const ecoOrg = await ecoOrganismeFactory({ handleBsdasri: true }); + const ecoOrg = await ecoOrganismeFactory({ + handle: { handleBsdasri: true } + }); const { company: ecoOrgCompany } = await userWithCompanyFactory("MEMBER", { siret: ecoOrg.siret }); @@ -355,7 +357,9 @@ describe("Mutation.updateBsdasri", () => { it("should allow eco organisme fields nulling for INITIAL bsdasris", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); - const ecoOrg = await ecoOrganismeFactory({ handleBsdasri: true }); + const ecoOrg = await ecoOrganismeFactory({ + handle: { handleBsdasri: true } + }); const { company: ecoOrgCompany } = await userWithCompanyFactory("MEMBER", { siret: ecoOrg.siret }); @@ -430,7 +434,9 @@ describe("Mutation.updateBsdasri", () => { }); it("should disallow eco organisme fields update after emission signature", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); - const ecoOrg = await ecoOrganismeFactory({ handleBsdasri: true }); + const ecoOrg = await ecoOrganismeFactory({ + handle: { handleBsdasri: true } + }); const destination = await userWithCompanyFactory("MEMBER"); await userWithCompanyFactory("MEMBER", { diff --git a/back/src/bsdasris/sirenify.ts b/back/src/bsdasris/sirenify.ts index 24abc56877..37478152cd 100644 --- a/back/src/bsdasris/sirenify.ts +++ b/back/src/bsdasris/sirenify.ts @@ -1,5 +1,8 @@ import { Prisma } from "@prisma/client"; -import buildSirenify, { nextBuildSirenify } from "../companies/sirenify"; +import buildSirenify, { + nextBuildSirenify, + NextCompanyInputAccessor +} from "../companies/sirenify"; import { BsdasriInput, CompanyInput } from "../generated/graphql/types"; const accessors = (input: BsdasriInput) => [ @@ -31,11 +34,11 @@ export const sirenify = buildSirenify(accessors); const bsdasriCreateInputAccessors = ( input: Prisma.BsdasriCreateInput, sealedFields: string[] = [] // Tranformations should not be run on sealed fields -) => [ +): NextCompanyInputAccessor[] => [ { siret: input?.emitterCompanySiret, skip: sealedFields.includes("emitterCompanySiret"), - setter: (input: Prisma.BsdasriCreateInput, companyInput: CompanyInput) => { + setter: (input, companyInput) => { input.emitterCompanyName = companyInput.name; input.emitterCompanyAddress = companyInput.address; } @@ -43,7 +46,7 @@ const bsdasriCreateInputAccessors = ( { siret: input?.transporterCompanySiret, skip: sealedFields.includes("transporterCompanySiret"), - setter: (input: Prisma.BsdasriCreateInput, companyInput: CompanyInput) => { + setter: (input, companyInput) => { input.transporterCompanyName = companyInput.name; input.transporterCompanyAddress = companyInput.address; } @@ -51,10 +54,19 @@ const bsdasriCreateInputAccessors = ( { siret: input?.destinationCompanySiret, skip: sealedFields.includes("destinationCompanySiret"), - setter: (input: Prisma.BsdasriCreateInput, companyInput: CompanyInput) => { + setter: (input, companyInput) => { input.destinationCompanyName = companyInput.name; input.destinationCompanyAddress = companyInput.address; } + }, + { + siret: input?.ecoOrganismeSiret, + skip: sealedFields.includes("ecoOrganismeSiret"), + setter: (input, companyInput) => { + if (companyInput.name) { + input.ecoOrganismeName = companyInput.name; + } + } } ]; diff --git a/back/src/bsffs/validation/bsff/refinements.ts b/back/src/bsffs/validation/bsff/refinements.ts index 97e461a51c..6e5dcfecab 100644 --- a/back/src/bsffs/validation/bsff/refinements.ts +++ b/back/src/bsffs/validation/bsff/refinements.ts @@ -32,7 +32,7 @@ import { OPERATION } from "../../constants"; import { BsffOperationCode } from "../../../generated/graphql/types"; import { isDestinationRefinement, - isNotDormantRefinement, + isEmitterNotDormantRefinement, isRegisteredVatNumberRefinement, isTransporterRefinement } from "../../../common/validation/zod/refinement"; @@ -55,7 +55,7 @@ export const checkCompanies: Refinement = async ( bsff, zodContext ) => { - await isNotDormantRefinement(bsff.emitterCompanySiret, zodContext); + await isEmitterNotDormantRefinement(bsff.emitterCompanySiret, zodContext); await isDestinationRefinement(bsff.destinationCompanySiret, zodContext); for (const transporter of bsff.transporters ?? []) { diff --git a/back/src/bsvhu/__tests__/bsvhuEdition.test.ts b/back/src/bsvhu/__tests__/bsvhuEdition.test.ts index 482f6fd42f..29bc55ac76 100644 --- a/back/src/bsvhu/__tests__/bsvhuEdition.test.ts +++ b/back/src/bsvhu/__tests__/bsvhuEdition.test.ts @@ -1,6 +1,7 @@ import { BsvhuDestinationInput, BsvhuDestinationType, + BsvhuEcoOrganismeInput, BsvhuEmitterInput, BsvhuIdentificationInput, BsvhuInput, @@ -17,7 +18,7 @@ import { bsvhuEditionRules } from "../validation/rules"; import { graphQlInputToZodBsvhu } from "../validation/helpers"; describe("edition", () => { - test("an edition rule should be defined for every key in BsdaInput", () => { + test("an edition rule should be defined for every key in BsvhuInput", () => { // Create a dummy BSDVHU input where every possible key is present // The typing will break whenever a field is added or modified // to BsvhuInput so that we think of adding an entry to the edition rules @@ -96,6 +97,11 @@ describe("edition", () => { operation }; + const ecoOrganisme: Required = { + siret: "xxx", + name: "yyy" + }; + const input: Required = { emitter, wasteCode: "", @@ -104,7 +110,9 @@ describe("edition", () => { quantity: 1, weight, transporter, - destination + destination, + intermediaries: [company], + ecoOrganisme }; const flatInput = graphQlInputToZodBsvhu(input); for (const key of Object.keys(flatInput)) { diff --git a/back/src/bsvhu/__tests__/elastic.integration.ts b/back/src/bsvhu/__tests__/elastic.integration.ts index 4ab671918c..c622cc5391 100644 --- a/back/src/bsvhu/__tests__/elastic.integration.ts +++ b/back/src/bsvhu/__tests__/elastic.integration.ts @@ -30,6 +30,7 @@ describe("toBsdElastic > companies Names & OrgIds", () => { let elasticBsvhu: BsdElastic; let intermediary1: Company; let intermediary2: Company; + let ecoOrganisme: Company; beforeAll(async () => { // Given @@ -43,6 +44,7 @@ describe("toBsdElastic > companies Names & OrgIds", () => { }); intermediary1 = await companyFactory({ name: "Intermediaire 1" }); intermediary2 = await companyFactory({ name: "Intermediaire 2" }); + ecoOrganisme = await companyFactory({ name: "Eco organisme" }); bsvhu = await bsvhuFactory({ opt: { @@ -53,6 +55,8 @@ describe("toBsdElastic > companies Names & OrgIds", () => { transporterCompanyVatNumber: transporter.vatNumber, destinationCompanyName: destination.name, destinationCompanySiret: destination.siret, + ecoOrganismeName: ecoOrganisme.name, + ecoOrganismeSiret: ecoOrganisme.siret, intermediaries: { createMany: { data: [ @@ -75,6 +79,7 @@ describe("toBsdElastic > companies Names & OrgIds", () => { expect(elasticBsvhu.companyNames).toContain(destination.name); expect(elasticBsvhu.companyNames).toContain(intermediary1.name); expect(elasticBsvhu.companyNames).toContain(intermediary2.name); + expect(elasticBsvhu.companyNames).toContain(ecoOrganisme.name); }); test("companyOrgIds > should contain the orgIds of ALL BSVHU companies", async () => { @@ -84,5 +89,6 @@ describe("toBsdElastic > companies Names & OrgIds", () => { expect(elasticBsvhu.companyOrgIds).toContain(destination.siret); expect(elasticBsvhu.companyOrgIds).toContain(intermediary1.siret); expect(elasticBsvhu.companyOrgIds).toContain(intermediary2.siret); + expect(elasticBsvhu.companyOrgIds).toContain(ecoOrganisme.siret); }); }); diff --git a/back/src/bsvhu/__tests__/factories.vhu.ts b/back/src/bsvhu/__tests__/factories.vhu.ts index f77a861a64..c205ef7466 100644 --- a/back/src/bsvhu/__tests__/factories.vhu.ts +++ b/back/src/bsvhu/__tests__/factories.vhu.ts @@ -6,7 +6,11 @@ import { } from "@prisma/client"; import getReadableId, { ReadableIdPrefix } from "../../forms/readableId"; import { prisma } from "@td/prisma"; -import { companyFactory, siretify } from "../../__tests__/factories"; +import { + companyFactory, + ecoOrganismeFactory, + siretify +} from "../../__tests__/factories"; import { BsvhuForElastic, BsvhuForElasticInclude } from "../elastic"; export const bsvhuFactory = async ({ @@ -20,11 +24,16 @@ export const bsvhuFactory = async ({ const destinationCompany = await companyFactory({ companyTypes: ["WASTE_VEHICLES"] }); + const ecoOrganisme = await ecoOrganismeFactory({ + handle: { handleBsvhu: true }, + createAssociatedCompany: true + }); const created = await prisma.bsvhu.create({ data: { ...getVhuFormdata(), transporterCompanySiret: transporterCompany.siret, destinationCompanySiret: destinationCompany.siret, + ecoOrganismeSiret: ecoOrganisme.siret, ...opt }, include: { @@ -85,7 +94,10 @@ const getVhuFormdata = (): Prisma.BsvhuCreateInput => ({ destinationReceptionWeight: null, destinationReceptionAcceptationStatus: null, destinationReceptionRefusalReason: null, - destinationOperationCode: null + destinationOperationCode: null, + + ecoOrganismeSiret: siretify(4), + ecoOrganismeName: "Eco-Organisme" }); export const toIntermediaryCompany = (company: Company, contact = "toto") => ({ diff --git a/back/src/bsvhu/__tests__/registry.integration.ts b/back/src/bsvhu/__tests__/registry.integration.ts index e57cb1e192..46aa51a1f3 100644 --- a/back/src/bsvhu/__tests__/registry.integration.ts +++ b/back/src/bsvhu/__tests__/registry.integration.ts @@ -9,7 +9,7 @@ import { } from "../registry"; import { bsvhuFactory, toIntermediaryCompany } from "./factories.vhu"; import { resetDatabase } from "../../../integration-tests/helper"; -import { companyFactory } from "../../__tests__/factories"; +import { companyFactory, ecoOrganismeFactory } from "../../__tests__/factories"; import { RegistryBsvhuInclude } from "../../registry/elastic"; describe("toGenericWaste", () => { @@ -559,6 +559,32 @@ describe("toAllWaste", () => { expect(waste.intermediary3CompanyName).toBe(null); expect(waste.intermediary3CompanySiret).toBe(null); }); + + it("should contain ecoOrganisme infos", async () => { + // Given + const ecoOrganisme = await ecoOrganismeFactory({ + handle: { handleBsvhu: true } + }); + + const bsvhu = await bsvhuFactory({ + opt: { + ecoOrganismeSiret: ecoOrganisme.siret, + ecoOrganismeName: ecoOrganisme.name + } + }); + + // When + const bsvhuForRegistry = await prisma.bsvhu.findUniqueOrThrow({ + where: { id: bsvhu.id }, + include: RegistryBsvhuInclude + }); + const waste = toAllWaste(bsvhuForRegistry); + + // Then + expect(waste).not.toBeUndefined(); + expect(waste.ecoOrganismeSiren).toBe(ecoOrganisme.siret.substring(0, 9)); + expect(waste.ecoOrganismeName).toBe(ecoOrganisme.name); + }); }); describe("getTransportersData", () => { diff --git a/back/src/bsvhu/converter.ts b/back/src/bsvhu/converter.ts index 6909954f48..38d6b11d2a 100644 --- a/back/src/bsvhu/converter.ts +++ b/back/src/bsvhu/converter.ts @@ -22,7 +22,8 @@ import { BsvhuNextDestination, BsvhuTransport, BsvhuTransportInput, - CompanyInput + CompanyInput, + BsvhuEcoOrganisme } from "../generated/graphql/types"; import { Prisma, @@ -160,6 +161,10 @@ export function expandVhuFormFromDb(form: PrismaVhuForm): GraphqlVhuForm { takenOverAt: processDate(form.transporterTransportTakenOverAt) }) }), + ecoOrganisme: nullIfNoValues({ + name: form.ecoOrganismeName, + siret: form.ecoOrganismeSiret + }), metadata: null as any }; } @@ -169,6 +174,7 @@ export function flattenVhuInput(formInput: BsvhuInput) { ...flattenVhuEmitterInput(formInput), ...flattenVhuDestinationInput(formInput), ...flattenVhuTransporterInput(formInput), + ...flattenVhuEcoOrganismeInput(formInput), packaging: chain(formInput, f => f.packaging), wasteCode: chain(formInput, f => f.wasteCode), quantity: chain(formInput, f => f.quantity), @@ -341,6 +347,15 @@ function flattenVhuTransporterInput({ }; } +function flattenVhuEcoOrganismeInput({ + ecoOrganisme +}: Pick) { + return { + ecoOrganismeName: chain(ecoOrganisme, e => e.name), + ecoOrganismeSiret: chain(ecoOrganisme, e => e.siret) + }; +} + function flattenTransporterTransportInput( input: | { diff --git a/back/src/bsvhu/elastic.ts b/back/src/bsvhu/elastic.ts index 60745af50a..0ff61c929c 100644 --- a/back/src/bsvhu/elastic.ts +++ b/back/src/bsvhu/elastic.ts @@ -29,6 +29,7 @@ export async function getBsvhuForElastic( type ElasticSirets = { emitterCompanySiret: string | null | undefined; + ecoOrganismeSiret: string | null | undefined; destinationCompanySiret: string | null | undefined; transporterCompanySiret: string | null | undefined; intermediarySiret1?: string | null | undefined; @@ -54,6 +55,7 @@ const getBsvhuSirets = (bsvhu: BsvhuForElastic): ElasticSirets => { const bsvhuSirets: ElasticSirets = { emitterCompanySiret: bsvhu.emitterCompanySiret, destinationCompanySiret: bsvhu.destinationCompanySiret, + ecoOrganismeSiret: bsvhu.ecoOrganismeSiret, transporterCompanySiret: getTransporterCompanyOrgId(bsvhu), ...intermediarySirets }; @@ -210,8 +212,8 @@ export function toBsdElastic(bsvhu: BsvhuForElastic): BsdElastic { traderCompanySiret: "", traderCompanyAddress: "", - ecoOrganismeName: "", - ecoOrganismeSiret: "", + ecoOrganismeName: bsvhu.ecoOrganismeName ?? "", + ecoOrganismeSiret: bsvhu.ecoOrganismeSiret ?? "", nextDestinationCompanyName: bsvhu.destinationOperationNextDestinationCompanyName ?? "", @@ -247,6 +249,7 @@ export function toBsdElastic(bsvhu: BsvhuForElastic): BsdElastic { bsvhu.emitterCompanyName, bsvhu.transporterCompanyName, bsvhu.destinationCompanyName, + bsvhu.ecoOrganismeName, ...bsvhu.intermediaries.map(intermediary => intermediary.name) ] .filter(Boolean) @@ -256,6 +259,7 @@ export function toBsdElastic(bsvhu: BsvhuForElastic): BsdElastic { bsvhu.transporterCompanySiret, bsvhu.transporterCompanyVatNumber, bsvhu.destinationCompanySiret, + bsvhu.ecoOrganismeSiret, ...bsvhu.intermediaries.map(intermediary => intermediary.siret), ...bsvhu.intermediaries.map(intermediary => intermediary.vatNumber) ].filter(Boolean) diff --git a/back/src/bsvhu/pdf/components/BsvhuPdf.tsx b/back/src/bsvhu/pdf/components/BsvhuPdf.tsx index 00f8027adb..de28bfb96a 100644 --- a/back/src/bsvhu/pdf/components/BsvhuPdf.tsx +++ b/back/src/bsvhu/pdf/components/BsvhuPdf.tsx @@ -107,6 +107,16 @@ export function BsvhuPdf({ bsvhu, qrCode, renderEmpty }: Props) {

    Nom de la personne à contacter : {bsvhu.emitter?.company?.contact}

    + {bsvhu?.ecoOrganisme?.siret && ( +

    + Eco-organisme désigné :{" "} +

    + Raison sociale : {bsvhu.ecoOrganisme?.name} +
    + SIREN : {bsvhu.ecoOrganisme?.siret?.substring(0, 9)} +

    +

    + )}
    {/* End Emitter */} {/* Recipient */} diff --git a/back/src/bsvhu/permissions.ts b/back/src/bsvhu/permissions.ts index 852009b779..b73d8f1dc7 100644 --- a/back/src/bsvhu/permissions.ts +++ b/back/src/bsvhu/permissions.ts @@ -11,6 +11,7 @@ function readers(bsvhu: Bsvhu): string[] { bsvhu.destinationCompanySiret, bsvhu.transporterCompanySiret, bsvhu.transporterCompanyVatNumber, + bsvhu.ecoOrganismeSiret, ...bsvhu.intermediariesOrgIds ].filter(Boolean); } @@ -27,6 +28,8 @@ function contributors(bsvhu: Bsvhu, input?: BsvhuInput): string[] { const updateTransporterCompanySiret = input?.transporter?.company?.siret; const updateTransporterCompanyVatNumber = input?.transporter?.company?.vatNumber; + const updateEcoOrganismeCompanySiret = input?.ecoOrganisme?.siret; + const updateIntermediaries = (input?.intermediaries ?? []).flatMap(i => [ i.siret, i.vatNumber @@ -52,6 +55,10 @@ function contributors(bsvhu: Bsvhu, input?: BsvhuInput): string[] { ? updateTransporterCompanyVatNumber : bsvhu.transporterCompanyVatNumber; + const ecoOrganismeCompanySiret = + updateEcoOrganismeCompanySiret !== undefined + ? updateEcoOrganismeCompanySiret + : bsvhu.ecoOrganismeSiret; const intermediariesOrgIds = input?.intermediaries !== undefined ? updateIntermediaries @@ -62,6 +69,7 @@ function contributors(bsvhu: Bsvhu, input?: BsvhuInput): string[] { destinationCompanySiret, transporterCompanySiret, transporterCompanyVatNumber, + ecoOrganismeCompanySiret, ...intermediariesOrgIds ].filter(Boolean); } @@ -72,6 +80,7 @@ function contributors(bsvhu: Bsvhu, input?: BsvhuInput): string[] { function creators(input: BsvhuInput) { return [ input.emitter?.company?.siret, + input.ecoOrganisme?.siret, input.transporter?.company?.siret, input.transporter?.company?.vatNumber, input.destination?.company?.siret diff --git a/back/src/bsvhu/registry.ts b/back/src/bsvhu/registry.ts index fc6b3cc5e4..eb795ee62d 100644 --- a/back/src/bsvhu/registry.ts +++ b/back/src/bsvhu/registry.ts @@ -119,6 +119,10 @@ export function getRegistryFields( registryFields.isTransportedWasteFor.push(bsvhu.transporterCompanySiret); registryFields.isAllWasteFor.push(bsvhu.transporterCompanySiret); } + if (bsvhu.ecoOrganismeSiret) { + registryFields.isOutgoingWasteFor.push(bsvhu.ecoOrganismeSiret); + registryFields.isAllWasteFor.push(bsvhu.ecoOrganismeSiret); + } if (bsvhu.intermediaries?.length) { for (const intermediary of bsvhu.intermediaries) { const intermediaryOrgId = getIntermediaryCompanyOrgId(intermediary); @@ -172,8 +176,8 @@ export function toGenericWaste(bsvhu: RegistryBsvhu): GenericWaste { id: bsvhu.id, createdAt: bsvhu.createdAt, updatedAt: bsvhu.createdAt, - ecoOrganismeName: null, - ecoOrganismeSiren: null, + ecoOrganismeName: bsvhu.ecoOrganismeName, + ecoOrganismeSiren: bsvhu.ecoOrganismeSiret?.slice(0, 9), bsdType: "BSVHU", bsdSubType: getBsvhuSubType(bsvhu), status: bsvhu.status, diff --git a/back/src/bsvhu/resolvers/mutations/__tests__/createBsvhu.integration.ts b/back/src/bsvhu/resolvers/mutations/__tests__/createBsvhu.integration.ts index 3314fe114f..c28fb8d839 100644 --- a/back/src/bsvhu/resolvers/mutations/__tests__/createBsvhu.integration.ts +++ b/back/src/bsvhu/resolvers/mutations/__tests__/createBsvhu.integration.ts @@ -6,7 +6,8 @@ import { siretify, userFactory, userWithCompanyFactory, - transporterReceiptFactory + transporterReceiptFactory, + ecoOrganismeFactory } from "../../../../__tests__/factories"; import makeClient from "../../../../__tests__/testClient"; import gql from "graphql-tag"; @@ -46,6 +47,10 @@ const CREATE_VHU_FORM = gql` intermediaries { siret } + ecoOrganisme { + siret + name + } weight { value } @@ -311,6 +316,71 @@ describe("Mutation.Vhu.create", () => { ); }); + it("should create a bsvhu with eco-organisme", async () => { + const { user, company } = await userWithCompanyFactory("MEMBER"); + const destinationCompany = await companyFactory({ + companyTypes: ["WASTE_VEHICLES"] + }); + + const ecoOrganisme = await ecoOrganismeFactory({ + handle: { handleBsvhu: true }, + createAssociatedCompany: true + }); + + const input = { + emitter: { + company: { + siret: company.siret, + name: "The crusher", + address: "Rue de la carcasse", + contact: "Un centre VHU", + phone: "0101010101", + mail: "emitter@mail.com" + }, + agrementNumber: "1234" + }, + wasteCode: "16 01 06", + packaging: "UNITE", + identification: { + numbers: ["123", "456"], + type: "NUMERO_ORDRE_REGISTRE_POLICE" + }, + quantity: 2, + weight: { + isEstimate: false, + value: 1.3 + }, + destination: { + type: "BROYEUR", + plannedOperationCode: "R 12", + company: { + siret: destinationCompany.siret, + name: "destination", + address: "address", + contact: "contactEmail", + phone: "contactPhone", + mail: "contactEmail@mail.com" + }, + agrementNumber: "9876" + }, + ecoOrganisme: { + siret: ecoOrganisme.siret, + name: ecoOrganisme.name + } + }; + const { mutate } = makeClient(user); + const { data } = await mutate>( + CREATE_VHU_FORM, + { + variables: { + input + } + } + ); + expect(data.createBsvhu.id).toBeDefined(); + expect(data.createBsvhu.ecoOrganisme!.siret).toBe(ecoOrganisme.siret); + }); + it("should create a bsvhu with intermediary", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const destinationCompany = await companyFactory({ diff --git a/back/src/bsvhu/resolvers/mutations/__tests__/duplicateBsvhu.integration.ts b/back/src/bsvhu/resolvers/mutations/__tests__/duplicateBsvhu.integration.ts index 832656319c..7065d80925 100644 --- a/back/src/bsvhu/resolvers/mutations/__tests__/duplicateBsvhu.integration.ts +++ b/back/src/bsvhu/resolvers/mutations/__tests__/duplicateBsvhu.integration.ts @@ -3,6 +3,7 @@ import { xDaysAgo } from "../../../../utils"; import { resetDatabase } from "../../../../../integration-tests/helper"; import { companyFactory, + ecoOrganismeFactory, transporterReceiptFactory, userWithCompanyFactory } from "../../../../__tests__/factories"; @@ -82,6 +83,10 @@ describe("mutaion.duplicateBsvhu", () => { const intermediary = await companyFactory(); + const ecoOrganisme = await ecoOrganismeFactory({ + handle: { handleBsvhu: true } + }); + const bsvhu = await bsvhuFactory({ opt: { emitterIrregularSituation: false, @@ -125,7 +130,9 @@ describe("mutaion.duplicateBsvhu", () => { } ] } - } + }, + ecoOrganismeSiret: ecoOrganisme.siret, + ecoOrganismeName: ecoOrganisme.name } }); const { mutate } = makeClient(emitter.user); @@ -195,6 +202,8 @@ describe("mutaion.duplicateBsvhu", () => { transporterCustomInfo, transporterTransportPlates, transporterRecepisseIsExempted, + ecoOrganismeSiret, + ecoOrganismeName, ...rest } = bsvhu; @@ -283,7 +292,9 @@ describe("mutaion.duplicateBsvhu", () => { transporterTransportTakenOverAt, transporterCustomInfo, transporterTransportPlates, - transporterRecepisseIsExempted + transporterRecepisseIsExempted, + ecoOrganismeSiret, + ecoOrganismeName }); // make sure this test breaks when a new field is added to the Bsvhu model diff --git a/back/src/bsvhu/resolvers/queries/__tests__/bsvhu.integration.ts b/back/src/bsvhu/resolvers/queries/__tests__/bsvhu.integration.ts index 9a4c4fe2d3..c3684d4e4f 100644 --- a/back/src/bsvhu/resolvers/queries/__tests__/bsvhu.integration.ts +++ b/back/src/bsvhu/resolvers/queries/__tests__/bsvhu.integration.ts @@ -40,6 +40,10 @@ query GetBsvhu($id: ID!) { number } } + ecoOrganisme { + name + siret + } weight { value } diff --git a/back/src/bsvhu/resolvers/queries/__tests__/bsvhumetadata.integration.ts b/back/src/bsvhu/resolvers/queries/__tests__/bsvhumetadata.integration.ts index 8d344bb4ea..e0d2ef78c2 100644 --- a/back/src/bsvhu/resolvers/queries/__tests__/bsvhumetadata.integration.ts +++ b/back/src/bsvhu/resolvers/queries/__tests__/bsvhumetadata.integration.ts @@ -161,6 +161,6 @@ describe("Query.Bsvhu", () => { variables: { id: bsd.id } }); - expect(data.bsvhu.metadata?.fields?.sealed?.length).toBe(58); + expect(data.bsvhu.metadata?.fields?.sealed?.length).toBe(60); }); }); diff --git a/back/src/bsvhu/typeDefs/bsvhu.inputs.graphql b/back/src/bsvhu/typeDefs/bsvhu.inputs.graphql index 172c0b01ff..619723b271 100644 --- a/back/src/bsvhu/typeDefs/bsvhu.inputs.graphql +++ b/back/src/bsvhu/typeDefs/bsvhu.inputs.graphql @@ -96,6 +96,9 @@ input BsvhuInput { Le nombre maximal d'intermédiaires sur un bordereau est de 3. """ intermediaries: [CompanyInput!] + + "Eco-organisme" + ecoOrganisme: BsvhuEcoOrganismeInput } input BsvhuEmitterInput { @@ -109,6 +112,11 @@ input BsvhuEmitterInput { company: BsvhuCompanyInput } +input BsvhuEcoOrganismeInput { + name: String! + siret: String! +} + "Extension de CompanyInput ajoutant des champs d'adresse séparés" input BsvhuCompanyInput { """ diff --git a/back/src/bsvhu/typeDefs/bsvhu.objects.graphql b/back/src/bsvhu/typeDefs/bsvhu.objects.graphql index aef6bc68d6..3106fb537a 100644 --- a/back/src/bsvhu/typeDefs/bsvhu.objects.graphql +++ b/back/src/bsvhu/typeDefs/bsvhu.objects.graphql @@ -62,6 +62,9 @@ type Bsvhu { """ intermediaries: [FormCompany!] + "Eco-organisme" + ecoOrganisme: BsvhuEcoOrganisme + metadata: BsvhuMetadata! } @@ -82,6 +85,12 @@ type BsvhuEmission { signature: Signature } +"Information sur l'éco-organisme responsable du BSVHU" +type BsvhuEcoOrganisme { + name: String! + siret: String! +} + type BsvhuTransporter { "Coordonnées de l'entreprise de transport" company: FormCompany diff --git a/back/src/bsvhu/validation/__tests__/validation.integration.ts b/back/src/bsvhu/validation/__tests__/validation.integration.ts index e8a7ca65b8..7cee23b741 100644 --- a/back/src/bsvhu/validation/__tests__/validation.integration.ts +++ b/back/src/bsvhu/validation/__tests__/validation.integration.ts @@ -1,7 +1,8 @@ -import { Company, OperationMode } from "@prisma/client"; +import { Company, EcoOrganisme, OperationMode } from "@prisma/client"; import { resetDatabase } from "../../../../integration-tests/helper"; import { companyFactory, + ecoOrganismeFactory, transporterReceiptFactory } from "../../../__tests__/factories"; import { CompanySearchResult } from "../../../companies/types"; @@ -35,6 +36,7 @@ describe("BSVHU validation", () => { let foreignTransporter: Company; let transporterCompany: Company; let intermediaryCompany: Company; + let ecoOrganisme: EcoOrganisme; beforeAll(async () => { const emitterCompany = await companyFactory({ companyTypes: ["PRODUCER"] }); transporterCompany = await companyFactory({ @@ -51,12 +53,16 @@ describe("BSVHU validation", () => { intermediaryCompany = await companyFactory({ companyTypes: ["INTERMEDIARY"] }); - + ecoOrganisme = await ecoOrganismeFactory({ + handle: { handleBsvhu: true }, + createAssociatedCompany: true + }); const prismaBsvhu = await bsvhuFactory({ opt: { emitterCompanySiret: emitterCompany.siret, transporterCompanySiret: transporterCompany.siret, destinationCompanySiret: destinationCompany.siret, + ecoOrganismeSiret: ecoOrganisme.siret, intermediaries: { create: [toIntermediaryCompany(intermediaryCompany)] } @@ -408,6 +414,30 @@ describe("BSVHU validation", () => { } }); + test("when ecoOrganisme isn't authorized for BSVHU", async () => { + const ecoOrg = await ecoOrganismeFactory({ + handle: { handleBsda: true } + }); + const data: ZodBsvhu = { + ...bsvhu, + ecoOrganismeSiret: ecoOrg.siret + }; + expect.assertions(1); + + try { + await parseBsvhuAsync(data, { + ...context, + currentSignatureType: "TRANSPORT" + }); + } catch (err) { + expect((err as ZodError).issues).toEqual([ + expect.objectContaining({ + message: `L'éco-organisme avec le SIRET ${ecoOrg.siret} n'est pas autorisé à apparaitre sur un BSVHU` + }) + ]); + } + }); + describe("Emitter transports own waste", () => { it("allowed if exemption", async () => { const data: ZodBsvhu = { @@ -577,7 +607,8 @@ describe("BSVHU validation", () => { [bsvhu.emitterCompanySiret!]: searchResult("émetteur"), [bsvhu.transporterCompanySiret!]: searchResult("transporteur"), [bsvhu.destinationCompanySiret!]: searchResult("destinataire"), - [intermediaryCompany.siret!]: searchResult("intermédiaire") + [intermediaryCompany.siret!]: searchResult("intermédiaire"), + [ecoOrganisme.siret!]: searchResult("ecoOrganisme") }; (searchCompany as jest.Mock).mockImplementation((clue: string) => { return Promise.resolve(searchResults[clue]); @@ -611,6 +642,9 @@ describe("BSVHU validation", () => { expect(sirenified.intermediaries![0].address).toEqual( searchResults[intermediaryCompany.siret!].address ); + expect(sirenified.ecoOrganismeName).toEqual( + searchResults[ecoOrganisme.siret!].name + ); }); it("should not overwrite `name` and `address` based on SIRENE data for sealed fields", async () => { const searchResults = { diff --git a/back/src/bsvhu/validation/helpers.ts b/back/src/bsvhu/validation/helpers.ts index c641503d78..46ca506bcb 100644 --- a/back/src/bsvhu/validation/helpers.ts +++ b/back/src/bsvhu/validation/helpers.ts @@ -113,7 +113,10 @@ export async function getBsvhuUserFunctions( (bsvhu.transporterCompanySiret != null && orgIds.includes(bsvhu.transporterCompanySiret)) || (bsvhu.transporterCompanyVatNumber != null && - orgIds.includes(bsvhu.transporterCompanyVatNumber)) + orgIds.includes(bsvhu.transporterCompanyVatNumber)), + isEcoOrganisme: + bsvhu.ecoOrganismeSiret != null && + orgIds.includes(bsvhu.ecoOrganismeSiret) }; } diff --git a/back/src/bsvhu/validation/refinements.ts b/back/src/bsvhu/validation/refinements.ts index acd1373602..57b4c9c75c 100644 --- a/back/src/bsvhu/validation/refinements.ts +++ b/back/src/bsvhu/validation/refinements.ts @@ -10,11 +10,12 @@ import { import { getSignatureAncestors } from "./helpers"; import { isArray } from "../../common/dataTypes"; import { capitalize } from "../../common/strings"; -import { WasteAcceptationStatus } from "@prisma/client"; +import { BsdType, WasteAcceptationStatus } from "@prisma/client"; import { destinationOperationModeRefinement, isDestinationRefinement, - isNotDormantRefinement, + isEcoOrganismeRefinement, + isEmitterNotDormantRefinement, isRegisteredVatNumberRefinement, isTransporterRefinement } from "../../common/validation/zod/refinement"; @@ -33,7 +34,7 @@ export const checkCompanies: Refinement = async ( bsvhu, zodContext ) => { - await isNotDormantRefinement(bsvhu.emitterCompanySiret, zodContext); + await isEmitterNotDormantRefinement(bsvhu.emitterCompanySiret, zodContext); await isDestinationRefinement( bsvhu.destinationCompanySiret, zodContext, @@ -51,6 +52,11 @@ export const checkCompanies: Refinement = async ( bsvhu.transporterCompanyVatNumber, zodContext ); + await isEcoOrganismeRefinement( + bsvhu.ecoOrganismeSiret, + BsdType.BSVHU, + zodContext + ); }; export const checkWeights: Refinement = ( diff --git a/back/src/bsvhu/validation/rules.ts b/back/src/bsvhu/validation/rules.ts index 1b6e5bafde..16157624e3 100644 --- a/back/src/bsvhu/validation/rules.ts +++ b/back/src/bsvhu/validation/rules.ts @@ -511,6 +511,20 @@ export const bsvhuEditionRules: BsvhuEditionRules = { // from: "TRANSPORT" // } }, + ecoOrganismeName: { + readableFieldName: "le nom de l'éco-organisme", + sealed: { from: "OPERATION" }, + path: ["ecoOrganisme", "name"], + required: { + from: "TRANSPORT", + when: bsvhu => !!bsvhu.ecoOrganismeSiret + } + }, + ecoOrganismeSiret: { + readableFieldName: "le SIRET de l'éco-organisme", + sealed: { from: "OPERATION" }, + path: ["ecoOrganisme", "siret"] + }, intermediaries: { readableFieldName: "les intermédiaires", sealed: { from: "TRANSPORT" }, diff --git a/back/src/bsvhu/validation/schema.ts b/back/src/bsvhu/validation/schema.ts index 018b1fbef8..aa6f80c9f9 100644 --- a/back/src/bsvhu/validation/schema.ts +++ b/back/src/bsvhu/validation/schema.ts @@ -182,6 +182,8 @@ const rawBsvhuSchema = z.object({ .boolean() .nullish() .transform(v => Boolean(v)), + ecoOrganismeName: z.string().nullish(), + ecoOrganismeSiret: siretSchema(CompanyRole.EcoOrganisme).nullish(), intermediaries: z .array(intermediarySchema) .nullish() diff --git a/back/src/bsvhu/validation/sirenify.ts b/back/src/bsvhu/validation/sirenify.ts index ddd01cfb7a..06bb4783ed 100644 --- a/back/src/bsvhu/validation/sirenify.ts +++ b/back/src/bsvhu/validation/sirenify.ts @@ -49,6 +49,15 @@ const sirenifyBsvhuAccessors = ( input.transporterCompanyAddress = companyInput.address; } }, + { + siret: bsvhu?.ecoOrganismeSiret, + skip: sealedFields.includes("ecoOrganismeSiret"), + setter: (input, companyInput) => { + if (companyInput.name) { + input.ecoOrganismeName = companyInput.name; + } + } + }, ...(bsvhu.intermediaries ?? []).map( (_, idx) => ({ diff --git a/back/src/bsvhu/validation/types.ts b/back/src/bsvhu/validation/types.ts index a2dfb7ecf6..c26110df02 100644 --- a/back/src/bsvhu/validation/types.ts +++ b/back/src/bsvhu/validation/types.ts @@ -7,6 +7,7 @@ export type BsvhuUserFunctions = { isEmitter: boolean; isDestination: boolean; isTransporter: boolean; + isEcoOrganisme: boolean; }; export type BsvhuValidationContext = { diff --git a/back/src/common/validation/zod/refinement.ts b/back/src/common/validation/zod/refinement.ts index f34986fd7f..6a7d5abc3c 100644 --- a/back/src/common/validation/zod/refinement.ts +++ b/back/src/common/validation/zod/refinement.ts @@ -7,7 +7,7 @@ import { isWasteVehicles } from "../../../companies/validation"; import { prisma } from "@td/prisma"; -import { Company, CompanyVerificationStatus } from "@prisma/client"; +import { BsdType, Company, CompanyVerificationStatus } from "@prisma/client"; import { getOperationModesFromOperationCode } from "../../operationModes"; import { CompanyRole, pathFromCompanyRole } from "./schema"; @@ -73,6 +73,27 @@ export async function refineSiretAndGetCompany( return company; } + +export async function refineAndGetEcoOrganisme( + siret: string | null | undefined, + ctx: RefinementCtx +) { + if (!siret) return null; + const ecoOrganisme = await prisma.ecoOrganisme.findUnique({ + where: { siret } + }); + + if (ecoOrganisme === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ecoOrganisme", "siret"], + message: `L'éco-organisme avec le SIRET ${siret} n'est pas référencé sur Trackdéchets` + }); + } + + return ecoOrganisme; +} + export const isRegisteredVatNumberRefinement = async ( vatNumber: string | null | undefined, ctx: RefinementCtx @@ -159,7 +180,7 @@ export async function isDestinationRefinement( } } -export async function isNotDormantRefinement( +export async function isEmitterNotDormantRefinement( siret: string | null | undefined, ctx: RefinementCtx ) { @@ -171,6 +192,7 @@ export async function isNotDormantRefinement( if (company?.isDormantSince) { ctx.addIssue({ code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Emitter), message: `L'établissement avec le SIRET ${siret} est en sommeil sur Trackdéchets, il n'est pas possible de le mentionner sur un bordereau` }); } @@ -205,3 +227,27 @@ export function destinationOperationModeRefinement( } } } + +export async function isEcoOrganismeRefinement( + siret: string | null | undefined, + bsdType: BsdType, + ctx: RefinementCtx +) { + const ecoOrganisme = await refineAndGetEcoOrganisme(siret, ctx); + + if (ecoOrganisme) { + if (bsdType === BsdType.BSDA && !ecoOrganisme.handleBsda) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.EcoOrganisme), + message: `L'éco-organisme avec le SIRET ${siret} n'est pas autorisé à apparaitre sur un BSDA` + }); + } else if (bsdType === BsdType.BSVHU && !ecoOrganisme.handleBsvhu) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.EcoOrganisme), + message: `L'éco-organisme avec le SIRET ${siret} n'est pas autorisé à apparaitre sur un BSVHU` + }); + } + } +} diff --git a/back/src/common/validation/zod/schema.ts b/back/src/common/validation/zod/schema.ts index b853a23e27..35510c29e5 100644 --- a/back/src/common/validation/zod/schema.ts +++ b/back/src/common/validation/zod/schema.ts @@ -22,7 +22,7 @@ export const pathFromCompanyRole = (companyRole?: CompanyRole): string[] => { case CompanyRole.Destination: return ["destination", "company", "siret"]; case CompanyRole.EcoOrganisme: - return ["ecoOrganisme", "company", "siret"]; + return ["ecoOrganisme", "siret"]; case CompanyRole.Broker: return ["broker", "company", "siret"]; case CompanyRole.Worker: diff --git a/back/src/companies/sirenify.ts b/back/src/companies/sirenify.ts index cce4905734..92fdb87777 100644 --- a/back/src/companies/sirenify.ts +++ b/back/src/companies/sirenify.ts @@ -140,41 +140,47 @@ export function nextBuildSirenify( // check if we found a corresponding companySearchResult based on siret const companySearchResults = await Promise.all( - accessors.map(({ siret, skip }) => - !skip && siret ? searchCompanyFailFast(siret) : null - ) + accessors.map(({ siret, skip }) => { + if (skip || !siret) { + return null; + } + return searchCompanyFailFast(siret); + }) ); // make a copy to avoid mutating initial data const sirenifiedInput = { ...input }; for (const [idx, companySearchResult] of companySearchResults.entries()) { + const { setter } = accessors[idx]; + if (!companySearchResult) { + continue; + } + const company = companySearchResult as CompanySearchResult; if ( - !companySearchResult || - companySearchResult.statutDiffusionEtablissement === - ("P" as StatutDiffusionEtablissement) - ) + company.statutDiffusionEtablissement === + ("P" as StatutDiffusionEtablissement) + ) { continue; - if (companySearchResult.etatAdministratif === "F") { + } + if (company.etatAdministratif === "F") { throw new UserInputError( - `L'établissement ${companySearchResult.siret} est fermé selon le répertoire SIRENE` + `L'établissement ${company.siret} est fermé selon le répertoire SIRENE` ); } - if (companySearchResult.isDormant) { + if (company.isDormant) { throw new UserInputError( - `L'établissement ${companySearchResult.siret} est en sommeil sur Trackdéchets. Il n'est pas possible de le mentionner dans un BSD.` + `L'établissement ${company.siret} est en sommeil sur Trackdéchets. Il n'est pas possible de le mentionner dans un BSD.` ); } - const { setter } = accessors[idx]; - setter(sirenifiedInput, { - name: companySearchResult.name, - address: companySearchResult.address, - city: companySearchResult.addressCity, - postalCode: companySearchResult.addressPostalCode, - street: companySearchResult.addressVoie + name: company.name, + address: company.address, + city: company.addressCity, + postalCode: company.addressPostalCode, + street: company.addressVoie }); } diff --git a/back/src/companies/typeDefs/company.objects.graphql b/back/src/companies/typeDefs/company.objects.graphql index bb52547b40..6fce74cd92 100644 --- a/back/src/companies/typeDefs/company.objects.graphql +++ b/back/src/companies/typeDefs/company.objects.graphql @@ -496,6 +496,7 @@ type EcoOrganisme { handleBsdasri: Boolean handleBsda: Boolean + handleBsvhu: Boolean } """ diff --git a/back/src/forms/sirenify.ts b/back/src/forms/sirenify.ts index 92d98d0f48..c62846e064 100644 --- a/back/src/forms/sirenify.ts +++ b/back/src/forms/sirenify.ts @@ -1,5 +1,8 @@ import { Prisma } from "@prisma/client"; -import buildSirenify, { nextBuildSirenify } from "../companies/sirenify"; +import buildSirenify, { + nextBuildSirenify, + NextCompanyInputAccessor +} from "../companies/sirenify"; import { CompanyInput, CreateFormInput, @@ -127,7 +130,7 @@ export const sirenifyTransporterInput = buildSirenify( const formCreateInputAccessors = ( formCreateInput: Prisma.FormCreateInput, sealedFields: string[] = [] // Tranformations should not be run on sealed fields -) => [ +): NextCompanyInputAccessor[] => [ { siret: formCreateInput?.emitterCompanySiret, skip: sealedFields.includes("emitterCompanySiret"), @@ -164,7 +167,9 @@ const formCreateInputAccessors = ( siret: formCreateInput?.ecoOrganismeSiret, skip: sealedFields.includes("ecoOrganismeSiret"), setter: (formCreateInput, companyInput: CompanyInput) => { - formCreateInput.ecoOrganismeName = companyInput.name; + if (companyInput.name) { + formCreateInput.ecoOrganismeName = companyInput.name; + } } }, ...( @@ -194,10 +199,14 @@ const formCreateInputAccessors = ( )?.transporterCompanySiret || sealedFields.includes("transporterCompanySiret"), setter: (formCreateInput, companyInput: CompanyInput) => { - formCreateInput.transporters.create.transporterCompanyName = - companyInput.name; - formCreateInput.transporters.create.transporterCompanyAddress = - companyInput.address; + ( + formCreateInput.transporters + ?.create as Prisma.BsddTransporterCreateWithoutFormInput + ).transporterCompanyName = companyInput.name; + ( + formCreateInput.transporters + ?.create as Prisma.BsddTransporterCreateWithoutFormInput + ).transporterCompanyAddress = companyInput.address; } } ]; diff --git a/libs/back/object-creator/.eslintrc.json b/libs/back/object-creator/.eslintrc.json new file mode 100644 index 0000000000..3456be9b90 --- /dev/null +++ b/libs/back/object-creator/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/back/object-creator/project.json b/libs/back/object-creator/project.json new file mode 100644 index 0000000000..48f86d059a --- /dev/null +++ b/libs/back/object-creator/project.json @@ -0,0 +1,54 @@ +{ + "name": "object-creator", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/back/object-creator/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "platform": "node", + "outputPath": "dist/libs/back/object-creator", + "format": ["cjs"], + "bundle": false, + "main": "libs/back/object-creator/src/main.ts", + "tsConfig": "libs/back/object-creator/tsconfig.app.json", + "assets": ["libs/back/object-creator/src/assets"], + "generatePackageJson": true, + "esbuildOptions": { + "sourcemap": true, + "outExtension": { + ".js": ".js" + } + } + }, + "configurations": { + "development": {}, + "production": { + "esbuildOptions": { + "sourcemap": false, + "outExtension": { + ".js": ".js" + } + } + } + } + }, + "run": { + "executor": "@nx/js:node", + "defaultConfiguration": "development", + "options": { + "buildTarget": "object-creator:build", + "watch": false + }, + "configurations": { + "development": { + "buildTarget": "object-creator:build:development" + } + } + } + }, + "tags": [] +} diff --git a/libs/back/object-creator/src/main.ts b/libs/back/object-creator/src/main.ts new file mode 100644 index 0000000000..d8cd53a8e9 --- /dev/null +++ b/libs/back/object-creator/src/main.ts @@ -0,0 +1,54 @@ +import { unescape } from "node:querystring"; +import objects from "./objects"; +import { PrismaClient } from "@prisma/client"; + +const { DATABASE_URL } = process.env; + +/* + Database clients init +*/ + +if (!DATABASE_URL) { + throw new Error("DATABASE_URL is not defined"); +} + +function getDbUrlWithSchema(rawDatabaseUrl: string) { + try { + const dbUrl = new URL(rawDatabaseUrl); + dbUrl.searchParams.set("schema", "default$default"); + + return unescape(dbUrl.href); // unescape needed because of the `$` + } catch (err) { + return ""; + } +} + +const prisma = new PrismaClient({ + datasources: { + db: { url: getDbUrlWithSchema(DATABASE_URL) } + }, + log: [] +}); + +/* + The main Run method +*/ +const run = async () => { + for (let index = 0; index < objects.length; index++) { + try { + const newObj = objects[index]; + await prisma[newObj.type].create({ + data: newObj.object + }); + console.log(`saved object ${index + 1}`); + } catch (error) { + console.error(error); + } + } + + console.log( + "ALL DONE ! remember to reindex to elastic if needed ( > npx nx run back:reindex-all-bsds-bulk -- -f )" + ); +}; + +run(); diff --git a/libs/back/object-creator/src/objects.ts b/libs/back/object-creator/src/objects.ts new file mode 100644 index 0000000000..a42bc99a84 --- /dev/null +++ b/libs/back/object-creator/src/objects.ts @@ -0,0 +1,26 @@ +import { PrismaClient, Prisma } from "@prisma/client"; + +type DataType = Prisma.Args< + PrismaClient[M], + "create" +>["data"]; + +type CreationObject = { + type: M; + object: DataType; +}; +const objects = [ + { + type: "ecoOrganisme", + object: { + siret: "00000000000013", + name: "Mon éco-organisme", + address: "12 RUE DES PINSONS 75012 PARIS", + handleBsdasri: false, + handleBsda: false, + handleBsvhu: true + } + } as CreationObject<"ecoOrganisme"> +]; + +export default objects; diff --git a/libs/back/object-creator/tsconfig.app.json b/libs/back/object-creator/tsconfig.app.json new file mode 100644 index 0000000000..762205a8de --- /dev/null +++ b/libs/back/object-creator/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/libs/back/object-creator/tsconfig.json b/libs/back/object-creator/tsconfig.json new file mode 100644 index 0000000000..089e304e98 --- /dev/null +++ b/libs/back/object-creator/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ], + "compilerOptions": { + "esModuleInterop": true, + "noErrorTruncation": true + } +} diff --git a/libs/back/prisma/src/migrations/20240924172106_bsvhu_ecoorganisme/migration.sql b/libs/back/prisma/src/migrations/20240924172106_bsvhu_ecoorganisme/migration.sql new file mode 100644 index 0000000000..5b431af16e --- /dev/null +++ b/libs/back/prisma/src/migrations/20240924172106_bsvhu_ecoorganisme/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Bsvhu" ADD COLUMN "ecoOrganismeName" TEXT, +ADD COLUMN "ecoOrganismeSiret" TEXT; + +-- CreateIndex +CREATE INDEX "_BsdaEcoOrganismeSiretIdx" ON "Bsda"("ecoOrganismeSiret"); + +-- CreateIndex +CREATE INDEX "_BsvhuEcoOrganismeSiretIdx" ON "Bsvhu"("ecoOrganismeSiret"); diff --git a/libs/back/prisma/src/migrations/20240924183924_ecoorganisme_handle_bsvhu/migration.sql b/libs/back/prisma/src/migrations/20240924183924_ecoorganisme_handle_bsvhu/migration.sql new file mode 100644 index 0000000000..acb15efba7 --- /dev/null +++ b/libs/back/prisma/src/migrations/20240924183924_ecoorganisme_handle_bsvhu/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EcoOrganisme" ADD COLUMN "handleBsvhu" BOOLEAN NOT NULL DEFAULT false; diff --git a/libs/back/prisma/src/schema.prisma b/libs/back/prisma/src/schema.prisma index 81a46eda36..89c5e6859a 100644 --- a/libs/back/prisma/src/schema.prisma +++ b/libs/back/prisma/src/schema.prisma @@ -392,6 +392,7 @@ model EcoOrganisme { address String handleBsdasri Boolean @default(false) handleBsda Boolean @default(false) + handleBsvhu Boolean @default(false) } // Permet de faire le lien entre un bordereau "initial" et le ou @@ -422,14 +423,14 @@ model BsddFinalOperation { } model Form { - id String @id @default(cuid()) @db.VarChar(30) - rowNumber Int @unique(map: "Form_rowNumber_ukey") @default(autoincrement()) - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) - readableId String @unique(map: "Form.readableId._UNIQUE") + id String @id @default(cuid()) @db.VarChar(30) + rowNumber Int @unique(map: "Form_rowNumber_ukey") @default(autoincrement()) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + readableId String @unique(map: "Form.readableId._UNIQUE") customId String? - isDeleted Boolean? @default(false) - status Status @default(DRAFT) + isDeleted Boolean? @default(false) + status Status @default(DRAFT) emitterType EmitterType? emitterPickupSite String? emitterCompanyName String? @@ -443,8 +444,8 @@ model Form { emitterWorkSiteCity String? emitterWorkSitePostalCode String? emitterWorkSiteInfos String? - emitterIsPrivateIndividual Boolean? @default(false) - emitterIsForeignShip Boolean? @default(false) + emitterIsPrivateIndividual Boolean? @default(false) + emitterIsForeignShip Boolean? @default(false) emitterCompanyOmiNumber String? recipientCap String? recipientProcessingOperation String? @@ -454,18 +455,18 @@ model Form { recipientCompanyContact String? recipientCompanyPhone String? recipientCompanyMail String? - recipientIsTempStorage Boolean? @default(false) + recipientIsTempStorage Boolean? @default(false) wasteDetailsCode String? wasteDetailsName String? wasteDetailsOnuCode String? - wasteDetailsQuantity Decimal? @db.Decimal(65, 30) + wasteDetailsQuantity Decimal? @db.Decimal(65, 30) wasteDetailsQuantityType QuantityType? wasteDetailsConsistence Consistence? - wasteDetailsPackagingInfos Json @default("[]") - wasteDetailsPop Boolean @default(false) + wasteDetailsPackagingInfos Json @default("[]") + wasteDetailsPop Boolean @default(false) wasteDetailsSampleNumber String? - wasteDetailsIsDangerous Boolean @default(false) - wasteDetailsParcelNumbers Json? @default("[]") + wasteDetailsIsDangerous Boolean @default(false) + wasteDetailsParcelNumbers Json? @default("[]") wasteDetailsAnalysisReferences String[] wasteDetailsLandIdentifiers String[] traderCompanyName String? @@ -476,7 +477,7 @@ model Form { traderCompanyMail String? traderReceipt String? traderDepartment String? - traderValidityLimit DateTime? @db.Timestamptz(6) + traderValidityLimit DateTime? @db.Timestamptz(6) ecoOrganismeName String? ecoOrganismeSiret String? brokerCompanyName String? @@ -487,7 +488,7 @@ model Form { brokerCompanyMail String? brokerReceipt String? brokerDepartment String? - brokerValidityLimit DateTime? @db.Timestamptz(6) + brokerValidityLimit DateTime? @db.Timestamptz(6) nextDestinationProcessingOperation String? nextDestinationCompanyName String? nextDestinationCompanySiret String? @@ -496,31 +497,31 @@ model Form { nextDestinationCompanyPhone String? nextDestinationCompanyMail String? nextDestinationCompanyCountry String? - nextDestinationCompanyVatNumber String? @db.VarChar(30) + nextDestinationCompanyVatNumber String? @db.VarChar(30) nextDestinationNotificationNumber String? nextDestinationCompanyExtraEuropeanId String? nextTransporterOrgId String? - emittedAt DateTime? @db.Timestamptz(6) + emittedAt DateTime? @db.Timestamptz(6) emittedBy String? emittedByEcoOrganisme Boolean? - takenOverAt DateTime? @db.Timestamptz(6) + takenOverAt DateTime? @db.Timestamptz(6) takenOverBy String? - signedAt DateTime? @db.Timestamptz(6) + signedAt DateTime? @db.Timestamptz(6) signedBy String? - isImportedFromPaper Boolean @default(false) + isImportedFromPaper Boolean @default(false) quantityReceivedType QuantityType? signedByTransporter Boolean? - sentAt DateTime? @db.Timestamptz(6) + sentAt DateTime? @db.Timestamptz(6) sentBy String? - isAccepted Boolean? @default(false) + isAccepted Boolean? @default(false) wasteAcceptationStatus WasteAcceptationStatus? wasteRefusalReason String? receivedBy String? - receivedAt DateTime? @db.Timestamptz(6) - quantityReceived Decimal? @db.Decimal(65, 30) - quantityRefused Decimal? @db.Decimal(65, 30) + receivedAt DateTime? @db.Timestamptz(6) + quantityReceived Decimal? @db.Decimal(65, 30) + quantityRefused Decimal? @db.Decimal(65, 30) processedBy String? - processedAt DateTime? @db.Timestamptz(6) + processedAt DateTime? @db.Timestamptz(6) processingOperationDone String? processingOperationDescription String? noTraceability Boolean? @@ -1064,6 +1065,9 @@ model Bsvhu { transporterTransportPlates String[] transporterRecepisseIsExempted Boolean? + ecoOrganismeName String? + ecoOrganismeSiret String? + intermediaries IntermediaryBsvhuAssociation[] // Denormalized fields, storing sirets to speed up queries and avoid expensive joins @@ -1075,6 +1079,7 @@ model Bsvhu { @@index([destinationCompanySiret], map: "_BsvhuDestinationCompanySiretIdx") @@index([transporterCompanySiret], map: "_BsvhuTransporterCompanySiretIdx") @@index([transporterCompanyVatNumber], map: "_BsvhuTransporterCompanyVatNumberIdx") + @@index([ecoOrganismeSiret], map: "_BsvhuEcoOrganismeSiretIdx") @@index([intermediariesOrgIds], map: "_BsvhuIntermediariesOrgIdsIdx", type: Gin) @@index([status], map: "_BsvhuStatusIdx") @@index([updatedAt], map: "_BsvhuUpdatedAtIdx") @@ -1645,6 +1650,7 @@ model Bsda { @@index([brokerCompanySiret], map: "_BsdaBrokerCompanySiretIdx") @@index([destinationCompanySiret], map: "_BsdaDestinationCompanySiretIdx") @@index([workerCompanySiret], map: "_BsdaWorkerCompanySiretIdx") + @@index([ecoOrganismeSiret], map: "_BsdaEcoOrganismeSiretIdx") @@index([destinationOperationNextDestinationCompanySiret], map: "_BsdaDestinationOperationNextDestinationCompanySiretIdx") @@index([status], map: "_BsdaStatusIdx") @@index([groupedInId], map: "_BsdaGroupedInIdIdx") @@ -2141,4 +2147,4 @@ enum EmptyReturnADR { EMPTY_RETURN_NOT_WASHED EMPTY_VEHICLE EMPTY_CITERNE -} \ No newline at end of file +} From 0215da939dfcaabf0c5bb67075a8e5b27afb1e70 Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Wed, 9 Oct 2024 23:45:39 +0200 Subject: [PATCH 017/114] Update main logo --- front/index.html | 2 +- front/src/Apps/common/Components/layout/Header.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/front/index.html b/front/index.html index 177299f9ae..a2acc7c7b9 100644 --- a/front/index.html +++ b/front/index.html @@ -24,7 +24,7 @@ de la transition
    - écologique + écologique, +
    + de l'énergie, du climat,
    + et de la prévention
    + des risques

    From 363c16b095910f8a0b77ace3add8dea28ca62dc4 Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Wed, 9 Oct 2024 23:53:04 +0200 Subject: [PATCH 018/114] Remove obsolete isRegistreNational field --- Changelog.md | 4 +++ .../scripts/createGovernementAccounts.ts | 33 ------------------- .../migration.sql | 8 +++++ libs/back/prisma/src/schema.prisma | 2 -- 4 files changed, 12 insertions(+), 35 deletions(-) delete mode 100644 back/prisma/scripts/createGovernementAccounts.ts create mode 100644 libs/back/prisma/src/migrations/20241009214940_remove_is_registre_national/migration.sql diff --git a/Changelog.md b/Changelog.md index c1499e108c..77cc9b0b5c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -15,6 +15,10 @@ et le projet suit un schéma de versionning inspiré de [Calendar Versioning](ht - Documentation API Developers : Page Not Found, si on n'y accède pas via l'arborescence [PR 3621](https://github.com/MTES-MCT/trackdechets/pull/3621) +#### :house: Interne + +- Suppression du champ isRegistreNational [PR 3652](https://github.com/MTES-MCT/trackdechets/pull/3652) + # [2024.9.1] 24/09/2024 #### :rocket: Nouvelles fonctionnalités diff --git a/back/prisma/scripts/createGovernementAccounts.ts b/back/prisma/scripts/createGovernementAccounts.ts deleted file mode 100644 index 1a7a7c567d..0000000000 --- a/back/prisma/scripts/createGovernementAccounts.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { prisma } from "@td/prisma"; -import { registerUpdater, Updater } from "./helper/helper"; - -@registerUpdater( - "Create government accounts", - "Create government accounts", - false -) -export class CreateGovernementAccounts implements Updater { - async run() { - const registreNationalUsers = await prisma.user.findMany({ - where: { isRegistreNational: true } - }); - for (const user of registreNationalUsers) { - if (!user.governmentAccountId) { - const accountName = user.email.includes("gerep") ? "GEREP" : "RNDTS"; - await prisma.user.update({ - where: { id: user.id }, - data: { - governmentAccount: { - create: { - permissions: ["REGISTRY_CAN_READ_ALL"], - name: accountName, - authorizedOrgIds: ["ALL"], - authorizedIPs: [] // à compléter à la mano - } - } - } - }); - } - } - } -} diff --git a/libs/back/prisma/src/migrations/20241009214940_remove_is_registre_national/migration.sql b/libs/back/prisma/src/migrations/20241009214940_remove_is_registre_national/migration.sql new file mode 100644 index 0000000000..e312554734 --- /dev/null +++ b/libs/back/prisma/src/migrations/20241009214940_remove_is_registre_national/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `isRegistreNational` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "isRegistreNational"; diff --git a/libs/back/prisma/src/schema.prisma b/libs/back/prisma/src/schema.prisma index 89c5e6859a..64202fe3b8 100644 --- a/libs/back/prisma/src/schema.prisma +++ b/libs/back/prisma/src/schema.prisma @@ -891,8 +891,6 @@ model User { firstAssociationDate DateTime? @db.Timestamptz(6) isActive Boolean? @default(false) isAdmin Boolean @default(false) - // TODO champ à supprimer suite à la création des comptes gouvernementaux - isRegistreNational Boolean @default(false) governmentAccountId String? @unique governmentAccount GovernmentAccount? @relation(fields: [governmentAccountId], references: [id]) AccessToken AccessToken[] From 061237cd8526890a8a8d41694f8d6d5866878b96 Mon Sep 17 00:00:00 2001 From: JulianaJM <37509748+JulianaJM@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:12:47 +0200 Subject: [PATCH 019/114] =?UTF-8?q?[tra-15071]Enrichir=20les=20textes=20po?= =?UTF-8?q?ur=20le=20lecteur=20d'ecran=20sur=20les=20ic=C3=B4nes=20du=20ta?= =?UTF-8?q?bleau=20de=20bord=20(#3649)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [tra-15071]Enrichir les textes pour le lecteur d'ecran sur les icônes du tableau de bord * fix e2e --- e2e/src/utils/bsvhu.ts | 10 ++++--- .../Dashboard/Components/Actors/Actors.tsx | 20 +++++++++++--- .../BsdAdditionalActionsButton.tsx | 4 ++- .../Dashboard/Components/BsdCard/BsdCard.tsx | 3 +++ .../Components/InfoWithIcon/InfoWithIcon.tsx | 26 ++++++++++++++++++- .../Components/WasteDetails/WasteDetails.tsx | 26 +++++++++++++++++-- .../Components/Filters/AdvancedFilters.tsx | 1 + .../common/Components/Filters/FilterLine.tsx | 18 ++++++++++--- .../Apps/common/Components/Select/Select.tsx | 14 ++++++---- .../SelectWithSubOptions.tsx | 1 + .../wordings/dashboard/wordingsDashboard.ts | 2 +- 11 files changed, 104 insertions(+), 21 deletions(-) diff --git a/e2e/src/utils/bsvhu.ts b/e2e/src/utils/bsvhu.ts index 271add499d..423848c152 100644 --- a/e2e/src/utils/bsvhu.ts +++ b/e2e/src/utils/bsvhu.ts @@ -343,17 +343,21 @@ export const verifyCardData = async ( .locator(".actors__label") .first() .getAttribute("aria-label"); - await expect(emitterName).toEqual(emitter.name); + await expect(emitterName).toEqual(`Expédition du bordereau ${emitter.name}`); const transporterName = await vhuDiv .locator(".actors__label") .nth(1) .getAttribute("aria-label"); - await expect(transporterName).toEqual(transporter.name); + await expect(transporterName).toEqual( + `Transporteur visé sur le bordereau ${transporter.name}` + ); const destinationName = await vhuDiv .locator(".actors__label") .nth(2) .getAttribute("aria-label"); - await expect(destinationName).toEqual(destination.name); + await expect(destinationName).toEqual( + `Destination du bordereau ${destination.name}` + ); // Primary button await expect(vhuDiv.getByRole("button").getByText("Publier")).toBeVisible(); diff --git a/front/src/Apps/Dashboard/Components/Actors/Actors.tsx b/front/src/Apps/Dashboard/Components/Actors/Actors.tsx index d56297867a..558394e903 100644 --- a/front/src/Apps/Dashboard/Components/Actors/Actors.tsx +++ b/front/src/Apps/Dashboard/Components/Actors/Actors.tsx @@ -33,7 +33,10 @@ function Actors({ {emitterName && (
    -

    +

    {split(emitterName).firstPart} @@ -48,7 +51,10 @@ function Actors({ {workerCompanyName && (

    -

    +

    {split(workerCompanyName).firstPart} @@ -63,7 +69,10 @@ function Actors({ {transporterName && (

    -

    +

    {split(transporterName).firstPart} @@ -78,7 +87,10 @@ function Actors({ {destinationName && (

    -

    +

    {split(destinationName).firstPart} diff --git a/front/src/Apps/Dashboard/Components/BsdAdditionalActionsButton/BsdAdditionalActionsButton.tsx b/front/src/Apps/Dashboard/Components/BsdAdditionalActionsButton/BsdAdditionalActionsButton.tsx index 6135e65fa4..89f540d805 100644 --- a/front/src/Apps/Dashboard/Components/BsdAdditionalActionsButton/BsdAdditionalActionsButton.tsx +++ b/front/src/Apps/Dashboard/Components/BsdAdditionalActionsButton/BsdAdditionalActionsButton.tsx @@ -155,7 +155,9 @@ function BsdAdditionalActionsButton({ onClick={toggleMenu} > - {isOpen ? "fermer menu actions" : "ouvrir menu actions"} + {isOpen + ? `fermer le menu secondaire du bordereau numéro ${bsd.readableid}` + : `ouvrir le menu secondaire du bordereau numéro ${bsd.readableid}`}

    diff --git a/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx b/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx index bb7f4e24b8..ca24fc505b 100644 --- a/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx +++ b/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx @@ -465,6 +465,9 @@ function BsdCard({ onClick={handleValidationClick} > {ctaPrimaryLabel} + + bordereau numéro {bsdDisplay.readableid} + )} diff --git a/front/src/Apps/Dashboard/Components/InfoWithIcon/InfoWithIcon.tsx b/front/src/Apps/Dashboard/Components/InfoWithIcon/InfoWithIcon.tsx index b371b631d1..00bf680711 100644 --- a/front/src/Apps/Dashboard/Components/InfoWithIcon/InfoWithIcon.tsx +++ b/front/src/Apps/Dashboard/Components/InfoWithIcon/InfoWithIcon.tsx @@ -24,9 +24,25 @@ function InfoWithIcon({ [] ); + const displayAlternativeText = () => { + switch (labelCode) { + case InfoIconCode.EcoOrganism: + return "Éco-organisme visé sur le bordereau"; + case InfoIconCode.PickupSite: + return "Nom de l'adresse de chantier"; + case InfoIconCode.CustomInfo: + return "Champs libres ajoutés sur le bordereau"; + case InfoIconCode.TransporterNumberPlate: + return "Immatriculation du transporteur"; + default: + return ""; + } + }; + const customInfo = editableInfos?.customInfo || "-"; return !hasEditableInfos ? (

    + {displayAlternativeText()} {!info ? labelValue : `${labelValue} ${info}`}

    ) : ( @@ -38,12 +54,20 @@ function InfoWithIcon({ title={labelValue} >

    + + Modifier le champ libre et l'immatriculation + {labelCode === InfoIconCode.CustomInfo ? customInfo : formatTranporterPlates(editableInfos?.transporterNumberPlate)}

    {labelCode === InfoIconCode.TransporterNumberPlate && !isDisabled && ( - + <> + + Modifier le champ libre et l'immatriculation + + + )} ); diff --git a/front/src/Apps/Dashboard/Components/WasteDetails/WasteDetails.tsx b/front/src/Apps/Dashboard/Components/WasteDetails/WasteDetails.tsx index 45f96454e6..f718e3744a 100644 --- a/front/src/Apps/Dashboard/Components/WasteDetails/WasteDetails.tsx +++ b/front/src/Apps/Dashboard/Components/WasteDetails/WasteDetails.tsx @@ -36,9 +36,30 @@ function WasteDetails({ break; } }; + const displayIconWasteAlternative = () => { + switch (wasteType) { + case BsdType.Bsdd: + return "Bordereau de Suivi de Déchets Dangereux ou Non Dangereux"; + case BsdType.Bsda: + return "Bordereau de Suivi de Déchets d'Amiante"; + case BsdType.Bsvhu: + return "Bordereau de Suivi de Véhicules Hors d'Usage"; + case BsdType.Bsdasri: + return "Bordereau de Suivi de Déchets d'Activités de Soins à Risque Infectieux"; + case BsdType.Bsff: + return "Bordereau de Suivi de Déchets de Fluides Frigorigènes"; + case BsdType.Bspaoh: + return "Bordereau de Suivi de Pièces Anatomiques d'Origine Humaine"; + default: + break; + } + }; return (
    -
    {displayIconWaste()}
    +
    + {displayIconWaste()} + {displayIconWasteAlternative()} +

    {code}

    {name}

    @@ -51,7 +72,8 @@ function WasteDetails({ /> )}

    - {weight} + + Poids {weight}

    )} diff --git a/front/src/Apps/common/Components/Filters/AdvancedFilters.tsx b/front/src/Apps/common/Components/Filters/AdvancedFilters.tsx index 5274c050a2..6ebe2da340 100644 --- a/front/src/Apps/common/Components/Filters/AdvancedFilters.tsx +++ b/front/src/Apps/common/Components/Filters/AdvancedFilters.tsx @@ -276,6 +276,7 @@ const AdvancedFilters = ({ onAddFilterType={onAddFilterType} onRemoveFilterType={onRemoveFilterType} value={filter.name} + srLabel={filter.label} disabledSelect={true} isMaxLine={hasReachMaxFilter} isCurrentLine={i === filterSelectedList.length - 1} diff --git a/front/src/Apps/common/Components/Filters/FilterLine.tsx b/front/src/Apps/common/Components/Filters/FilterLine.tsx index ca1f4dcf02..b58e0a94ee 100644 --- a/front/src/Apps/common/Components/Filters/FilterLine.tsx +++ b/front/src/Apps/common/Components/Filters/FilterLine.tsx @@ -19,6 +19,7 @@ interface FilterLineProps { ) => void; disabledSelect?: boolean; value?: string; + srLabel?: string; children?: ReactElement; isMaxLine: boolean; isCurrentLine: boolean; @@ -33,7 +34,8 @@ const FilterLine = ({ value, children, isMaxLine, - isCurrentLine + isCurrentLine, + srLabel = "" }: FilterLineProps) => (
    @@ -54,18 +56,26 @@ const FilterLine = ({ onClick={e => onRemoveFilterType(e, value)} id={`${value}_delete_btn`} > - - {sr_btn_delete_filter_line} + - + {`${sr_btn_delete_filter_line} ${srLabel}`} {isCurrentLine && ( )}
    diff --git a/front/src/Apps/common/Components/Select/Select.tsx b/front/src/Apps/common/Components/Select/Select.tsx index af15af098e..a21f2aa490 100644 --- a/front/src/Apps/common/Components/Select/Select.tsx +++ b/front/src/Apps/common/Components/Select/Select.tsx @@ -43,11 +43,15 @@ const Select = React.forwardRef( if (hasSubOptions) { return ( - + //@ts-ignore + // quick fix pour réparer la nav clavier avec ce type de select +
    + +
    ); } else if (isMultiple) { return ( diff --git a/front/src/Apps/common/Components/SelectWithSubOptions/SelectWithSubOptions.tsx b/front/src/Apps/common/Components/SelectWithSubOptions/SelectWithSubOptions.tsx index 8f185e9822..aeb6119457 100644 --- a/front/src/Apps/common/Components/SelectWithSubOptions/SelectWithSubOptions.tsx +++ b/front/src/Apps/common/Components/SelectWithSubOptions/SelectWithSubOptions.tsx @@ -103,6 +103,7 @@ const SelectWithSubOptions = ({ className={`fr-select select ${isOpen ? "select-open" : ""}`} onClick={() => setIsOpen(!isOpen)} onKeyDown={handleKeyDown} + aria-expanded={isOpen} > {getLabel(allOptions, getValuesFromOptions(selected))}
    diff --git a/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts b/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts index 2e727f804d..697050af8d 100644 --- a/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts +++ b/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts @@ -135,7 +135,7 @@ export const bsd_sub_type_option_synthesis = "Synthèse"; export const filter_type_select_option_placeholder = "Sélectionner une option"; export const max_filter_autorized_label = "Vous avez atteint le nombre de filtres maximum"; -export const sr_btn_delete_filter_line = "supprimer un filtre"; +export const sr_btn_delete_filter_line = "Retirer le filtre"; export const sr_btn_add_filter_line = "ajouter un filtre"; export const multi_select_select_all_label = "Tout sélectionner"; From 65505750b2931c76d14f1a2eb07622f4120ee32f Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Thu, 10 Oct 2024 12:15:48 +0200 Subject: [PATCH 020/114] Add some tests for vhu security code --- .../__tests__/signBsvhu.integration.ts | 226 +++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/back/src/bsvhu/resolvers/mutations/__tests__/signBsvhu.integration.ts b/back/src/bsvhu/resolvers/mutations/__tests__/signBsvhu.integration.ts index b411ef4620..40e23d7e36 100644 --- a/back/src/bsvhu/resolvers/mutations/__tests__/signBsvhu.integration.ts +++ b/back/src/bsvhu/resolvers/mutations/__tests__/signBsvhu.integration.ts @@ -7,7 +7,11 @@ import { } from "../../../../__tests__/factories"; import makeClient from "../../../../__tests__/testClient"; import { bsvhuFactory } from "../../../__tests__/factories.vhu"; -import { companyFactory } from "../../../../__tests__/factories"; +import { + companyFactory, + transporterReceiptFactory +} from "../../../../__tests__/factories"; +import { prisma } from "@td/prisma"; const SIGN_VHU_FORM = ` mutation SignVhuForm($id: ID!, $input: BsvhuSignatureInput!) { @@ -74,6 +78,226 @@ describe("Mutation.Vhu.sign", () => { expect(data.signBsvhu.emitter!.emission!.signature!.author).toBe(user.name); expect(data.signBsvhu.emitter!.emission!.signature!.date).not.toBeNull(); }); + it("should forbid another company to sign EMISSION when security code is not provided", async () => { + const { company: emitterCompany } = await userWithCompanyFactory("MEMBER", { + securityCode: 9421 + }); + const { user, company } = await userWithCompanyFactory("MEMBER"); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanySiret: emitterCompany.siret, + destinationCompanySiret: company.siret + } + }); + + const { mutate } = makeClient(user); + const { errors } = await mutate>( + SIGN_VHU_FORM, + { + variables: { + id: bsvhu.id, + input: { type: "EMISSION", author: user.name } + } + } + ); + expect(errors).toEqual([ + expect.objectContaining({ + message: "Vous ne pouvez pas signer ce bordereau", + extensions: expect.objectContaining({ + code: ErrorCode.FORBIDDEN + }) + }) + ]); + }); + + it("should forbid another company to sign EMISSION when security code is wrong", async () => { + const { company: emitterCompany } = await userWithCompanyFactory("MEMBER", { + securityCode: 9421 + }); + const { user, company } = await userWithCompanyFactory("MEMBER"); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanySiret: emitterCompany.siret, + destinationCompanySiret: company.siret + } + }); + + const { mutate } = makeClient(user); + const { errors } = await mutate>( + SIGN_VHU_FORM, + { + variables: { + id: bsvhu.id, + input: { type: "EMISSION", author: user.name, securityCode: 5555 } + } + } + ); + expect(errors).toEqual([ + expect.objectContaining({ + message: "Le code de signature est invalide.", + extensions: expect.objectContaining({ + code: ErrorCode.FORBIDDEN + }) + }) + ]); + }); + + it("should allow another company to sign EMISSION when security code is provided", async () => { + const { company: emitterCompany } = await userWithCompanyFactory("MEMBER", { + securityCode: 9421 + }); + const { user, company } = await userWithCompanyFactory("MEMBER"); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanySiret: emitterCompany.siret, + destinationCompanySiret: company.siret + } + }); + + const { mutate } = makeClient(user); + const { data } = await mutate>(SIGN_VHU_FORM, { + variables: { + id: bsvhu.id, + input: { type: "EMISSION", author: user.name, securityCode: 9421 } + } + }); + + expect(data.signBsvhu.emitter!.emission!.signature!.author).toBe(user.name); + expect(data.signBsvhu.emitter!.emission!.signature!.date).not.toBeNull(); + }); + + it("should forbid another company to sign TRANSPORT when security code is not provided", async () => { + const { user: emitter, company: emitterCompany } = + await userWithCompanyFactory("MEMBER"); + const { company: transporterCompany } = await userWithCompanyFactory( + "MEMBER", + { + companyTypes: ["TRANSPORTER"], + securityCode: 9421 + } + ); + + await transporterReceiptFactory({ company: transporterCompany }); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanySiret: emitterCompany.siret, + transporterCompanySiret: transporterCompany.siret, + status: "SIGNED_BY_PRODUCER" + } + }); + + const { mutate } = makeClient(emitter); + const { errors } = await mutate>( + SIGN_VHU_FORM, + { + variables: { + id: bsvhu.id, + input: { type: "TRANSPORT", author: emitter.name } + } + } + ); + + const signedBsvhu = await prisma.bsvhu.findUnique({ + where: { id: bsvhu.id } + }); + expect(signedBsvhu?.status).toEqual("SIGNED_BY_PRODUCER"); + + expect(errors).toEqual([ + expect.objectContaining({ + message: "Vous ne pouvez pas signer ce bordereau", + extensions: expect.objectContaining({ + code: ErrorCode.FORBIDDEN + }) + }) + ]); + }); + + it("should forbid another company to sign TRANSPORT when security code is wrong", async () => { + const { user: emitter, company: emitterCompany } = + await userWithCompanyFactory("MEMBER"); + const { company: transporterCompany } = await userWithCompanyFactory( + "MEMBER", + { + companyTypes: ["TRANSPORTER"], + securityCode: 9421 + } + ); + + await transporterReceiptFactory({ company: transporterCompany }); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanySiret: emitterCompany.siret, + transporterCompanySiret: transporterCompany.siret, + status: "SIGNED_BY_PRODUCER" + } + }); + + const { mutate } = makeClient(emitter); + const { errors } = await mutate>( + SIGN_VHU_FORM, + { + variables: { + id: bsvhu.id, + input: { type: "TRANSPORT", author: emitter.name, securityCode: 3333 } + } + } + ); + + const signedBsvhu = await prisma.bsvhu.findUnique({ + where: { id: bsvhu.id } + }); + expect(signedBsvhu?.status).toEqual("SIGNED_BY_PRODUCER"); + + expect(errors).toEqual([ + expect.objectContaining({ + message: "Le code de signature est invalide.", + extensions: expect.objectContaining({ + code: ErrorCode.FORBIDDEN + }) + }) + ]); + }); + + it("should allow another company to sign TRANSPORT when security code is provided", async () => { + const { user: emitter, company: emitterCompany } = + await userWithCompanyFactory("MEMBER", {}); + const { company: transporterCompany } = await userWithCompanyFactory( + "MEMBER", + { + companyTypes: ["TRANSPORTER"], + securityCode: 9421 + } + ); + + await transporterReceiptFactory({ company: transporterCompany }); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanySiret: emitterCompany.siret, + transporterCompanySiret: transporterCompany.siret, + status: "SIGNED_BY_PRODUCER" + } + }); + + const { mutate } = makeClient(emitter); + const { data } = await mutate>(SIGN_VHU_FORM, { + variables: { + id: bsvhu.id, + input: { type: "TRANSPORT", author: emitter.name, securityCode: 9421 } + } + }); + + const signedBsvhu = await prisma.bsvhu.findUnique({ + where: { id: bsvhu.id } + }); + expect(signedBsvhu?.status).toEqual("SENT"); + + expect(data.signBsvhu.transporter!.transport!.signature!.author).toBe( + emitter.name + ); + expect( + data.signBsvhu.transporter!.transport!.signature!.date + ).not.toBeNull(); + }); it("should use the provided date for the signature if given", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); From f1e43c1ae69e01225e1b0d1f7fccac29fa7e5489 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Mon, 23 Sep 2024 10:09:18 +0200 Subject: [PATCH 021/114] feat(sirene) : migration vers le nouveau portail API de l'INSEE --- .env.model | 8 ++++++-- back/src/companies/sirene/insee/client.ts | 3 ++- back/src/companies/sirene/insee/token.ts | 22 +++++++++++++++++----- libs/back/env/src/index.ts | 5 ++++- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.env.model b/.env.model index 5bae6ec850..758f523898 100644 --- a/.env.model +++ b/.env.model @@ -54,8 +54,12 @@ NGINX_NETWORK_MODE=host|bridge # Secret key to hash token API_TOKEN_SECRET=********* -# Secret for INSEE SIRENE API -INSEE_SECRET=********* +# ID et SECRET de l'application sur le portail https://portail-api.insee.fr/ +INSEE_CLIENT_ID=********* +INSEE_CLIENT_SECRET=********* +# Identifiant et mot de passe de https://portail-api.insee.fr/ +INSEE_USERNAME=************* +INSEE_PASSWORD=************* # Permet de court-circuiter l'API INSEE en cas de maintenance INSEE_MAINTENANCE=true|false diff --git a/back/src/companies/sirene/insee/client.ts b/back/src/companies/sirene/insee/client.ts index 03f207e674..b8c026e018 100644 --- a/back/src/companies/sirene/insee/client.ts +++ b/back/src/companies/sirene/insee/client.ts @@ -16,7 +16,8 @@ import { StatutDiffusionEtablissement } from "../../../generated/graphql/types"; -const SIRENE_API_BASE_URL = "https://api.insee.fr/entreprises/sirene/V3.11"; +const SIRENE_API_BASE_URL = "https://api.insee.fr/api-sirene/prive/3.11"; + export const SEARCH_COMPANIES_MAX_SIZE = 20; /** diff --git a/back/src/companies/sirene/insee/token.ts b/back/src/companies/sirene/insee/token.ts index 9afd1d968e..5d4318e175 100644 --- a/back/src/companies/sirene/insee/token.ts +++ b/back/src/companies/sirene/insee/token.ts @@ -1,34 +1,46 @@ import axios, { AxiosResponse, AxiosRequestConfig } from "axios"; import { redisClient, setInCache } from "../../../common/redis"; -const SIRENE_API_TOKEN_URL = "https://api.insee.fr/token"; +const SIRENE_API_TOKEN_URL = + "https://auth.insee.net/auth/realms/apim-gravitee/protocol/openid-connect/token"; export const INSEE_TOKEN_KEY = "insee_token"; -const { INSEE_SECRET } = process.env; +const { INSEE_CLIENT_SECRET, INSEE_CLIENT_ID, INSEE_USERNAME, INSEE_PASSWORD } = + process.env; /** * Generates INSEE Sirene API token */ async function generateToken(): Promise { const headers = { - Authorization: `Basic ${INSEE_SECRET}`, "Content-Type": "application/x-www-form-urlencoded" }; + // Création des paramètres de la requête avec URLSearchParams + const params = new URLSearchParams(); + params.append("grant_type", "password"); + params.append("client_id", INSEE_CLIENT_ID); + params.append("client_secret", INSEE_CLIENT_SECRET); + params.append("username", INSEE_USERNAME); + params.append("password", INSEE_PASSWORD); + const response = await axios.post<{ access_token: string }>( SIRENE_API_TOKEN_URL, - "grant_type=client_credentials", + params, { headers } ); return response.data.access_token; } +// Le token expire au bout de 300 secondes +const INSEE_TOKEN_EX = 300; + /** * Generates a token and save it to redis cache */ async function renewToken(): Promise { const token = await generateToken(); - await setInCache(INSEE_TOKEN_KEY, token); + await setInCache(INSEE_TOKEN_KEY, token, { EX: INSEE_TOKEN_EX }); } /** diff --git a/libs/back/env/src/index.ts b/libs/back/env/src/index.ts index aaa8bbb182..a15d97c0b9 100644 --- a/libs/back/env/src/index.ts +++ b/libs/back/env/src/index.ts @@ -111,7 +111,10 @@ export const schema = z.object({ TD_COMPANY_ELASTICSEARCH_IGNORE_SSL: z.string().optional().refine(isBoolean), VERIFY_COMPANY: z.string().refine(isBoolean), ALLOW_TEST_COMPANY: z.string().refine(isBoolean), - INSEE_SECRET: z.string(), + INSEE_CLIENT_ID: z.string(), + INSEE_CLIENT_SECRET: z.string(), + INSEE_USERNAME: z.string(), + INSEE_PASSWORD: z.string(), // ------- // S3 S3_ENDPOINT: z.string(), From 772e20aa309286ac4fed47a18362b2ab95785d4d Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Mon, 23 Sep 2024 11:48:54 +0200 Subject: [PATCH 022/114] fix : e2e config --- .github/workflows/e2e.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3b009cc015..36350560fc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -114,7 +114,11 @@ env: QUEUE_MONITOR_TOKEN: token DATABASE_URL: "postgresql://trackdechets:password@postgres:5432/e2e?schema=default$default" API_TOKEN_SECRET: any_secret - INSEE_SECRET: unset + INSEE_CLIENT_ID: unset + INSEE_CLIENT_SECRET: unset + INSEE_USERNAME: unset + INSEE_PASSWORD: unset + SESSION_SECRET: any_secret SESSION_NAME: connect.sid SESSION_COOKIE_HOST: trackdechets.local From 61d558a57072536659d41bfdb6a31bec1a784df4 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Mon, 23 Sep 2024 11:52:12 +0200 Subject: [PATCH 023/114] mod(terraform) : update env model --- scripts/terraform/terraform.tfvars.model | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/terraform/terraform.tfvars.model b/scripts/terraform/terraform.tfvars.model index 440df55035..393b61fee9 100644 --- a/scripts/terraform/terraform.tfvars.model +++ b/scripts/terraform/terraform.tfvars.model @@ -1,7 +1,8 @@ api_environment_secrets = { API_TOKEN_SECRET="XXXXXXXX" SESSION_SECRET="XXXXXXXX" - INSEE_SECRET="XXXXXXXX" + INSEE_CLIENT_SECRET="XXXXXXXX" + INSEE_CLIENT_PASSWORD="XXXXXXXX" TD_COMPANY_ELASTICSEARCH_CACERT=< Date: Thu, 10 Oct 2024 20:26:49 +0200 Subject: [PATCH 024/114] chore(Changelog) --- Changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Changelog.md b/Changelog.md index c1499e108c..900e4c139d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -15,6 +15,10 @@ et le projet suit un schéma de versionning inspiré de [Calendar Versioning](ht - Documentation API Developers : Page Not Found, si on n'y accède pas via l'arborescence [PR 3621](https://github.com/MTES-MCT/trackdechets/pull/3621) +#### :house: Interne + +- Migration vers le nouveau portail API de l'INSEE [PR 3602](https://github.com/MTES-MCT/trackdechets/pull/3602) + # [2024.9.1] 24/09/2024 #### :rocket: Nouvelles fonctionnalités From c7a6de4ca60b61e86f80cdba777ff22eb826489b Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Tue, 24 Sep 2024 16:58:26 +0200 Subject: [PATCH 025/114] fix(bsff validation) : ensure persisted packagings are in the same order as data sent by the UI --- back/src/bsffs/database.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/back/src/bsffs/database.ts b/back/src/bsffs/database.ts index d139f870a4..582be65c29 100644 --- a/back/src/bsffs/database.ts +++ b/back/src/bsffs/database.ts @@ -44,7 +44,10 @@ export async function getBsffOrNotFound(where: Prisma.BsffWhereUniqueInput) { include: { ...BsffWithTransportersInclude, ...BsffWithFicheInterventionInclude, - packagings: { include: { previousPackagings: true } } + packagings: { + include: { previousPackagings: true }, + orderBy: { numero: "asc" } + } } }); From d199eaccb84ab751c8e5dba6b8d39bdefb216e3b Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Wed, 25 Sep 2024 09:45:21 +0200 Subject: [PATCH 026/114] test(updateBsff) : add a test that resend packagings data in the same order as received by the UI --- back/src/bsffs/__tests__/factories.ts | 2 +- back/src/bsffs/fragments.ts | 1 + .../__tests__/updateBsff.integration.ts | 87 ++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/back/src/bsffs/__tests__/factories.ts b/back/src/bsffs/__tests__/factories.ts index c73efd45cc..1876854842 100644 --- a/back/src/bsffs/__tests__/factories.ts +++ b/back/src/bsffs/__tests__/factories.ts @@ -376,7 +376,7 @@ export function createFicheIntervention({ type AddBsffTransporterOpt = { bsffId: string; transporter: UserWithCompany; - opt: { transporterTransportPlates: string[] }; + opt?: { transporterTransportPlates: string[] }; }; export const addBsffTransporter = async ({ diff --git a/back/src/bsffs/fragments.ts b/back/src/bsffs/fragments.ts index c9d8964e36..65f9acc5bd 100644 --- a/back/src/bsffs/fragments.ts +++ b/back/src/bsffs/fragments.ts @@ -60,6 +60,7 @@ export const fullBsff = gql` } } packagings { + type name volume numero diff --git a/back/src/bsffs/resolvers/mutations/__tests__/updateBsff.integration.ts b/back/src/bsffs/resolvers/mutations/__tests__/updateBsff.integration.ts index bb9bdcd3a8..3d5b616f3a 100644 --- a/back/src/bsffs/resolvers/mutations/__tests__/updateBsff.integration.ts +++ b/back/src/bsffs/resolvers/mutations/__tests__/updateBsff.integration.ts @@ -2,7 +2,8 @@ import { UserRole, BsffType, BsffStatus, - BsffPackagingType + BsffPackagingType, + Prisma } from "@prisma/client"; import { gql } from "graphql-tag"; import { resetDatabase } from "../../../../../integration-tests/helper"; @@ -40,6 +41,7 @@ import { getFirstTransporterSync, getTransportersSync } from "../../../database"; +import { Query, QueryBsffArgs } from "@td/codegen-ui"; export const UPDATE_BSFF = gql` mutation UpdateBsff($id: ID!, $input: BsffInput!) { @@ -50,6 +52,15 @@ export const UPDATE_BSFF = gql` ${fullBsff} `; +const BSFF = gql` + query Bsff($id: ID!) { + bsff(id: $id) { + ...FullBsff + } + } + ${fullBsff} +`; + describe("Mutation.updateBsff", () => { afterEach(resetDatabase); @@ -3007,4 +3018,78 @@ describe("Mutation.updateBsff", () => { }) ]); }); + + it( + "should be possible to resend same packagings data after emitter's signature" + + "in the same order as it is received from the query bsff { packagings }", + async () => { + const emitter = await userWithCompanyFactory("ADMIN"); + const destination = await userWithCompanyFactory("ADMIN"); + + const bsff = await createBsffAfterEmission({ + emitter, + destination + }); + + const packagingsData: Prisma.BsffPackagingUncheckedCreateInput[] = [ + { + bsffId: bsff.id, + type: BsffPackagingType.BOUTEILLE, + weight: 1, + volume: 1, + numero: "C", + emissionNumero: "C" + }, + { + bsffId: bsff.id, + type: BsffPackagingType.BOUTEILLE, + weight: 1, + volume: 1, + numero: "B", + emissionNumero: "B" + }, + { + bsffId: bsff.id, + type: BsffPackagingType.BOUTEILLE, + weight: 1, + volume: 1, + numero: "A", + emissionNumero: "A" + } + ]; + + for (const packagingData of packagingsData) { + await prisma.bsffPackaging.create({ data: packagingData }); + } + + const { query, mutate } = makeClient(destination.user); + + const { data: bsffData } = await query< + Pick, + QueryBsffArgs + >(BSFF, { + variables: { id: bsff.id } + }); + + const { errors } = await mutate< + Pick, + MutationUpdateBsffArgs + >(UPDATE_BSFF, { + variables: { + id: bsff.id, + input: { + packagings: bsffData.bsff.packagings.map(p => ({ + type: p.type, + weight: p.weight, + volume: p.volume, + numero: p.numero, + other: p.other + })) + } + } + }); + + expect(errors).toBeUndefined(); + } + ); }); From ef9b60860817d6c075d0a1be7fcfb2278f2c74e1 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Thu, 10 Oct 2024 20:34:33 +0200 Subject: [PATCH 027/114] chore(changelog) --- Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Changelog.md b/Changelog.md index c1499e108c..e297c0b351 100644 --- a/Changelog.md +++ b/Changelog.md @@ -14,6 +14,7 @@ et le projet suit un schéma de versionning inspiré de [Calendar Versioning](ht #### :bug: Corrections de bugs - Documentation API Developers : Page Not Found, si on n'y accède pas via l'arborescence [PR 3621](https://github.com/MTES-MCT/trackdechets/pull/3621) +- Ne pas apporter automatiquement de modification sur la liste des contenants lorsque je procède à une modification transporteur et que le BSFF est au statut SENT [PR 3615](https://github.com/MTES-MCT/trackdechets/pull/3615) # [2024.9.1] 24/09/2024 From b7727d7990478167dc89307bc28c55d65606b256 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Thu, 26 Sep 2024 10:35:04 +0200 Subject: [PATCH 028/114] doc : add search with algolia --- apps/doc/docusaurus.config.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/doc/docusaurus.config.js b/apps/doc/docusaurus.config.js index caac9d64c6..5fd08c1a9d 100644 --- a/apps/doc/docusaurus.config.js +++ b/apps/doc/docusaurus.config.js @@ -19,6 +19,13 @@ module.exports = { colorMode: { disableSwitch: true }, + algolia: { + // L'ID de l'application fourni par Algolia + appId: '2UK4UF9U8K', + // Clé d'API publique : il est possible de la committer en toute sécurité + apiKey: 'cd706026bcf0dd0df345c4a4f450f844', + indexName: 'trackdechets', + }, navbar: { title: "Trackdéchets", logo: { From 216dc79aee967ddccc4e05d6c8f66758b29c98f2 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Thu, 10 Oct 2024 20:36:04 +0200 Subject: [PATCH 029/114] chore(changelog) --- Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Changelog.md b/Changelog.md index c1499e108c..38dc2ef020 100644 --- a/Changelog.md +++ b/Changelog.md @@ -10,6 +10,7 @@ et le projet suit un schéma de versionning inspiré de [Calendar Versioning](ht #### :rocket: Nouvelles fonctionnalités - Ajout d'Eco-organisme sur BSVHU [PR 3619](https://github.com/MTES-MCT/trackdechets/pull/3619) +- Ajoute d'un moteur de recherche sur la documentation développeurs [PR 3622](https://github.com/MTES-MCT/trackdechets/pull/3622) #### :bug: Corrections de bugs From 5f1e9d63d7ab9a3dfb6cbe28c84244bf7e141ec0 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Wed, 25 Sep 2024 11:03:25 +0200 Subject: [PATCH 030/114] feat(FicheIntervention) : update CERFA name and add link --- front/src/form/bsff/FicheInterventionList.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/front/src/form/bsff/FicheInterventionList.tsx b/front/src/form/bsff/FicheInterventionList.tsx index f9f639d060..206fb74e79 100644 --- a/front/src/form/bsff/FicheInterventionList.tsx +++ b/front/src/form/bsff/FicheInterventionList.tsx @@ -105,10 +105,19 @@ function AddFicheInterventionModal({

    Ajouter une fiche d'intervention

    - Reportez ici certaines des informations d'une fiche d'intervention - (formulaire 15497*03) dans Trackdéchets. L'ajout d'une fiche - d'intervention permet d'identifier le détenteur d'un équipement afin que - celui-ci ait accès au suivi du bordereau. + Reportez ici certaines des informations d'une fiche d'intervention ( + + formulaire 15497*04 + + ) dans Trackdéchets. L'ajout d'une fiche d'intervention permet + d'identifier le détenteur d'un équipement afin que celui-ci ait accès au + suivi du bordereau.
    From c0c77b2e8c88a9c10855a07c591ddc6c3c5707a6 Mon Sep 17 00:00:00 2001 From: Benoit Guigal Date: Thu, 10 Oct 2024 20:37:52 +0200 Subject: [PATCH 031/114] chore(changelog) --- Changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Changelog.md b/Changelog.md index 900e4c139d..5846cdcf64 100644 --- a/Changelog.md +++ b/Changelog.md @@ -11,6 +11,10 @@ et le projet suit un schéma de versionning inspiré de [Calendar Versioning](ht - Ajout d'Eco-organisme sur BSVHU [PR 3619](https://github.com/MTES-MCT/trackdechets/pull/3619) +#### :nail_care: Améliorations + +- Changer la référence du cerfa lors de l'ajout d'une fiche d'intervention [PR 3616](https://github.com/MTES-MCT/trackdechets/pull/3616) + #### :bug: Corrections de bugs - Documentation API Developers : Page Not Found, si on n'y accède pas via l'arborescence [PR 3621](https://github.com/MTES-MCT/trackdechets/pull/3621) From 6a809b1da73f2c75402c4d4880d50cf39062a768 Mon Sep 17 00:00:00 2001 From: GaelFerrand <45355989+GaelFerrand@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:30:34 +0200 Subject: [PATCH 032/114] =?UTF-8?q?[TRA-14824]=20Cr=C3=A9ation=20d'une=20d?= =?UTF-8?q?emande=20de=20d=C3=A9l=C3=A9gation=20de=20d=C3=A9claration=20RN?= =?UTF-8?q?DTS=20(back)=20(#3561)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implemented create endpoint * feat: added endpoint rndtsDeclarationDelegation * refacto: improved condition check on overlap + tests * refacto * feat: checking that delegator & delegate are different * feat: small opti on queries * refacto: making endpoints session only * feat: removed isDeleted, using isRevoked instead + added status sub-resolver * feat: cleaned up dates to midnight * feat: huge refactoring, nesting companies within delegation + started revoke endpoint * test: added test for revocation * fix: fixed tests * feat: added endpoints delegationS * fix: fixes & refacto * fix: fixed test * fix: moved delegate & delegator to subResolvers + dates fixing to zod only * fix: changing where.id to where.orgId * fix: using .nullish() instead of .optional() * feat: added prisma migration script * feat: changed pagination args * feat: trying to fix test * fix: trying to fix tests * fix: fixing typing issues due to sub-resolvers * fix: let's chill on the capslock * fix: trying to fix subresolvers (isDormant error) * feat: added givenName to CompanyPublic * feat: sending email on creation. Needing content though * feat: added email content. Still some details missing * fix: lint * fix: fixed email with links * fix: fixed permissions using can() * fix: renamed rndtsDeclarationDelegation -> registryDelegation * feat: re-generated migration script with new name registryDelegation * feat: added events in repository methods * feat: added endDate in mail --- back/src/__tests__/factories.ts | 21 + .../typeDefs/company.objects.graphql | 4 +- back/src/permissions/permissions.ts | 10 +- .../registryDelegation/__tests__/factories.ts | 47 ++ back/src/registryDelegation/permissions.ts | 97 +++ .../registryDelegation/repository/index.ts | 37 ++ .../repository/registryDelegation/count.ts | 14 + .../repository/registryDelegation/create.ts | 34 + .../registryDelegation/findFirst.ts | 18 + .../repository/registryDelegation/findMany.ts | 26 + .../repository/registryDelegation/update.ts | 36 ++ .../registryDelegation/repository/types.ts | 16 + .../registryDelegation/resolvers/Mutation.ts | 15 + .../src/registryDelegation/resolvers/Query.ts | 15 + .../resolvers/__tests__/status.integration.ts | 73 +++ .../src/registryDelegation/resolvers/index.ts | 9 + .../createRegistryDelegation.integration.ts | 598 ++++++++++++++++++ .../revokeRegistryDelegation.integration.ts | 175 +++++ .../mutations/createRegistryDelegation.ts | 63 ++ .../mutations/revokeRegistryDelegation.ts | 41 ++ .../utils/createRegistryDelegation.utils.ts | 110 ++++ .../utils/revokeRegistryDelegation.utils.ts | 13 + .../registryDelegation.integration.ts | 116 ++++ .../registryDelegations.integration.ts | 298 +++++++++ .../resolvers/queries/registryDelegation.ts | 35 + .../resolvers/queries/registryDelegations.ts | 44 ++ .../utils/registryDelegations.utils.ts | 52 ++ .../subResolvers/RegistryDelegation.ts | 12 + .../resolvers/subResolvers/delegate.ts | 19 + .../resolvers/subResolvers/delegator.ts | 19 + .../resolvers/subResolvers/status.ts | 14 + .../registryDelegation/resolvers/typing.ts | 29 + .../src/registryDelegation/resolvers/utils.ts | 95 +++ .../private/registryDelegation.enums.graphql | 19 + .../private/registryDelegation.inputs.graphql | 39 ++ .../registryDelegation.mutations.graphql | 15 + .../registryDelegation.objects.graphql | 71 +++ .../registryDelegation.queries.graphql | 15 + .../validation/__tests__/index.test.ts | 290 +++++++++ .../registryDelegation/validation/index.ts | 43 ++ .../registryDelegation/validation/schema.ts | 78 +++ back/src/schema.ts | 7 +- back/src/users/typeDefs/user.enums.graphql | 2 + back/src/utils.ts | 22 + libs/back/mail/src/templates/index.ts | 19 +- .../registry-delegation-creation.html | 26 + .../migration.sql | 26 + libs/back/prisma/src/schema.prisma | 29 + 48 files changed, 2899 insertions(+), 7 deletions(-) create mode 100644 back/src/registryDelegation/__tests__/factories.ts create mode 100644 back/src/registryDelegation/permissions.ts create mode 100644 back/src/registryDelegation/repository/index.ts create mode 100644 back/src/registryDelegation/repository/registryDelegation/count.ts create mode 100644 back/src/registryDelegation/repository/registryDelegation/create.ts create mode 100644 back/src/registryDelegation/repository/registryDelegation/findFirst.ts create mode 100644 back/src/registryDelegation/repository/registryDelegation/findMany.ts create mode 100644 back/src/registryDelegation/repository/registryDelegation/update.ts create mode 100644 back/src/registryDelegation/repository/types.ts create mode 100644 back/src/registryDelegation/resolvers/Mutation.ts create mode 100644 back/src/registryDelegation/resolvers/Query.ts create mode 100644 back/src/registryDelegation/resolvers/__tests__/status.integration.ts create mode 100644 back/src/registryDelegation/resolvers/index.ts create mode 100644 back/src/registryDelegation/resolvers/mutations/__tests__/createRegistryDelegation.integration.ts create mode 100644 back/src/registryDelegation/resolvers/mutations/__tests__/revokeRegistryDelegation.integration.ts create mode 100644 back/src/registryDelegation/resolvers/mutations/createRegistryDelegation.ts create mode 100644 back/src/registryDelegation/resolvers/mutations/revokeRegistryDelegation.ts create mode 100644 back/src/registryDelegation/resolvers/mutations/utils/createRegistryDelegation.utils.ts create mode 100644 back/src/registryDelegation/resolvers/mutations/utils/revokeRegistryDelegation.utils.ts create mode 100644 back/src/registryDelegation/resolvers/queries/__tests__/registryDelegation.integration.ts create mode 100644 back/src/registryDelegation/resolvers/queries/__tests__/registryDelegations.integration.ts create mode 100644 back/src/registryDelegation/resolvers/queries/registryDelegation.ts create mode 100644 back/src/registryDelegation/resolvers/queries/registryDelegations.ts create mode 100644 back/src/registryDelegation/resolvers/queries/utils/registryDelegations.utils.ts create mode 100644 back/src/registryDelegation/resolvers/subResolvers/RegistryDelegation.ts create mode 100644 back/src/registryDelegation/resolvers/subResolvers/delegate.ts create mode 100644 back/src/registryDelegation/resolvers/subResolvers/delegator.ts create mode 100644 back/src/registryDelegation/resolvers/subResolvers/status.ts create mode 100644 back/src/registryDelegation/resolvers/typing.ts create mode 100644 back/src/registryDelegation/resolvers/utils.ts create mode 100644 back/src/registryDelegation/typeDefs/private/registryDelegation.enums.graphql create mode 100644 back/src/registryDelegation/typeDefs/private/registryDelegation.inputs.graphql create mode 100644 back/src/registryDelegation/typeDefs/private/registryDelegation.mutations.graphql create mode 100644 back/src/registryDelegation/typeDefs/private/registryDelegation.objects.graphql create mode 100644 back/src/registryDelegation/typeDefs/private/registryDelegation.queries.graphql create mode 100644 back/src/registryDelegation/validation/__tests__/index.test.ts create mode 100644 back/src/registryDelegation/validation/index.ts create mode 100644 back/src/registryDelegation/validation/schema.ts create mode 100644 libs/back/mail/src/templates/mustache/registry-delegation-creation.html create mode 100644 libs/back/prisma/src/migrations/20241010153237_add_registry_delegation/migration.sql diff --git a/back/src/__tests__/factories.ts b/back/src/__tests__/factories.ts index e88bf2f82e..3ebce9e844 100644 --- a/back/src/__tests__/factories.ts +++ b/back/src/__tests__/factories.ts @@ -148,6 +148,27 @@ export const userWithCompanyFactory = async ( return { user, company }; }; +/** + * Create a user and add him to an existing company + */ +export const userInCompany = async ( + role: UserRole = "ADMIN", + companyId: string, + userOpts: Partial = {} +) => { + const user = await userFactory({ + ...userOpts, + companyAssociations: { + create: { + company: { connect: { id: companyId } }, + role: role + } + } + }); + + return user; +}; + export const destinationFactory = async ( companyOpts: Partial = {} ) => { diff --git a/back/src/companies/typeDefs/company.objects.graphql b/back/src/companies/typeDefs/company.objects.graphql index 6fce74cd92..b453a15438 100644 --- a/back/src/companies/typeDefs/company.objects.graphql +++ b/back/src/companies/typeDefs/company.objects.graphql @@ -182,8 +182,10 @@ type CompanyPublic { address: String "Code commune de l'établissement" codeCommune: String - "Nom de l'établissement" + "Raison sociale de l'établissement" name: String + "Nom de l'établissement" + givenName: String "Code NAF" naf: String "Libellé NAF" diff --git a/back/src/permissions/permissions.ts b/back/src/permissions/permissions.ts index c88f82cc16..5ef8e63646 100644 --- a/back/src/permissions/permissions.ts +++ b/back/src/permissions/permissions.ts @@ -33,7 +33,8 @@ export enum Permission { CompanyCanVerify = "CompanyCanVerify", CompanyCanManageSignatureAutomation = "CompanyCanManageSignatureAutomation", CompanyCanManageMembers = "CompanyCanManageMembers", - CompanyCanRenewSecurityCode = "CompanyCanRenewSecurityCode" + CompanyCanRenewSecurityCode = "CompanyCanRenewSecurityCode", + CompanyCanManageRegistryDelegation = "CompanyCanManageRegistryDelegation" } export function toGraphQLPermission(permission: Permission): UserPermission { @@ -57,7 +58,9 @@ export function toGraphQLPermission(permission: Permission): UserPermission { [Permission.CompanyCanManageSignatureAutomation]: "COMPANY_CAN_MANAGE_SIGNATURE_AUTOMATION", [Permission.CompanyCanManageMembers]: "COMPANY_CAN_MANAGE_MEMBERS", - [Permission.CompanyCanRenewSecurityCode]: "COMPANY_CAN_RENEW_SECURITY_CODE" + [Permission.CompanyCanRenewSecurityCode]: "COMPANY_CAN_RENEW_SECURITY_CODE", + [Permission.CompanyCanManageRegistryDelegation]: + "COMPANY_CAN_MANAGE_REGISTRY_DELEAGATION" }; return mapping[permission]; } @@ -105,7 +108,8 @@ const adminPermissions = [ Permission.CompanyCanVerify, Permission.CompanyCanManageSignatureAutomation, Permission.CompanyCanManageMembers, - Permission.CompanyCanRenewSecurityCode + Permission.CompanyCanRenewSecurityCode, + Permission.CompanyCanManageRegistryDelegation ]; export const grants: { [Key in UserRole]: Permission[] } = { diff --git a/back/src/registryDelegation/__tests__/factories.ts b/back/src/registryDelegation/__tests__/factories.ts new file mode 100644 index 0000000000..b3c2d0e7e6 --- /dev/null +++ b/back/src/registryDelegation/__tests__/factories.ts @@ -0,0 +1,47 @@ +import { Company, Prisma } from "@prisma/client"; +import { prisma } from "@td/prisma"; +import { userWithCompanyFactory } from "../../__tests__/factories"; +import { startOfDay } from "../../utils"; + +export const registryDelegationFactory = async ( + opt?: Partial +) => { + const { user: delegateUser, company: delegateCompany } = + await userWithCompanyFactory(); + const { user: delegatorUser, company: delegatorCompany } = + await userWithCompanyFactory(); + + const delegation = await prisma.registryDelegation.create({ + data: { + startDate: startOfDay(new Date()), + delegate: { connect: { id: delegateCompany.id } }, + delegator: { connect: { id: delegatorCompany.id } }, + ...opt + } + }); + + return { + delegation, + delegateUser, + delegateCompany, + delegatorUser, + delegatorCompany + }; +}; + +export const registryDelegationFactoryWithExistingCompanies = async ( + delegate: Company, + delegator: Company, + opt?: Partial +) => { + const delegation = await prisma.registryDelegation.create({ + data: { + startDate: startOfDay(new Date()), + delegate: { connect: { id: delegate.id } }, + delegator: { connect: { id: delegator.id } }, + ...opt + } + }); + + return delegation; +}; diff --git a/back/src/registryDelegation/permissions.ts b/back/src/registryDelegation/permissions.ts new file mode 100644 index 0000000000..77e4bdb4c5 --- /dev/null +++ b/back/src/registryDelegation/permissions.ts @@ -0,0 +1,97 @@ +import { Company, RegistryDelegation, User } from "@prisma/client"; +import { prisma } from "@td/prisma"; +import { ForbiddenError } from "../common/errors"; +import { can, Permission } from "../permissions"; + +export async function checkCanCreate(user: User, delegator: Company) { + const companyAssociation = await prisma.companyAssociation.findFirst({ + where: { + userId: user.id, + companyId: delegator.id + }, + select: { + role: true + } + }); + + if (!companyAssociation) { + throw new ForbiddenError( + "Vous devez faire partie de l'entreprise délégante pour pouvoir créer une délégation." + ); + } + + if ( + !can(companyAssociation.role, Permission.CompanyCanManageRegistryDelegation) + ) { + throw new ForbiddenError( + "Vous n'avez pas les permissions suffisantes pour pouvoir créer une délégation." + ); + } +} + +export async function checkCanAccess( + user: User, + delegation: RegistryDelegation +) { + const companyAssociation = await prisma.companyAssociation.findFirst({ + where: { + userId: user.id, + companyId: { in: [delegation.delegateId, delegation.delegatorId] } + }, + select: { + id: true + } + }); + + if (!companyAssociation) { + throw new ForbiddenError( + "Vous devez faire partie de l'entreprise délégante ou délégataire d'une délégation pour y avoir accès." + ); + } +} + +export async function checkCanRevoke( + user: User, + delegation: RegistryDelegation +) { + const companyAssociations = await prisma.companyAssociation.findMany({ + where: { + userId: user.id, + companyId: { in: [delegation.delegatorId, delegation.delegateId] } + }, + select: { + role: true + } + }); + + if (!companyAssociations?.length) { + throw new ForbiddenError( + "Vous devez faire partie de l'entreprise délégante ou délégataire d'une délégation pour pouvoir la révoquer." + ); + } + + if ( + !companyAssociations.some(association => + can(association.role, Permission.CompanyCanManageRegistryDelegation) + ) + ) { + throw new ForbiddenError( + "Vous n'avez pas les permissions suffisantes pour pouvoir créer une délégation." + ); + } +} + +export async function checkBelongsTo(user: User, company: Company) { + const companyAssociation = await prisma.companyAssociation.findFirst({ + where: { + userId: user.id, + companyId: company.id + } + }); + + if (!companyAssociation) { + throw new ForbiddenError( + `L'utilisateur ne fait pas partie de l'entreprise ${company.orgId}.` + ); + } +} diff --git a/back/src/registryDelegation/repository/index.ts b/back/src/registryDelegation/repository/index.ts new file mode 100644 index 0000000000..8aeb71a92d --- /dev/null +++ b/back/src/registryDelegation/repository/index.ts @@ -0,0 +1,37 @@ +import { prisma } from "@td/prisma"; +import { buildCreateRegistryDelegation } from "./registryDelegation/create"; +import { transactionWrapper } from "../../common/repository/helper"; +import { + RepositoryFnBuilder, + RepositoryTransaction +} from "../../common/repository/types"; +import { RegistryDelegationActions } from "./types"; +import buildFindFirstRegistryDelegation from "./registryDelegation/findFirst"; +import { buildUpdateRegistryDelegation } from "./registryDelegation/update"; +import { buildCountRegistryDelegations } from "./registryDelegation/count"; +import { buildFindManyRegistryDelegation } from "./registryDelegation/findMany"; + +export type RegistryDelegationRepository = RegistryDelegationActions; + +export function getReadonlyRegistryDelegationRepository() { + return { + findFirst: buildFindFirstRegistryDelegation({ prisma }), + count: buildCountRegistryDelegations({ prisma }), + findMany: buildFindManyRegistryDelegation({ prisma }) + }; +} + +export function getRegistryDelegationRepository( + user: Express.User, + transaction?: RepositoryTransaction +): RegistryDelegationRepository { + function useTransaction(builder: RepositoryFnBuilder) { + return transactionWrapper(builder, { user, transaction }); + } + + return { + ...getReadonlyRegistryDelegationRepository(), + create: useTransaction(buildCreateRegistryDelegation), + update: useTransaction(buildUpdateRegistryDelegation) + }; +} diff --git a/back/src/registryDelegation/repository/registryDelegation/count.ts b/back/src/registryDelegation/repository/registryDelegation/count.ts new file mode 100644 index 0000000000..b9141e169d --- /dev/null +++ b/back/src/registryDelegation/repository/registryDelegation/count.ts @@ -0,0 +1,14 @@ +import { Prisma } from "@prisma/client"; +import { ReadRepositoryFnDeps } from "../../../common/repository/types"; + +export type CountRegistryDelegationsFn = ( + where: Prisma.RegistryDelegationWhereInput +) => Promise; + +export function buildCountRegistryDelegations({ + prisma +}: ReadRepositoryFnDeps): CountRegistryDelegationsFn { + return where => { + return prisma.registryDelegation.count({ where }); + }; +} diff --git a/back/src/registryDelegation/repository/registryDelegation/create.ts b/back/src/registryDelegation/repository/registryDelegation/create.ts new file mode 100644 index 0000000000..3aebb5afda --- /dev/null +++ b/back/src/registryDelegation/repository/registryDelegation/create.ts @@ -0,0 +1,34 @@ +import { Prisma, RegistryDelegation } from "@prisma/client"; +import { + LogMetadata, + RepositoryFnDeps +} from "../../../common/repository/types"; + +export type CreateRegistryDelegationFn = ( + data: Prisma.RegistryDelegationCreateInput, + logMetadata?: LogMetadata +) => Promise; + +export const buildCreateRegistryDelegation = ( + deps: RepositoryFnDeps +): CreateRegistryDelegationFn => { + return async (data, logMetadata?) => { + const { prisma, user } = deps; + + const delegation = await prisma.registryDelegation.create({ + data + }); + + await prisma.event.create({ + data: { + streamId: delegation.id, + actor: user.id, + type: "RegistryDelegationCreated", + data: { content: data } as Prisma.InputJsonObject, + metadata: { ...logMetadata, authType: user.auth } + } + }); + + return delegation; + }; +}; diff --git a/back/src/registryDelegation/repository/registryDelegation/findFirst.ts b/back/src/registryDelegation/repository/registryDelegation/findFirst.ts new file mode 100644 index 0000000000..cdd3a55b3d --- /dev/null +++ b/back/src/registryDelegation/repository/registryDelegation/findFirst.ts @@ -0,0 +1,18 @@ +import { Prisma, RegistryDelegation } from "@prisma/client"; +import { ReadRepositoryFnDeps } from "../../../common/repository/types"; + +export type FindFirstRegistryDelegationFn = ( + where: Prisma.RegistryDelegationWhereInput, + options?: Omit +) => Promise; + +const buildFindFirstRegistryDelegation: ( + deps: ReadRepositoryFnDeps +) => FindFirstRegistryDelegationFn = + ({ prisma }) => + (where, options?) => { + const input = { where, ...options }; + return prisma.registryDelegation.findFirst(input); + }; + +export default buildFindFirstRegistryDelegation; diff --git a/back/src/registryDelegation/repository/registryDelegation/findMany.ts b/back/src/registryDelegation/repository/registryDelegation/findMany.ts new file mode 100644 index 0000000000..5b17bddf6b --- /dev/null +++ b/back/src/registryDelegation/repository/registryDelegation/findMany.ts @@ -0,0 +1,26 @@ +import { Prisma } from "@prisma/client"; +import { ReadRepositoryFnDeps } from "../../../common/repository/types"; + +export type FindManyRegistryDelegationFn = < + Args extends Omit +>( + where: Prisma.RegistryDelegationWhereInput, + options?: Args +) => Promise>>; + +export function buildFindManyRegistryDelegation({ + prisma +}: ReadRepositoryFnDeps): FindManyRegistryDelegationFn { + return async < + Args extends Omit + >( + where: Prisma.RegistryDelegationWhereInput, + options?: Args + ) => { + const input = { where, ...options }; + const registryDelegations = await prisma.registryDelegation.findMany(input); + return registryDelegations as Array< + Prisma.RegistryDelegationGetPayload + >; + }; +} diff --git a/back/src/registryDelegation/repository/registryDelegation/update.ts b/back/src/registryDelegation/repository/registryDelegation/update.ts new file mode 100644 index 0000000000..a8a8755099 --- /dev/null +++ b/back/src/registryDelegation/repository/registryDelegation/update.ts @@ -0,0 +1,36 @@ +import { Prisma, RegistryDelegation } from "@prisma/client"; +import { + LogMetadata, + RepositoryFnDeps +} from "../../../common/repository/types"; + +export type UpdateRegistryDelegationFn = ( + where: Prisma.RegistryDelegationWhereUniqueInput, + data: Prisma.RegistryDelegationUpdateInput, + logMetadata?: LogMetadata +) => Promise; + +export const buildUpdateRegistryDelegation = ( + deps: RepositoryFnDeps +): UpdateRegistryDelegationFn => { + return async (where, data, logMetadata) => { + const { prisma, user } = deps; + + const delegation = await prisma.registryDelegation.update({ + where, + data + }); + + await prisma.event.create({ + data: { + streamId: delegation.id, + actor: user.id, + type: "RegistryDelegationUpdated", + data: { content: data } as Prisma.InputJsonObject, + metadata: { ...logMetadata, authType: user.auth } + } + }); + + return delegation; + }; +}; diff --git a/back/src/registryDelegation/repository/types.ts b/back/src/registryDelegation/repository/types.ts new file mode 100644 index 0000000000..5470b33eea --- /dev/null +++ b/back/src/registryDelegation/repository/types.ts @@ -0,0 +1,16 @@ +import { CountRegistryDelegationsFn } from "./registryDelegation/count"; +import { CreateRegistryDelegationFn } from "./registryDelegation/create"; +import { FindFirstRegistryDelegationFn } from "./registryDelegation/findFirst"; +import { FindManyRegistryDelegationFn } from "./registryDelegation/findMany"; +import { UpdateRegistryDelegationFn } from "./registryDelegation/update"; + +export type RegistryDelegationActions = { + // Read + findFirst: FindFirstRegistryDelegationFn; + count: CountRegistryDelegationsFn; + findMany: FindManyRegistryDelegationFn; + + // Write + create: CreateRegistryDelegationFn; + update: UpdateRegistryDelegationFn; +}; diff --git a/back/src/registryDelegation/resolvers/Mutation.ts b/back/src/registryDelegation/resolvers/Mutation.ts new file mode 100644 index 0000000000..514f76a3c1 --- /dev/null +++ b/back/src/registryDelegation/resolvers/Mutation.ts @@ -0,0 +1,15 @@ +import { MutationResolvers } from "../../generated/graphql/types"; +import createRegistryDelegation from "./mutations/createRegistryDelegation"; +import revokeRegistryDelegation from "./mutations/revokeRegistryDelegation"; + +export type RegistryDelegationMutationResolvers = Pick< + MutationResolvers, + "createRegistryDelegation" | "revokeRegistryDelegation" +>; + +const Mutation: RegistryDelegationMutationResolvers = { + createRegistryDelegation, + revokeRegistryDelegation +}; + +export default Mutation; diff --git a/back/src/registryDelegation/resolvers/Query.ts b/back/src/registryDelegation/resolvers/Query.ts new file mode 100644 index 0000000000..2b72dc96b3 --- /dev/null +++ b/back/src/registryDelegation/resolvers/Query.ts @@ -0,0 +1,15 @@ +import { QueryResolvers } from "../../generated/graphql/types"; +import registryDelegation from "./queries/registryDelegation"; +import registryDelegations from "./queries/registryDelegations"; + +export type RegistryDelegationQueryResolvers = Pick< + QueryResolvers, + "registryDelegation" | "registryDelegations" +>; + +const Query: RegistryDelegationQueryResolvers = { + registryDelegation, + registryDelegations +}; + +export default Query; diff --git a/back/src/registryDelegation/resolvers/__tests__/status.integration.ts b/back/src/registryDelegation/resolvers/__tests__/status.integration.ts new file mode 100644 index 0000000000..44b6a1c75b --- /dev/null +++ b/back/src/registryDelegation/resolvers/__tests__/status.integration.ts @@ -0,0 +1,73 @@ +import { Prisma } from "@prisma/client"; +import { nowPlusXHours } from "../../../utils"; +import { registryDelegationFactory } from "../../__tests__/factories"; +import { getDelegation } from "../queries/__tests__/registryDelegation.integration"; +import { RegistryDelegationStatus } from "../../../generated/graphql/types"; + +describe("status", () => { + it.each([ + // CLOSED + [ + { isRevoked: true, startDate: nowPlusXHours(-2) }, + "CLOSED" as RegistryDelegationStatus + ], + [ + { isRevoked: true, startDate: nowPlusXHours(2) }, + "CLOSED" as RegistryDelegationStatus + ], + [ + { + isRevoked: true, + startDate: nowPlusXHours(2), + endDate: nowPlusXHours(3) + }, + "CLOSED" as RegistryDelegationStatus + ], + [ + { + isRevoked: true, + startDate: nowPlusXHours(-4), + endDate: nowPlusXHours(-3) + }, + "CLOSED" as RegistryDelegationStatus + ], + [ + { + isRevoked: false, + startDate: nowPlusXHours(-4), + endDate: nowPlusXHours(-3) + }, + "CLOSED" as RegistryDelegationStatus + ], + // INCOMING + [{ startDate: nowPlusXHours(2) }, "INCOMING" as RegistryDelegationStatus], + [ + { startDate: nowPlusXHours(2), endDate: nowPlusXHours(3) }, + "INCOMING" as RegistryDelegationStatus + ], + // ONGOING + [{ startDate: nowPlusXHours(-1) }, "ONGOING" as RegistryDelegationStatus], + [ + { startDate: nowPlusXHours(-1), endDate: nowPlusXHours(3) }, + "ONGOING" as RegistryDelegationStatus + ] + ])( + "delegation = %p, should return status %p", + async ( + delegationOpt: Partial, + status: RegistryDelegationStatus + ) => { + // Given + const { delegation, delegateUser } = await registryDelegationFactory( + delegationOpt + ); + + // When + const { errors, data } = await getDelegation(delegateUser, delegation.id); + + // Then + expect(errors).toBeUndefined(); + expect(data.registryDelegation.status).toBe(status); + } + ); +}); diff --git a/back/src/registryDelegation/resolvers/index.ts b/back/src/registryDelegation/resolvers/index.ts new file mode 100644 index 0000000000..5b48652485 --- /dev/null +++ b/back/src/registryDelegation/resolvers/index.ts @@ -0,0 +1,9 @@ +import Mutation from "./Mutation"; +import Query from "./Query"; +import RegistryDelegation from "./subResolvers/RegistryDelegation"; + +export default { + Mutation, + Query, + RegistryDelegation +}; diff --git a/back/src/registryDelegation/resolvers/mutations/__tests__/createRegistryDelegation.integration.ts b/back/src/registryDelegation/resolvers/mutations/__tests__/createRegistryDelegation.integration.ts new file mode 100644 index 0000000000..267d98d450 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/__tests__/createRegistryDelegation.integration.ts @@ -0,0 +1,598 @@ +import gql from "graphql-tag"; +import makeClient from "../../../../__tests__/testClient"; +import { + CreateRegistryDelegationInput, + Mutation +} from "../../../../generated/graphql/types"; +import { + companyFactory, + userInCompany, + userWithCompanyFactory +} from "../../../../__tests__/factories"; +import { User, RegistryDelegation } from "@prisma/client"; +import { resetDatabase } from "../../../../../integration-tests/helper"; +import { prisma } from "@td/prisma"; +import { GraphQLFormattedError } from "graphql"; +import { nowPlusXHours, todayAtMidnight, toddMMYYYY } from "../../../../utils"; +import { sendMail } from "../../../../mailer/mailing"; +import { renderMail, registryDelegationCreation } from "@td/mail"; +import { getStream } from "../../../../activity-events/data"; + +// Mock emails +jest.mock("../../../../mailer/mailing"); +(sendMail as jest.Mock).mockImplementation(() => Promise.resolve()); + +const CREATE_REGISTRY_DELEGATION = gql` + mutation createRegistryDelegation($input: CreateRegistryDelegationInput!) { + createRegistryDelegation(input: $input) { + id + createdAt + updatedAt + delegate { + orgId + givenName + } + delegator { + orgId + } + startDate + endDate + comment + isRevoked + } + } +`; + +interface CreateDelegation { + errors: readonly GraphQLFormattedError[]; + data: Pick; + delegation?: RegistryDelegation; +} +export const createDelegation = async ( + user: User | null, + input: CreateRegistryDelegationInput +): Promise => { + const { mutate } = makeClient(user); + const { errors, data } = await mutate< + Pick + >(CREATE_REGISTRY_DELEGATION, { + variables: { + input + } + }); + + if (errors) { + return { errors, data }; + } + + const delegation = await prisma.registryDelegation.findFirstOrThrow({ + where: { + id: data.createRegistryDelegation.id + } + }); + + return { errors, data, delegation }; +}; + +describe("mutation createRegistryDelegation", () => { + afterAll(resetDatabase); + + afterEach(async () => { + jest.resetAllMocks(); + }); + + describe("successful use-cases", () => { + it("should create a delegation declaration", async () => { + // Given + const delegate = await companyFactory({ givenName: "Some given name" }); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When + const { errors, data, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).toBeUndefined(); + + // Mutation return value should be OK + expect(data.createRegistryDelegation.delegator.orgId).toBe( + delegator.orgId + ); + expect(data.createRegistryDelegation.delegate.orgId).toBe(delegate.orgId); + expect(data.createRegistryDelegation.delegate.givenName).toBe( + "Some given name" + ); + + // Persisted value should be OK + expect(delegation?.delegatorId).toBe(delegator.id); + expect(delegation?.delegateId).toBe(delegate.id); + + // Should create an event + const eventsAfterCreate = await getStream(delegation!.id); + expect(eventsAfterCreate.length).toBe(1); + expect(eventsAfterCreate[0]).toMatchObject({ + type: "RegistryDelegationCreated", + actor: user.id, + streamId: delegation!.id + }); + }); + + it("should populate default values", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When + const { errors, data, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).toBeUndefined(); + + // Mutation return value should be OK + // Can't really do better for dates: https://github.com/prisma/prisma/issues/16719 + expect(data.createRegistryDelegation.createdAt).not.toBeNull(); + expect(data.createRegistryDelegation.updatedAt).not.toBeNull(); + expect(data.createRegistryDelegation.startDate).toBe( + todayAtMidnight().toISOString() + ); + expect(data.createRegistryDelegation.endDate).toBeNull(); + expect(data.createRegistryDelegation.comment).toBeNull(); + expect(data.createRegistryDelegation.isRevoked).toBeFalsy(); + + // Persisted value should be OK + expect(delegation?.createdAt).not.toBeNull(); + expect(delegation?.updatedAt).not.toBeNull(); + expect(delegation?.startDate.toISOString()).toBe( + todayAtMidnight().toISOString() + ); + expect(delegation?.endDate).toBeNull(); + expect(delegation?.comment).toBeNull(); + expect(delegation?.isRevoked).toBeFalsy(); + }); + + it("user can add a comment", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When + const COMMENT = "A super comment to explain delegation"; + const { errors, data, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId, + comment: COMMENT + }); + + // Then + expect(errors).toBeUndefined(); + + // Mutation return value should be OK + expect(data.createRegistryDelegation.comment).toBe(COMMENT); + + // Persisted value should be OK + expect(delegation?.comment).toBe(COMMENT); + }); + + it("test with null values", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When + const { errors, data, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId, + startDate: null, + endDate: null + }); + + // Then + expect(errors).toBeUndefined(); + + // Mutation return value should be OK + expect(data.createRegistryDelegation.delegator.orgId).toBe( + delegator.orgId + ); + expect(data.createRegistryDelegation.delegate.orgId).toBe(delegate.orgId); + + // Persisted value should be OK + expect(delegation?.delegatorId).toBe(delegator.id); + expect(delegation?.delegateId).toBe(delegate.id); + }); + + it("should send an email to companies admins", async () => { + // Given + const delegate = await companyFactory({ givenName: "Some given name" }); + const { user: delegatorAdmin, company: delegator } = + await userWithCompanyFactory(); + await userInCompany("MEMBER", delegator.id); // Not an admin, shoud not receive mail + await userInCompany("MEMBER", delegate.id); // Not an admin, shoud not receive mail + const delegateAdmin = await userInCompany("ADMIN", delegate.id); // Admin, should receive mail + await userWithCompanyFactory("ADMIN"); // Not part of the delegation, should not receive mail + + // When + const { errors, delegation } = await createDelegation(delegatorAdmin, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).toBeUndefined(); + + // Email + jest.mock("../../../../mailer/mailing"); + (sendMail as jest.Mock).mockImplementation(() => Promise.resolve()); + + expect(sendMail as jest.Mock).toHaveBeenCalledTimes(1); + + // Onboarding email + expect(sendMail as jest.Mock).toHaveBeenCalledWith( + renderMail(registryDelegationCreation, { + variables: { + startDate: toddMMYYYY(delegation!.startDate!), + delegator, + delegate + }, + messageVersions: [ + { + to: expect.arrayContaining([ + { email: delegatorAdmin.email, name: delegatorAdmin.name }, + { email: delegateAdmin.email, name: delegateAdmin.name } + ]) + } + ] + }) + ); + }); + }); + + it("testing email content - no end date", async () => { + // Given + const delegate = await companyFactory({ givenName: "Some given name" }); + const { user: delegatorAdmin, company: delegator } = + await userWithCompanyFactory(); + + // Email + jest.mock("../../../../mailer/mailing"); + (sendMail as jest.Mock).mockImplementation(() => Promise.resolve()); + + // When + const { errors, delegation } = await createDelegation(delegatorAdmin, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).toBeUndefined(); + expect(sendMail as jest.Mock).toHaveBeenCalledTimes(1); + expect(delegation).not.toBeUndefined(); + + if (!delegation) return; + + const formattedStartDate = toddMMYYYY(delegation.startDate).replace( + /\//g, + "/" + ); + expect(sendMail as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining(`effective à partir du ${formattedStartDate} + pour une durée illimitée.`), + subject: `Émission d'une demande de délégation de l'établissement ${delegator.name} (${delegator.siret})` + }) + ); + }); + + it("testing email content - with end date", async () => { + // Given + const delegate = await companyFactory({ givenName: "Some given name" }); + const { user: delegatorAdmin, company: delegator } = + await userWithCompanyFactory(); + const endDate = new Date("2050-10-10"); + + // Email + jest.mock("../../../../mailer/mailing"); + (sendMail as jest.Mock).mockImplementation(() => Promise.resolve()); + + // When + const { errors, delegation } = await createDelegation(delegatorAdmin, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId, + endDate: endDate.toISOString() as any + }); + + // Then + expect(errors).toBeUndefined(); + expect(sendMail as jest.Mock).toHaveBeenCalledTimes(1); + + if (!delegation) return; + + const formattedStartDate = toddMMYYYY(delegation.startDate).replace( + /\//g, + "/" + ); + const formattedEndDate = toddMMYYYY(endDate).replace(/\//g, "/"); + expect(sendMail as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining(`effective à partir du ${formattedStartDate} + et jusqu'au ${formattedEndDate}.`), + subject: `Émission d'une demande de délégation de l'établissement ${delegator.name} (${delegator.siret})` + }) + ); + }); + + describe("authentication & roles", () => { + it("user must be authenticated", async () => { + // Given + const delegate = await companyFactory(); + const delegator = await companyFactory(); + + // When + const { errors } = await createDelegation(null, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Vous n'êtes pas connecté."); + }); + + it("user must be admin", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory( + "MEMBER" + ); + + // When + const { errors } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous n'avez pas les permissions suffisantes pour pouvoir créer une délégation." + ); + }); + + it("user must belong to delegator company", async () => { + // Given + const delegate = await companyFactory(); + const delegator = await companyFactory(); + const { user } = await userWithCompanyFactory("ADMIN"); + + // When + const { errors } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous devez faire partie de l'entreprise délégante pour pouvoir créer une délégation." + ); + }); + }); + + describe("async validation", () => { + it("delegate company must exist", async () => { + // Given + const { user, company: delegator } = await userWithCompanyFactory(); + + // When + const { errors } = await createDelegation(user, { + delegateOrgId: "40081510600010", + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "L'entreprise 40081510600010 visée comme délégataire n'existe pas dans Trackdéchets" + ); + }); + + it("delegator company must exist", async () => { + // Given + const { user, company: delegate } = await userWithCompanyFactory(); + + // When + const { errors } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: "40081510600010" + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "L'entreprise 40081510600010 visée comme délégante n'existe pas dans Trackdéchets" + ); + }); + }); + + describe("prevent simultaneous valid delegations", () => { + it("should throw if there is already an active delegation for those companies (no start date, no end date)", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When: create first delegation + const { errors, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).toBeUndefined(); + + // When: create second delegation + const { errors: errors2 } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors2).not.toBeUndefined(); + expect(errors2[0].message).toBe( + `Une délégation existe déjà pour ce délégataire et ce délégant (id ${delegation?.id})` + ); + }); + + it("should throw if there is already an active delegation for those companies (no start date, end date)", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When: create first delegation + const { errors, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId, + endDate: nowPlusXHours(2).toISOString() as any + }); + + // Then + expect(errors).toBeUndefined(); + + // When: create second delegation + const { errors: errors2 } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors2).not.toBeUndefined(); + expect(errors2[0].message).toBe( + `Une délégation existe déjà pour ce délégataire et ce délégant (id ${delegation?.id})` + ); + }); + + it("should throw if there is already an active delegation for those companies (start date, end date)", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When: create first delegation + const { errors, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId, + startDate: new Date().toISOString() as any, + endDate: nowPlusXHours(2).toISOString() as any + }); + + // Then + expect(errors).toBeUndefined(); + + // When: create second delegation + const { errors: errors2 } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors2).not.toBeUndefined(); + expect(errors2[0].message).toBe( + `Une délégation existe déjà pour ce délégataire et ce délégant (id ${delegation?.id})` + ); + }); + + it("should throw if there is already an existing delegation programmed in the future", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When: create first delegation + const { errors, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId, + startDate: nowPlusXHours(2).toISOString() as any, + endDate: nowPlusXHours(3).toISOString() as any + }); + + // Then + expect(errors).toBeUndefined(); + + // When: create second delegation + const { errors: errors2 } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors2).not.toBeUndefined(); + expect(errors2[0].message).toBe( + `Une délégation existe déjà pour ce délégataire et ce délégant (id ${delegation?.id})` + ); + }); + + it("should not throw if there is an overlapping delegation but it's been refused", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When: create first delegation + const { errors, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId, + endDate: nowPlusXHours(3).toISOString() as any + }); + + // Then + expect(errors).toBeUndefined(); + + // Refuse the delegation + await prisma.registryDelegation.update({ + where: { id: delegation?.id }, + data: { isRevoked: true } + }); + + // When: create second delegation + const { errors: errors2 } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors2).toBeUndefined(); + }); + + it("should not throw if there is an existing delegation in the future but it's been refused", async () => { + // Given + const delegate = await companyFactory(); + const { user, company: delegator } = await userWithCompanyFactory(); + + // When: create first delegation + const { errors, delegation } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId, + startDate: nowPlusXHours(2).toISOString() as any, + endDate: nowPlusXHours(3).toISOString() as any + }); + + // Then + expect(errors).toBeUndefined(); + + // Refuse the delegation + await prisma.registryDelegation.update({ + where: { id: delegation?.id }, + data: { isRevoked: true } + }); + + // When: create second delegation + const { errors: errors2 } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors2).toBeUndefined(); + }); + }); +}); diff --git a/back/src/registryDelegation/resolvers/mutations/__tests__/revokeRegistryDelegation.integration.ts b/back/src/registryDelegation/resolvers/mutations/__tests__/revokeRegistryDelegation.integration.ts new file mode 100644 index 0000000000..0560e9c90d --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/__tests__/revokeRegistryDelegation.integration.ts @@ -0,0 +1,175 @@ +import gql from "graphql-tag"; +import { resetDatabase } from "../../../../../integration-tests/helper"; +import { User } from "@prisma/client"; +import makeClient from "../../../../__tests__/testClient"; +import { Mutation } from "../../../../generated/graphql/types"; +import { prisma } from "@td/prisma"; +import { registryDelegationFactory } from "../../../__tests__/factories"; +import { userFactory, userInCompany } from "../../../../__tests__/factories"; +import { getStream } from "../../../../activity-events/data"; + +const REVOKE_REGISTRY_DELEGATION = gql` + mutation revokeRegistryDelegation($delegationId: ID!) { + revokeRegistryDelegation(delegationId: $delegationId) { + id + isRevoked + status + delegate { + orgId + } + } + } +`; + +const revokeDelegation = async (user: User | null, delegationId: string) => { + const { mutate } = makeClient(user); + const { errors, data } = await mutate< + Pick + >(REVOKE_REGISTRY_DELEGATION, { + variables: { + delegationId + } + }); + + const delegation = await prisma.registryDelegation.findFirst({ + where: { id: delegationId } + }); + + return { data, errors, delegation }; +}; + +describe("mutation revokeRegistryDelegation", () => { + afterAll(resetDatabase); + + describe("successful use-cases", () => { + it("should revoke delegation", async () => { + // Given + const { delegation, delegatorUser, delegateCompany } = + await registryDelegationFactory(); + + // When + const { + errors, + data, + delegation: updatedDelegation + } = await revokeDelegation(delegatorUser, delegation.id); + + // Then + expect(errors).toBeUndefined(); + expect(data.revokeRegistryDelegation.isRevoked).toBeTruthy(); + expect(data.revokeRegistryDelegation.status).toBe("CLOSED"); + expect(data.revokeRegistryDelegation.delegate.orgId).toBe( + delegateCompany.orgId + ); + expect(updatedDelegation?.isRevoked).toBeTruthy(); + + // Should create an event + const eventsAfterCreate = await getStream(delegation!.id); + expect(eventsAfterCreate.length).toBe(1); + expect(eventsAfterCreate[0]).toMatchObject({ + type: "RegistryDelegationUpdated", + actor: delegatorUser.id, + streamId: delegation!.id + }); + }); + + it("delegator can revoke delegation", async () => { + // Given + const { delegation, delegatorUser } = await registryDelegationFactory(); + + // When + const { + errors, + data, + delegation: updatedDelegation + } = await revokeDelegation(delegatorUser, delegation.id); + + // Then + expect(errors).toBeUndefined(); + expect(data.revokeRegistryDelegation.isRevoked).toBeTruthy(); + expect(data.revokeRegistryDelegation.status).toBe("CLOSED"); + expect(updatedDelegation?.isRevoked).toBeTruthy(); + }); + + it("delegate can revoke delegation", async () => { + // Given + const { delegation, delegateUser } = await registryDelegationFactory(); + + // When + const { + errors, + data, + delegation: updatedDelegation + } = await revokeDelegation(delegateUser, delegation.id); + + // Then + expect(errors).toBeUndefined(); + expect(data.revokeRegistryDelegation.isRevoked).toBeTruthy(); + expect(data.revokeRegistryDelegation.status).toBe("CLOSED"); + expect(updatedDelegation?.isRevoked).toBeTruthy(); + }); + }); + + describe("authentication & roles", () => { + it("user must be authenticated", async () => { + // Given + const { delegation } = await registryDelegationFactory(); + + // When + const { errors } = await revokeDelegation(null, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Vous n'êtes pas connecté."); + }); + + it("user must belong to one of the companies", async () => { + // Given + const { delegation } = await registryDelegationFactory(); + const user = await userFactory(); + + // When + const { errors } = await revokeDelegation(user, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous devez faire partie de l'entreprise délégante ou délégataire d'une délégation pour pouvoir la révoquer." + ); + }); + + it("user must be admin", async () => { + // Given + const { delegation, delegateCompany } = await registryDelegationFactory(); + const user = await userInCompany("MEMBER", delegateCompany.id); + + // When + const { errors } = await revokeDelegation(user, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous n'avez pas les permissions suffisantes pour pouvoir créer une délégation." + ); + }); + }); + + describe("async validation", () => { + it("should throw if delegation does not exist", async () => { + // Given + const user = await userFactory(); + + // When + const { errors } = await revokeDelegation( + user, + "cxxxxxxxxxxxxxxxxxxxxxxxx" + ); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "La demande de délégation cxxxxxxxxxxxxxxxxxxxxxxxx n'existe pas." + ); + }); + }); +}); diff --git a/back/src/registryDelegation/resolvers/mutations/createRegistryDelegation.ts b/back/src/registryDelegation/resolvers/mutations/createRegistryDelegation.ts new file mode 100644 index 0000000000..72342589c1 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/createRegistryDelegation.ts @@ -0,0 +1,63 @@ +import { applyAuthStrategies, AuthType } from "../../../auth"; +import { checkIsAuthenticated } from "../../../common/permissions"; +import { + MutationCreateRegistryDelegationArgs, + ResolversParentTypes, + RegistryDelegation +} from "../../../generated/graphql/types"; +import { GraphQLContext } from "../../../types"; +import { checkCanCreate } from "../../permissions"; +import { parseCreateRegistryDelegationInput } from "../../validation"; +import { fixTyping } from "../typing"; +import { findDelegateAndDelegatorOrThrow } from "../utils"; +import { + createDelegation, + checkNoExistingNotRevokedAndNotExpiredDelegation, + sendRegistryDelegationCreationEmail +} from "./utils/createRegistryDelegation.utils"; + +const createRegistryDelegation = async ( + _: ResolversParentTypes["Mutation"], + { input }: MutationCreateRegistryDelegationArgs, + context: GraphQLContext +): Promise => { + // Browser only + applyAuthStrategies(context, [AuthType.Session]); + + // User must be authenticated + const user = checkIsAuthenticated(context); + + // Sync validation of input + const delegationInput = parseCreateRegistryDelegationInput(input); + + // Fetch companies + const { delegator, delegate } = await findDelegateAndDelegatorOrThrow( + delegationInput.delegateOrgId, + delegationInput.delegatorOrgId + ); + + // Make sure user can create delegation + await checkCanCreate(user, delegator); + + // Check there's not already an existing delegation + await checkNoExistingNotRevokedAndNotExpiredDelegation( + user, + delegator, + delegate + ); + + // Create delegation + const delegation = await createDelegation( + user, + delegationInput, + delegator, + delegate + ); + + // Send email + await sendRegistryDelegationCreationEmail(delegation, delegator, delegate); + + return fixTyping(delegation); +}; + +export default createRegistryDelegation; diff --git a/back/src/registryDelegation/resolvers/mutations/revokeRegistryDelegation.ts b/back/src/registryDelegation/resolvers/mutations/revokeRegistryDelegation.ts new file mode 100644 index 0000000000..9308ec2b2a --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/revokeRegistryDelegation.ts @@ -0,0 +1,41 @@ +import { applyAuthStrategies, AuthType } from "../../../auth"; +import { checkIsAuthenticated } from "../../../common/permissions"; +import { + MutationRevokeRegistryDelegationArgs, + ResolversParentTypes, + RegistryDelegation +} from "../../../generated/graphql/types"; +import { GraphQLContext } from "../../../types"; +import { checkCanRevoke } from "../../permissions"; +import { parseMutationRevokeRegistryDelegationArgs } from "../../validation"; +import { fixTyping } from "../typing"; +import { findDelegationByIdOrThrow } from "../utils"; +import { revokeDelegation } from "./utils/revokeRegistryDelegation.utils"; + +const revokeRegistryDelegation = async ( + _: ResolversParentTypes["Mutation"], + args: MutationRevokeRegistryDelegationArgs, + context: GraphQLContext +): Promise => { + // Browser only + applyAuthStrategies(context, [AuthType.Session]); + + // User must be authenticated + const user = checkIsAuthenticated(context); + + // Sync validation of input + const { delegationId } = parseMutationRevokeRegistryDelegationArgs(args); + + // Fetch delegation + const delegation = await findDelegationByIdOrThrow(user, delegationId); + + // Make sure user can revoke delegation + await checkCanRevoke(user, delegation); + + // Revoke delegation + const revokedDelegation = await revokeDelegation(user, delegation); + + return fixTyping(revokedDelegation); +}; + +export default revokeRegistryDelegation; diff --git a/back/src/registryDelegation/resolvers/mutations/utils/createRegistryDelegation.utils.ts b/back/src/registryDelegation/resolvers/mutations/utils/createRegistryDelegation.utils.ts new file mode 100644 index 0000000000..4360a09be1 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/utils/createRegistryDelegation.utils.ts @@ -0,0 +1,110 @@ +import { Company, RegistryDelegation } from "@prisma/client"; +import { UserInputError } from "../../../../common/errors"; +import { getRegistryDelegationRepository } from "../../../repository"; +import { ParsedCreateRegistryDelegationInput } from "../../../validation"; +import { prisma } from "@td/prisma"; +import { renderMail, registryDelegationCreation } from "@td/mail"; +import { sendMail } from "../../../../mailer/mailing"; +import { toddMMYYYY } from "../../../../utils"; + +export const createDelegation = async ( + user: Express.User, + input: ParsedCreateRegistryDelegationInput, + delegator: Company, + delegate: Company +) => { + const delegationRepository = getRegistryDelegationRepository(user); + return delegationRepository.create({ + startDate: input.startDate, + endDate: input.endDate, + comment: input.comment, + delegate: { + connect: { + id: delegate.id + } + }, + delegator: { + connect: { + id: delegator.id + } + } + }); +}; + +/** + * Check to prevent having multiple active delegations at the same time. + * + * We don't authorize already having a non-revoked delegation (isRevoked=false) that has + * not yet expired. + * + * That means that if the company has a delegation that only takes effect in the future, + * it cannot create a new one for the meantime. Users would have to delete the delegation + * in the future and create a new one. + */ +export const checkNoExistingNotRevokedAndNotExpiredDelegation = async ( + user: Express.User, + delegator: Company, + delegate: Company +) => { + const NOW = new Date(); + + const delegationRepository = getRegistryDelegationRepository(user); + const activeDelegation = await delegationRepository.findFirst({ + delegatorId: delegator.id, + delegateId: delegate.id, + isRevoked: false, + OR: [{ endDate: null }, { endDate: { gt: NOW } }] + }); + + if (activeDelegation) { + throw new UserInputError( + `Une délégation existe déjà pour ce délégataire et ce délégant (id ${activeDelegation.id})` + ); + } +}; + +/** + * Send a creation email to admin of both companies (delegate & delegator) + */ +export const sendRegistryDelegationCreationEmail = async ( + delegation: RegistryDelegation, + delegator: Company, + delegate: Company +) => { + // Find admins from both delegator & delegate companies + const companyAssociations = await prisma.companyAssociation.findMany({ + where: { + companyId: { in: [delegator.id, delegate.id] }, + role: "ADMIN" + }, + include: { + user: { + select: { + id: true, + email: true, + name: true + } + } + } + }); + + // Prepare mail template + const payload = renderMail(registryDelegationCreation, { + variables: { + startDate: toddMMYYYY(delegation.startDate), + endDate: delegation.endDate ? toddMMYYYY(delegation.endDate) : undefined, + delegator, + delegate + }, + messageVersions: [ + { + to: companyAssociations.map(companyAssociation => ({ + email: companyAssociation.user.email, + name: companyAssociation.user.name + })) + } + ] + }); + + await sendMail(payload); +}; diff --git a/back/src/registryDelegation/resolvers/mutations/utils/revokeRegistryDelegation.utils.ts b/back/src/registryDelegation/resolvers/mutations/utils/revokeRegistryDelegation.utils.ts new file mode 100644 index 0000000000..02dd695eaf --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/utils/revokeRegistryDelegation.utils.ts @@ -0,0 +1,13 @@ +import { RegistryDelegation } from "@prisma/client"; +import { getRegistryDelegationRepository } from "../../../repository"; + +export const revokeDelegation = async ( + user: Express.User, + delegation: RegistryDelegation +) => { + const delegationRepository = getRegistryDelegationRepository(user); + return delegationRepository.update( + { id: delegation.id }, + { isRevoked: true } + ); +}; diff --git a/back/src/registryDelegation/resolvers/queries/__tests__/registryDelegation.integration.ts b/back/src/registryDelegation/resolvers/queries/__tests__/registryDelegation.integration.ts new file mode 100644 index 0000000000..7c5ff0dc49 --- /dev/null +++ b/back/src/registryDelegation/resolvers/queries/__tests__/registryDelegation.integration.ts @@ -0,0 +1,116 @@ +import gql from "graphql-tag"; +import { resetDatabase } from "../../../../../integration-tests/helper"; +import { registryDelegationFactory } from "../../../__tests__/factories"; +import makeClient from "../../../../__tests__/testClient"; +import { Query } from "../../../../generated/graphql/types"; +import { User } from "@prisma/client"; +import { userFactory } from "../../../../__tests__/factories"; + +const REGISTRY_DELEGATION = gql` + query registryDelegation($delegationId: ID!) { + registryDelegation(delegationId: $delegationId) { + id + createdAt + updatedAt + delegate { + orgId + } + delegator { + orgId + } + startDate + endDate + comment + isRevoked + status + } + } +`; + +export const getDelegation = async ( + user: User | null, + delegationId: string +) => { + const { query } = makeClient(user); + return query>(REGISTRY_DELEGATION, { + variables: { + delegationId + } + }); +}; + +describe("query registryDelegation", () => { + afterAll(resetDatabase); + + describe("successful use-cases", () => { + it("should return delegation", async () => { + // Given + const { delegation, delegatorUser, delegateCompany, delegatorCompany } = + await registryDelegationFactory(); + + // When + const { errors, data } = await getDelegation( + delegatorUser, + delegation.id + ); + + // Then + expect(errors).toBeUndefined(); + expect(data.registryDelegation.id).toBe(delegation.id); + expect(data.registryDelegation.delegate.orgId).toBe( + delegateCompany.orgId + ); + expect(data.registryDelegation.delegator.orgId).toBe( + delegatorCompany.orgId + ); + }); + }); + + describe("authentication & roles", () => { + it("user must be authenticated", async () => { + // Given + const { delegation } = await registryDelegationFactory(); + + // When + const { errors } = await getDelegation(null, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Vous n'êtes pas connecté."); + }); + + it("user must belong to delegate or delegator company", async () => { + // Given + const { delegation } = await registryDelegationFactory(); + const user = await userFactory(); + + // When + const { errors } = await getDelegation(user, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous devez faire partie de l'entreprise délégante ou délégataire d'une délégation pour y avoir accès." + ); + }); + }); + + describe("async validation", () => { + it("should throw if delegation id does not exist", async () => { + // Given + const { delegatorUser } = await registryDelegationFactory(); + + // When + const { errors } = await getDelegation( + delegatorUser, + "cxxxxxxxxxxxxxxxxxxxxxxxx" + ); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "La demande de délégation cxxxxxxxxxxxxxxxxxxxxxxxx n'existe pas." + ); + }); + }); +}); diff --git a/back/src/registryDelegation/resolvers/queries/__tests__/registryDelegations.integration.ts b/back/src/registryDelegation/resolvers/queries/__tests__/registryDelegations.integration.ts new file mode 100644 index 0000000000..4b30032863 --- /dev/null +++ b/back/src/registryDelegation/resolvers/queries/__tests__/registryDelegations.integration.ts @@ -0,0 +1,298 @@ +import gql from "graphql-tag"; +import { resetDatabase } from "../../../../../integration-tests/helper"; +import { User } from "@prisma/client"; +import makeClient from "../../../../__tests__/testClient"; +import { + Query, + QueryRegistryDelegationsArgs +} from "../../../../generated/graphql/types"; +import { + registryDelegationFactory, + registryDelegationFactoryWithExistingCompanies +} from "../../../__tests__/factories"; +import { prisma } from "@td/prisma"; + +const REGISTRY_DELEGATIONS = gql` + query registryDelegations( + $skip: Int + $first: Int + $where: RegistryDelegationWhere + ) { + registryDelegations(skip: $skip, first: $first, where: $where) { + totalCount + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + edges { + node { + id + createdAt + updatedAt + delegate { + orgId + } + delegator { + orgId + } + startDate + endDate + comment + isRevoked + status + } + } + } + } +`; + +export const getDelegations = async ( + user: User | null, + paginationArgs: Partial +) => { + const { query } = makeClient(user); + return query>(REGISTRY_DELEGATIONS, { + variables: { + ...paginationArgs + } + }); +}; + +describe("query registryDelegations", () => { + afterAll(resetDatabase); + + describe("successful use-cases", () => { + it("should return delegations", async () => { + // Given + const { delegation, delegatorUser, delegatorCompany, delegateCompany } = + await registryDelegationFactory(); + + // When + const { errors, data } = await getDelegations(delegatorUser, { + where: { delegatorOrgId: delegatorCompany.orgId } + }); + + // Then + expect(errors).toBeUndefined(); + expect(data.registryDelegations.totalCount).toBe(1); + expect(data.registryDelegations.edges[0].node.id).toBe(delegation.id); + expect(data.registryDelegations.edges[0].node.delegate.orgId).toBe( + delegateCompany.orgId + ); + expect(data.registryDelegations.edges[0].node.delegator.orgId).toBe( + delegatorCompany.orgId + ); + }); + + it("user can query as delegate", async () => { + // Given + const { delegation, delegateUser, delegateCompany } = + await registryDelegationFactory(); + + // When + const { errors, data } = await getDelegations(delegateUser, { + where: { delegateOrgId: delegateCompany.orgId } + }); + + // Then + expect(errors).toBeUndefined(); + expect(data.registryDelegations.totalCount).toBe(1); + expect(data.registryDelegations.edges[0].node.id).toBe(delegation.id); + }); + + it("user can query as delegator", async () => { + // Given + const { delegation, delegatorUser, delegatorCompany } = + await registryDelegationFactory(); + + // When + const { errors, data } = await getDelegations(delegatorUser, { + where: { delegatorOrgId: delegatorCompany.orgId } + }); + + // Then + expect(errors).toBeUndefined(); + expect(data.registryDelegations.totalCount).toBe(1); + expect(data.registryDelegations.edges[0].node.id).toBe(delegation.id); + }); + }); + + describe("authentication & roles", () => { + it("user must be authenticated", async () => { + // Given + const { delegatorCompany } = await registryDelegationFactory(); + + // When + const { errors } = await getDelegations(null, { + where: { delegatorOrgId: delegatorCompany.orgId } + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Vous n'êtes pas connecté."); + }); + + it("user must belong to delegate company", async () => { + // Given + const { delegateCompany, delegatorUser } = + await registryDelegationFactory(); + + // When + const { errors } = await getDelegations(delegatorUser, { + where: { delegateOrgId: delegateCompany.orgId } + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + `L'utilisateur ne fait pas partie de l'entreprise ${delegateCompany.orgId}.` + ); + }); + + it("user must belong to delegator company", async () => { + // Given + const { delegatorCompany, delegateUser } = + await registryDelegationFactory(); + + // When + const { errors } = await getDelegations(delegateUser, { + where: { delegateOrgId: delegatorCompany.orgId } + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + `L'utilisateur ne fait pas partie de l'entreprise ${delegatorCompany.orgId}.` + ); + }); + }); + + describe("async validation", () => { + it("should throw if delegate company does not exist", async () => { + // Given + const { delegateUser } = await registryDelegationFactory(); + + // When + const { errors } = await getDelegations(delegateUser, { + where: { delegateOrgId: "39070205800012" } + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + `L'entreprise 39070205800012 n'existe pas.` + ); + }); + + it("should throw if delegator company does not exist", async () => { + // Given + const { delegatorUser } = await registryDelegationFactory(); + + // When + const { errors } = await getDelegations(delegatorUser, { + where: { delegateOrgId: "39070205800012" } + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + `L'entreprise 39070205800012 n'existe pas.` + ); + }); + }); + + it("should be sorted by updatedAt", async () => { + // Given + const { + delegation: delegation1, + delegatorUser, + delegateCompany, + delegatorCompany + } = await registryDelegationFactory(); + const delegation2 = await registryDelegationFactoryWithExistingCompanies( + delegateCompany, + delegatorCompany + ); + + // When + const { errors: errors1, data: data1 } = await getDelegations( + delegatorUser, + { + where: { delegatorOrgId: delegatorCompany.orgId } + } + ); + + // Then + expect(errors1).toBeUndefined(); + expect(data1.registryDelegations.totalCount).toBe(2); + expect(data1.registryDelegations.edges[0].node.id).toBe(delegation2.id); + expect(data1.registryDelegations.edges[1].node.id).toBe(delegation1.id); + + // When: update delegation 2 + await prisma.registryDelegation.update({ + where: { id: delegation1.id }, + data: { comment: "test" } + }); + const { errors: errors2, data: data2 } = await getDelegations( + delegatorUser, + { + where: { delegatorOrgId: delegatorCompany.orgId } + } + ); + + // Then + expect(errors2).toBeUndefined(); + expect(data2.registryDelegations.totalCount).toBe(2); + expect(data2.registryDelegations.edges[0].node.id).toBe(delegation1.id); + expect(data2.registryDelegations.edges[1].node.id).toBe(delegation2.id); + }); + + it("should return paginated results", async () => { + // Given + const { delegation, delegatorUser, delegatorCompany, delegateCompany } = + await registryDelegationFactory(); + const delegation2 = await registryDelegationFactoryWithExistingCompanies( + delegateCompany, + delegatorCompany + ); + const delegation3 = await registryDelegationFactoryWithExistingCompanies( + delegateCompany, + delegatorCompany + ); + + // When (1st page) + const { errors: errors1, data: data1 } = await getDelegations( + delegatorUser, + { + where: { delegatorOrgId: delegatorCompany.orgId }, + first: 2, + skip: 0 + } + ); + + // Then + expect(errors1).toBeUndefined(); + expect(data1.registryDelegations.totalCount).toBe(3); + expect(data1.registryDelegations.edges.length).toBe(2); + expect(data1.registryDelegations.edges[0].node.id).toBe(delegation3.id); + expect(data1.registryDelegations.edges[1].node.id).toBe(delegation2.id); + + // When (second page) + const { errors: errors2, data: data2 } = await getDelegations( + delegatorUser, + { + where: { delegatorOrgId: delegatorCompany.orgId }, + first: 2, + skip: 2 + } + ); + + // Then + expect(errors2).toBeUndefined(); + expect(data2.registryDelegations.totalCount).toBe(3); + expect(data2.registryDelegations.edges.length).toBe(1); + expect(data2.registryDelegations.edges[0].node.id).toBe(delegation.id); + }); +}); diff --git a/back/src/registryDelegation/resolvers/queries/registryDelegation.ts b/back/src/registryDelegation/resolvers/queries/registryDelegation.ts new file mode 100644 index 0000000000..2ff31cd698 --- /dev/null +++ b/back/src/registryDelegation/resolvers/queries/registryDelegation.ts @@ -0,0 +1,35 @@ +import { applyAuthStrategies, AuthType } from "../../../auth"; +import { checkIsAuthenticated } from "../../../common/permissions"; +import { + QueryResolvers, + RegistryDelegation +} from "../../../generated/graphql/types"; +import { checkCanAccess } from "../../permissions"; +import { parseQueryRegistryDelegationArgs } from "../../validation"; +import { fixTyping } from "../typing"; +import { findDelegationByIdOrThrow } from "../utils"; + +const registryDelegationResolver: QueryResolvers["registryDelegation"] = async ( + _, + args, + context +): Promise => { + // Browser only + applyAuthStrategies(context, [AuthType.Session]); + + // User must be authenticated + const user = checkIsAuthenticated(context); + + // Sync validation of args + const { delegationId } = parseQueryRegistryDelegationArgs(args); + + // Fetch delegation + const delegation = await findDelegationByIdOrThrow(user, delegationId); + + // Make sure user can access delegation + await checkCanAccess(user, delegation); + + return fixTyping(delegation); +}; + +export default registryDelegationResolver; diff --git a/back/src/registryDelegation/resolvers/queries/registryDelegations.ts b/back/src/registryDelegation/resolvers/queries/registryDelegations.ts new file mode 100644 index 0000000000..947c095310 --- /dev/null +++ b/back/src/registryDelegation/resolvers/queries/registryDelegations.ts @@ -0,0 +1,44 @@ +import { applyAuthStrategies, AuthType } from "../../../auth"; +import { checkIsAuthenticated } from "../../../common/permissions"; +import { QueryResolvers } from "../../../generated/graphql/types"; +import { checkBelongsTo } from "../../permissions"; +import { parseQueryRegistryDelegationsArgs } from "../../validation"; +import { fixPaginatedTyping } from "../typing"; +import { findDelegateOrDelegatorOrThrow } from "../utils"; +import { getPaginatedDelegations } from "./utils/registryDelegations.utils"; + +const registryDelegationsResolver: QueryResolvers["registryDelegations"] = + async (_, args, context) => { + // Browser only + applyAuthStrategies(context, [AuthType.Session]); + + // User must be authenticated + const user = checkIsAuthenticated(context); + + // Sync validation of args + const paginationArgs = parseQueryRegistryDelegationsArgs(args); + + const { delegateOrgId, delegatorOrgId } = paginationArgs.where; + + // Find targeted company + const { delegate, delegator } = await findDelegateOrDelegatorOrThrow( + delegateOrgId, + delegatorOrgId + ); + + // Check that user belongs to company + if (delegate) await checkBelongsTo(user, delegate); + if (delegator) await checkBelongsTo(user, delegator); + + // Get paginated delegations + const paginatedDelegations = await getPaginatedDelegations(user, { + delegate, + delegator, + skip: paginationArgs.skip, + first: paginationArgs.first + }); + + return fixPaginatedTyping(paginatedDelegations); + }; + +export default registryDelegationsResolver; diff --git a/back/src/registryDelegation/resolvers/queries/utils/registryDelegations.utils.ts b/back/src/registryDelegation/resolvers/queries/utils/registryDelegations.utils.ts new file mode 100644 index 0000000000..e67967483a --- /dev/null +++ b/back/src/registryDelegation/resolvers/queries/utils/registryDelegations.utils.ts @@ -0,0 +1,52 @@ +import { Company } from "@prisma/client"; +import { + getConnection, + getPrismaPaginationArgs +} from "../../../../common/pagination"; +import { getRegistryDelegationRepository } from "../../../repository"; +import { UserInputError } from "../../../../common/errors"; + +interface Args { + delegate?: Company; + delegator?: Company; + skip?: number | null | undefined; + first?: number | null | undefined; +} + +export const getPaginatedDelegations = async ( + user: Express.User, + { delegate, delegator, skip, first }: Args +) => { + if (!delegate && !delegator) { + throw new UserInputError( + "Vous devez préciser un délégant ou un délégataire" + ); + } + + const delegationRepository = getRegistryDelegationRepository(user); + + const fixedWhere = { + delegateId: delegate?.id, + delegatorId: delegator?.id + }; + + const totalCount = await delegationRepository.count(fixedWhere); + + const paginationArgs = getPrismaPaginationArgs({ + skip: skip ?? 0, + first: first ?? 10 + }); + + const result = await getConnection({ + totalCount, + findMany: () => + delegationRepository.findMany(fixedWhere, { + ...paginationArgs, + orderBy: { updatedAt: "desc" }, + include: { delegator: true, delegate: true } + }), + formatNode: node => ({ ...node }) + }); + + return result; +}; diff --git a/back/src/registryDelegation/resolvers/subResolvers/RegistryDelegation.ts b/back/src/registryDelegation/resolvers/subResolvers/RegistryDelegation.ts new file mode 100644 index 0000000000..fb74aba9ea --- /dev/null +++ b/back/src/registryDelegation/resolvers/subResolvers/RegistryDelegation.ts @@ -0,0 +1,12 @@ +import { RegistryDelegationResolvers } from "../../../generated/graphql/types"; +import { delegateResolver } from "./delegate"; +import { delegatorResolver } from "./delegator"; +import { statusResolver } from "./status"; + +const registryDelegationResolvers: RegistryDelegationResolvers = { + status: statusResolver, + delegate: delegateResolver, + delegator: delegatorResolver +}; + +export default registryDelegationResolvers; diff --git a/back/src/registryDelegation/resolvers/subResolvers/delegate.ts b/back/src/registryDelegation/resolvers/subResolvers/delegate.ts new file mode 100644 index 0000000000..1d5b972ca7 --- /dev/null +++ b/back/src/registryDelegation/resolvers/subResolvers/delegate.ts @@ -0,0 +1,19 @@ +import { prisma } from "@td/prisma"; +import { convertUrls } from "../../../companies/database"; +import { + RegistryDelegation, + RegistryDelegationResolvers +} from "../../../generated/graphql/types"; + +const getDelegate = async (delegation: RegistryDelegation) => { + const company = await prisma.registryDelegation + .findUniqueOrThrow({ + where: { id: delegation.id } + }) + .delegate(); + + return convertUrls(company); +}; + +export const delegateResolver: RegistryDelegationResolvers["delegate"] = + delegation => getDelegate(delegation) as any; diff --git a/back/src/registryDelegation/resolvers/subResolvers/delegator.ts b/back/src/registryDelegation/resolvers/subResolvers/delegator.ts new file mode 100644 index 0000000000..defd0e56a1 --- /dev/null +++ b/back/src/registryDelegation/resolvers/subResolvers/delegator.ts @@ -0,0 +1,19 @@ +import { prisma } from "@td/prisma"; +import { + RegistryDelegation, + RegistryDelegationResolvers +} from "../../../generated/graphql/types"; +import { convertUrls } from "../../../companies/database"; + +const getDelegator = async (delegation: RegistryDelegation) => { + const company = await prisma.registryDelegation + .findUniqueOrThrow({ + where: { id: delegation.id } + }) + .delegator(); + + return convertUrls(company); +}; + +export const delegatorResolver: RegistryDelegationResolvers["delegator"] = + delegation => getDelegator(delegation) as any; diff --git a/back/src/registryDelegation/resolvers/subResolvers/status.ts b/back/src/registryDelegation/resolvers/subResolvers/status.ts new file mode 100644 index 0000000000..810dec5e0d --- /dev/null +++ b/back/src/registryDelegation/resolvers/subResolvers/status.ts @@ -0,0 +1,14 @@ +import { + RegistryDelegation, + RegistryDelegationResolvers +} from "../../../generated/graphql/types"; +import { getDelegationStatus } from "../utils"; + +/** + * For frontend's convenience, we compute the status here + */ +const getStatus = (delegation: RegistryDelegation) => + getDelegationStatus(delegation); + +export const statusResolver: RegistryDelegationResolvers["status"] = + delegation => getStatus(delegation); diff --git a/back/src/registryDelegation/resolvers/typing.ts b/back/src/registryDelegation/resolvers/typing.ts new file mode 100644 index 0000000000..b5ed7b7a22 --- /dev/null +++ b/back/src/registryDelegation/resolvers/typing.ts @@ -0,0 +1,29 @@ +import { RegistryDelegation } from "@prisma/client"; +import { + CompanyPublic, + RegistryDelegationStatus +} from "../../generated/graphql/types"; + +// Revolvers don't provide some of the fields, because they are computed +// by sub-resolvers (ie: status or delegate). +// However, Apollo complains that the resolvers are missing said fields. +// With this method we fake providing the sub-fields and Apollo shuts up. +export const fixTyping = (delegation: RegistryDelegation) => { + return { + ...delegation, + delegator: null as unknown as CompanyPublic, + delegate: null as unknown as CompanyPublic, + status: null as unknown as RegistryDelegationStatus + }; +}; + +// Same as above, but for a paginated output +export const fixPaginatedTyping = paginatedDelegations => { + return { + ...paginatedDelegations, + edges: paginatedDelegations.edges.map(edge => ({ + ...edge, + node: fixTyping(edge.node) + })) + }; +}; diff --git a/back/src/registryDelegation/resolvers/utils.ts b/back/src/registryDelegation/resolvers/utils.ts new file mode 100644 index 0000000000..13ea6a4e2d --- /dev/null +++ b/back/src/registryDelegation/resolvers/utils.ts @@ -0,0 +1,95 @@ +import { prisma } from "@td/prisma"; +import { UserInputError } from "../../common/errors"; +import { getRegistryDelegationRepository } from "../repository"; +import { Company, Prisma } from "@prisma/client"; +import { + RegistryDelegation, + RegistryDelegationStatus +} from "../../generated/graphql/types"; + +export const findDelegateAndDelegatorOrThrow = async ( + delegateOrgId: string, + delegatorOrgId: string +) => { + const companies = await prisma.company.findMany({ + where: { + orgId: { in: [delegateOrgId, delegatorOrgId] } + } + }); + + const delegator = companies.find(company => company.orgId === delegatorOrgId); + const delegate = companies.find(company => company.orgId === delegateOrgId); + + if (!delegator) { + throw new UserInputError( + `L'entreprise ${delegatorOrgId} visée comme délégante n'existe pas dans Trackdéchets` + ); + } + + if (!delegate) { + throw new UserInputError( + `L'entreprise ${delegateOrgId} visée comme délégataire n'existe pas dans Trackdéchets` + ); + } + + return { delegator, delegate }; +}; + +export const findCompanyByOrgIdOrThrow = async (companyOrgId: string) => { + const company = await prisma.company.findFirst({ + where: { orgId: companyOrgId } + }); + + if (!company) { + throw new UserInputError(`L'entreprise ${companyOrgId} n'existe pas.`); + } + + return company; +}; + +export const findDelegationByIdOrThrow = async ( + user: Express.User, + id: string, + options?: Omit +) => { + const delegationRepository = getRegistryDelegationRepository(user); + const delegation = await delegationRepository.findFirst({ id }, options); + + if (!delegation) { + throw new UserInputError(`La demande de délégation ${id} n'existe pas.`); + } + + return delegation; +}; + +export const findDelegateOrDelegatorOrThrow = async ( + delegateOrgId?: string | null, + delegatorOrgId?: string | null +): Promise<{ delegate?: Company; delegator?: Company }> => { + let delegate, delegator; + + if (delegatorOrgId) { + delegator = await findCompanyByOrgIdOrThrow(delegatorOrgId); + } + + if (delegateOrgId) { + delegate = await findCompanyByOrgIdOrThrow(delegateOrgId); + } + + return { delegator, delegate }; +}; + +export const getDelegationStatus = (delegation: RegistryDelegation) => { + const NOW = new Date(); + + const { isRevoked, startDate, endDate } = delegation; + + if (isRevoked) return "CLOSED" as RegistryDelegationStatus; + + if (startDate > NOW) return "INCOMING" as RegistryDelegationStatus; + + if (startDate <= NOW && (!endDate || endDate > NOW)) + return "ONGOING" as RegistryDelegationStatus; + + return "CLOSED" as RegistryDelegationStatus; +}; diff --git a/back/src/registryDelegation/typeDefs/private/registryDelegation.enums.graphql b/back/src/registryDelegation/typeDefs/private/registryDelegation.enums.graphql new file mode 100644 index 0000000000..fee777f388 --- /dev/null +++ b/back/src/registryDelegation/typeDefs/private/registryDelegation.enums.graphql @@ -0,0 +1,19 @@ +""" +Statut d'une demande de délégation +""" +enum RegistryDelegationStatus { + """ + À venir + """ + INCOMING + + """ + En cours + """ + ONGOING + + """ + Clôturée + """ + CLOSED +} diff --git a/back/src/registryDelegation/typeDefs/private/registryDelegation.inputs.graphql b/back/src/registryDelegation/typeDefs/private/registryDelegation.inputs.graphql new file mode 100644 index 0000000000..bb9e8b8509 --- /dev/null +++ b/back/src/registryDelegation/typeDefs/private/registryDelegation.inputs.graphql @@ -0,0 +1,39 @@ +""" +Input pour la création d'une demande de délégation propre aux registres RNDTS +""" +input CreateRegistryDelegationInput { + """ + OrgId du délégataire, c'est-à-dire l'entreprise qui fera les déclarations + à la place du délégant + """ + delegateOrgId: String! + + """ + OrgId du délégant, c'est-à-dire l'entreprise qui délègue ses déclarations + aux délégataire + """ + delegatorOrgId: String! + + """ + Date de début de la délégation. Par défaut, la date du jour + """ + startDate: DateTime + + """ + Date de fin de la délégation. Si absente, la délégation est valable indéfiniment + """ + endDate: DateTime + + """ + Objet de la délégation + """ + comment: String +} + +input RegistryDelegationWhere { + """ + Delegate or delegator id. Specify exactly one of them + """ + delegatorOrgId: ID + delegateOrgId: ID +} diff --git a/back/src/registryDelegation/typeDefs/private/registryDelegation.mutations.graphql b/back/src/registryDelegation/typeDefs/private/registryDelegation.mutations.graphql new file mode 100644 index 0000000000..69687f0ea0 --- /dev/null +++ b/back/src/registryDelegation/typeDefs/private/registryDelegation.mutations.graphql @@ -0,0 +1,15 @@ +type Mutation { + """ + Permet de créer une demande de délégation pour déléguer les déclarations propres aux + registres RNDTS à un établissement. + """ + createRegistryDelegation( + input: CreateRegistryDelegationInput! + ): RegistryDelegation! + + """ + Permet de révoquer une demande de délégation. Peut être utilisé par le délégataire ou + le délégant. Une fois révoquée, une délégation ne peut plus être modifiée. + """ + revokeRegistryDelegation(delegationId: ID!): RegistryDelegation! +} diff --git a/back/src/registryDelegation/typeDefs/private/registryDelegation.objects.graphql b/back/src/registryDelegation/typeDefs/private/registryDelegation.objects.graphql new file mode 100644 index 0000000000..ad2aedd165 --- /dev/null +++ b/back/src/registryDelegation/typeDefs/private/registryDelegation.objects.graphql @@ -0,0 +1,71 @@ +""" +Demande de délégation pour déléguer les déclarations propres aux +registres RNDTS à un établissement. +""" +type RegistryDelegation { + """ + Identifiant de la demande de délégation + """ + id: String! + + """ + Date à laquelle la demande de délégation a été créée + """ + createdAt: DateTime! + + """ + Date de dernière mise à jour de la demande de délégation + """ + updatedAt: DateTime! + + """ + Délégataire, c'est-à-dire l'entreprise qui fera les déclarations + à la place du délégant + """ + delegate: CompanyPublic! + + """ + Délégant, c'est-à-dire l'entreprise qui délègue ses déclarations + aux délégataire + """ + delegator: CompanyPublic! + + """ + Date de début de la délégation + """ + startDate: DateTime! + + """ + Date de fin de la délégation + """ + endDate: DateTime + + """ + Objet de la délégation + """ + comment: String + + """ + Si la demande de délégation a été révoquée (par le délégataire ou le délégant) + """ + isRevoked: Boolean! + + """ + Statut de la délégation + """ + status: RegistryDelegationStatus! +} + +type RegistryDelegationEdge { + cursor: String! + node: RegistryDelegation! +} + +""" +List paginée de délégations +""" +type RegistryDelegationConnection { + totalCount: Int! + pageInfo: PageInfo! + edges: [RegistryDelegationEdge!]! +} diff --git a/back/src/registryDelegation/typeDefs/private/registryDelegation.queries.graphql b/back/src/registryDelegation/typeDefs/private/registryDelegation.queries.graphql new file mode 100644 index 0000000000..9ab67d88b7 --- /dev/null +++ b/back/src/registryDelegation/typeDefs/private/registryDelegation.queries.graphql @@ -0,0 +1,15 @@ +type Query { + """ + Renvoie une délégation, séléctionnée par son ID + """ + registryDelegation(delegationId: ID!): RegistryDelegation! + + """ + Renvoie une liste de délégations (triée par updatedAt) + """ + registryDelegations( + where: RegistryDelegationWhere + skip: Int + first: Int + ): RegistryDelegationConnection! +} diff --git a/back/src/registryDelegation/validation/__tests__/index.test.ts b/back/src/registryDelegation/validation/__tests__/index.test.ts new file mode 100644 index 0000000000..ba16272eea --- /dev/null +++ b/back/src/registryDelegation/validation/__tests__/index.test.ts @@ -0,0 +1,290 @@ +import { + parseCreateRegistryDelegationInput, + parseQueryRegistryDelegationArgs, + parseQueryRegistryDelegationsArgs +} from ".."; +import { CreateRegistryDelegationInput } from "../../../generated/graphql/types"; +import { startOfDay } from "../../../utils"; + +describe("index", () => { + describe("parseCreateRegistryDelegationInput", () => { + it("if no start date > default to today at midnight", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "40081510600010", + delegateOrgId: "39070205800012" + }; + + // When + const delegation = parseCreateRegistryDelegationInput(input); + + // Then + expect(delegation.delegatorOrgId).toBe("40081510600010"); + expect(delegation.delegateOrgId).toBe("39070205800012"); + expect(delegation.startDate?.toISOString()).toBe( + startOfDay(new Date()).toISOString() + ); + expect(delegation.endDate).toBeUndefined(); + }); + + it("should fix dates to midnight", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "40081510600010", + delegateOrgId: "39070205800012", + startDate: new Date("2040-09-30T05:32:11.250Z"), + endDate: new Date("2050-09-30T05:32:11.250Z") + }; + + // When + const delegation = parseCreateRegistryDelegationInput(input); + + // Then + expect(delegation.delegatorOrgId).toBe("40081510600010"); + expect(delegation.delegateOrgId).toBe("39070205800012"); + expect(delegation.startDate?.toISOString()).toBe( + "2040-09-30T00:00:00.000Z" + ); + expect(delegation.endDate?.toISOString()).toBe( + "2050-09-30T23:59:59.999Z" + ); + }); + + it("declaration with minimal info should be valid", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "40081510600010", + delegateOrgId: "39070205800012" + }; + + // When + const delegation = parseCreateRegistryDelegationInput(input); + + // Then + expect(delegation).toMatchObject({ + delegatorOrgId: "40081510600010", + delegateOrgId: "39070205800012" + }); + }); + + it("delegatorOrgId must be a valid orgId", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "NOT-A-SIRET", + delegateOrgId: "39070205800012" + }; + + // When + expect.assertions(1); + try { + parseCreateRegistryDelegationInput(input); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["delegatorOrgId"], + message: "NOT-A-SIRET n'est pas un numéro de SIRET valide" + }); + } + }); + + it("delegateOrgId must be a valid orgId", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "40081510600010", + delegateOrgId: "NOT-A-SIRET" + }; + + // When + expect.assertions(1); + try { + parseCreateRegistryDelegationInput(input); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["delegateOrgId"], + message: "NOT-A-SIRET n'est pas un numéro de SIRET valide" + }); + } + }); + + it("delegateOrgId must be different from delegatorOrgId", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "40081510600010", + delegateOrgId: "40081510600010" + }; + + // When + expect.assertions(1); + try { + parseCreateRegistryDelegationInput(input); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["delegatorOrgId"], + message: "Le délégant et le délégataire doivent être différents." + }); + } + }); + + it("validity start date cannot be in the past", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "40081510600010", + delegateOrgId: "39070205800012", + startDate: new Date("2000-01-01") + }; + + // When + expect.assertions(1); + try { + parseCreateRegistryDelegationInput(input); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["startDate"], + message: + "La date de début de validité ne peut pas être dans le passé." + }); + } + }); + + it("validity end date cannot be in the past", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "40081510600010", + delegateOrgId: "39070205800012", + endDate: new Date("2000-01-01") + }; + + // When + expect.assertions(1); + try { + parseCreateRegistryDelegationInput(input); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["endDate"], + message: "La date de fin de validité ne peut pas être dans le passé." + }); + } + }); + + it("validity end date cannot be before validity start date", () => { + // Given + const input: CreateRegistryDelegationInput = { + delegatorOrgId: "40081510600010", + delegateOrgId: "39070205800012", + startDate: new Date("2030-01-05"), + endDate: new Date("2030-01-01") + }; + + // When + expect.assertions(1); + try { + parseCreateRegistryDelegationInput(input); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["endDate"], + message: + "La date de début de validité doit être avant la date de fin." + }); + } + }); + }); + + describe("parseQueryRegistryDelegationArgs", () => { + it.each([null, undefined, 100, "no", "morethan25caracterssoitsinvalid"])( + "should throw if id is invalid (value = %p)", + delegationId => { + // Given + + // When + expect.assertions(1); + try { + parseQueryRegistryDelegationArgs({ + delegationId: delegationId as string + }); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["delegationId"], + message: "L'id doit faire 25 caractères." + }); + } + } + ); + + it("should return valid delegationId", () => { + // Given + const delegationId = "cl81ooom5138122w9sbznzdkg"; + + // When + const result = parseQueryRegistryDelegationArgs({ delegationId }); + + // Then + expect(result.delegationId).toBe(delegationId); + }); + }); + + describe("parseQueryRegistryDelegationsArgs", () => { + it("should not allow passing where.delegator & where.delegate", () => { + // Given + const args = { + where: { + delegatorOrgId: "39070205800012", + delegateOrgId: "39070205800012" + } + }; + + // When + expect.assertions(1); + try { + parseQueryRegistryDelegationsArgs(args); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["where"], + message: + "Vous ne pouvez pas renseigner les deux champs (delegatorOrgId et delegateOrgId)." + }); + } + }); + + it("should not allow empty where.delegator & where.delegate", () => { + // Given + const args = { where: {} }; + + // When + expect.assertions(1); + try { + parseQueryRegistryDelegationsArgs(args); + } catch (error) { + // Then + expect(error.errors[0]).toMatchObject({ + path: ["where"], + message: + "Vous devez renseigner un des deux champs (delegatorOrgId ou delegateOrgId)." + }); + } + }); + + it("should return valid args", () => { + // Given + const args = { + where: { delegatorOrgId: "39070205800012" }, + skip: 0, + first: 10 + }; + + // When + const { where, skip, first } = parseQueryRegistryDelegationsArgs(args); + + // Then + expect(where).toMatchObject({ delegatorOrgId: "39070205800012" }); + expect(skip).toBe(0); + expect(first).toBe(10); + }); + }); +}); diff --git a/back/src/registryDelegation/validation/index.ts b/back/src/registryDelegation/validation/index.ts new file mode 100644 index 0000000000..8471c9ecc8 --- /dev/null +++ b/back/src/registryDelegation/validation/index.ts @@ -0,0 +1,43 @@ +import { + CreateRegistryDelegationInput, + MutationRevokeRegistryDelegationArgs, + QueryRegistryDelegationArgs, + QueryRegistryDelegationsArgs +} from "../../generated/graphql/types"; +import { + delegationIdSchema, + createRegistryDelegationInputSchema, + queryRegistryDelegationsArgsSchema +} from "./schema"; + +export function parseCreateRegistryDelegationInput( + input: CreateRegistryDelegationInput +) { + return createRegistryDelegationInputSchema.parse(input); +} + +export type ParsedCreateRegistryDelegationInput = ReturnType< + typeof parseCreateRegistryDelegationInput +>; + +export function parseQueryRegistryDelegationArgs( + args: QueryRegistryDelegationArgs +) { + return delegationIdSchema.parse(args); +} + +export function parseMutationRevokeRegistryDelegationArgs( + args: MutationRevokeRegistryDelegationArgs +) { + return delegationIdSchema.parse(args); +} + +export function parseQueryRegistryDelegationsArgs( + args: QueryRegistryDelegationsArgs +) { + return queryRegistryDelegationsArgsSchema.parse(args); +} + +export type ParsedQueryRegistryDelegationsArgs = ReturnType< + typeof parseQueryRegistryDelegationsArgs +>; diff --git a/back/src/registryDelegation/validation/schema.ts b/back/src/registryDelegation/validation/schema.ts new file mode 100644 index 0000000000..ae55a234d3 --- /dev/null +++ b/back/src/registryDelegation/validation/schema.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import { siretSchema } from "../../common/validation/zod/schema"; +import { endOfDay, startOfDay, todayAtMidnight } from "../../utils"; + +const idSchema = z.coerce.string().length(25, "L'id doit faire 25 caractères."); + +export const createRegistryDelegationInputSchema = z + .object({ + delegateOrgId: siretSchema(), + delegatorOrgId: siretSchema(), + startDate: z.coerce + .date() + .nullish() + .transform((date): Date => { + // By default, today + if (!date) return todayAtMidnight(); + // Else, chosen date at midnight + return startOfDay(date); + }) + .refine(val => !val || val >= todayAtMidnight(), { + message: "La date de début de validité ne peut pas être dans le passé." + }), + endDate: z.coerce + .date() + .nullish() + .transform(date => { + if (!date) return date; + return endOfDay(date); + }) + .refine(val => !val || val > new Date(), { + message: "La date de fin de validité ne peut pas être dans le passé." + }), + comment: z.string().max(500).optional() + }) + .refine( + ({ delegateOrgId, delegatorOrgId }) => delegateOrgId !== delegatorOrgId, + { + path: ["delegatorOrgId"], + message: "Le délégant et le délégataire doivent être différents." + } + ) + .refine( + data => { + const { startDate, endDate } = data; + + if (startDate && endDate) { + return startDate < endDate; + } + + return true; + }, + { + path: ["endDate"], + message: "La date de début de validité doit être avant la date de fin." + } + ); + +export const delegationIdSchema = z.object({ + delegationId: idSchema +}); + +export const queryRegistryDelegationsArgsSchema = z.object({ + where: z + .object({ + delegatorOrgId: siretSchema().nullish(), + delegateOrgId: siretSchema().nullish() + }) + .refine( + data => Boolean(data.delegatorOrgId) || Boolean(data.delegateOrgId), + "Vous devez renseigner un des deux champs (delegatorOrgId ou delegateOrgId)." + ) + .refine( + data => !(Boolean(data.delegatorOrgId) && Boolean(data.delegateOrgId)), + "Vous ne pouvez pas renseigner les deux champs (delegatorOrgId et delegateOrgId)." + ), + skip: z.number().nonnegative().nullish(), + first: z.number().min(1).max(50).nullish() +}); diff --git a/back/src/schema.ts b/back/src/schema.ts index f6bb810b5f..678aa997ac 100644 --- a/back/src/schema.ts +++ b/back/src/schema.ts @@ -14,6 +14,7 @@ import registerResolvers from "./registry/resolvers"; import applicationResolvers from "./applications/resolvers"; import webhookResolvers from "./webhooks/resolvers"; import companyDigestResolvers from "./companydigest/resolvers"; +import registryDelegationResolvers from "./registryDelegation/resolvers"; // Merge GraphQL schema by merging types definitions and resolvers // from differents modules @@ -32,7 +33,8 @@ const repositories = [ "registry", "applications", "webhooks", - "companydigest" + "companydigest", + "registryDelegation" ]; const typeDefsPath = repositories.map( @@ -57,7 +59,8 @@ const resolvers = [ registerResolvers, applicationResolvers, webhookResolvers, - companyDigestResolvers + companyDigestResolvers, + registryDelegationResolvers ]; export { typeDefs, resolvers }; diff --git a/back/src/users/typeDefs/user.enums.graphql b/back/src/users/typeDefs/user.enums.graphql index 8b70db73d6..0204d5f4c7 100644 --- a/back/src/users/typeDefs/user.enums.graphql +++ b/back/src/users/typeDefs/user.enums.graphql @@ -66,6 +66,8 @@ enum UserPermission { COMPANY_CAN_MANAGE_MEMBERS "Renouveler le code de signature" COMPANY_CAN_RENEW_SECURITY_CODE + "Créer ou révoquer une délégation pour le registre terres & sédiments" + COMPANY_CAN_MANAGE_REGISTRY_DELEAGATION } """ diff --git a/back/src/utils.ts b/back/src/utils.ts index b0f9415ee1..405c6819de 100644 --- a/back/src/utils.ts +++ b/back/src/utils.ts @@ -58,6 +58,28 @@ export function sameDayMidnight(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } +export function startOfDay(date: Date | string): Date { + const result = new Date(date); + result.setHours(0, 0, 0, 0); + return result; +} + +export function endOfDay(date: Date | string): Date { + const result = new Date(date); + result.setHours(23, 59, 59, 999); + return result; +} + +export function todayAtMidnight(): Date { + return sameDayMidnight(new Date()); +} + +export const nowPlusXHours = (hours: number) => { + const res = new Date(); + res.setHours(res.getHours() + hours); + return res; +}; + /** * Number of days between two dates */ diff --git a/libs/back/mail/src/templates/index.ts b/libs/back/mail/src/templates/index.ts index 0f550536e7..6430a6bd8c 100644 --- a/libs/back/mail/src/templates/index.ts +++ b/libs/back/mail/src/templates/index.ts @@ -1,4 +1,9 @@ -import { BsddTransporter, CompanyVerificationMode, Form } from "@prisma/client"; +import { + BsddTransporter, + Company, + CompanyVerificationMode, + Form +} from "@prisma/client"; import { cleanupSpecialChars, toFrFormat } from "../helpers"; import { MailTemplate } from "../types"; import { templateIds } from "./provider/templateIds"; @@ -244,3 +249,15 @@ export const pendingRevisionRequestAdminDetailsEmail: MailTemplate<{ body: mustacheRenderer("pending-revision-request-admin-details.html"), templateId: templateIds.LAYOUT }; + +export const registryDelegationCreation: MailTemplate<{ + startDate: string; + delegator: Company; + delegate: Company; + endDate?: string; +}> = { + subject: ({ delegator }) => + `Émission d'une demande de délégation de l'établissement ${delegator.name} (${delegator.siret})`, + body: mustacheRenderer("registry-delegation-creation.html"), + templateId: templateIds.LAYOUT +}; diff --git a/libs/back/mail/src/templates/mustache/registry-delegation-creation.html b/libs/back/mail/src/templates/mustache/registry-delegation-creation.html new file mode 100644 index 0000000000..62c6abfda7 --- /dev/null +++ b/libs/back/mail/src/templates/mustache/registry-delegation-creation.html @@ -0,0 +1,26 @@ +

    + La plateforme Trackdéchets vous informe qu'une délégation a été accordée par + l'établissement {{delegator.name}} ({{delegator.siret}}) à l'établissement + {{delegate.name}} ({{delegate.siret}}), effective à partir du {{startDate}} + {{#endDate}} + et jusqu'au {{endDate}}. + {{/endDate}} {{^endDate}} + pour une durée illimitée. + {{/endDate}} Cette délégation permet à l'établissement {{delegate.name}} + ({{delegate.siret}}) de déclarer des registres de déchets règlementaires au + nom de l'établissement {{delegator.name}} ({{delegator.siret}}). +

    + +

    + Pour en savoir plus sur vos obligations en matière de déclarations, nous vous + invitons à consulter cet + article. +

    + +

    + Si vous avez des questions, n'hésitez pas à consulter notre + Foire aux Questions. +

    diff --git a/libs/back/prisma/src/migrations/20241010153237_add_registry_delegation/migration.sql b/libs/back/prisma/src/migrations/20241010153237_add_registry_delegation/migration.sql new file mode 100644 index 0000000000..004311f254 --- /dev/null +++ b/libs/back/prisma/src/migrations/20241010153237_add_registry_delegation/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "RegistryDelegation" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "delegateId" VARCHAR(30) NOT NULL, + "delegatorId" VARCHAR(30) NOT NULL, + "startDate" TIMESTAMPTZ(6) NOT NULL, + "endDate" TIMESTAMPTZ(6), + "comment" VARCHAR(500), + "isRevoked" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "RegistryDelegation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "_RegistryDelegationDelegateIdIdx" ON "RegistryDelegation"("delegateId"); + +-- CreateIndex +CREATE INDEX "_RegistryDelegationDelegatorIdIdx" ON "RegistryDelegation"("delegatorId"); + +-- AddForeignKey +ALTER TABLE "RegistryDelegation" ADD CONSTRAINT "RegistryDelegation_delegateId_fkey" FOREIGN KEY ("delegateId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RegistryDelegation" ADD CONSTRAINT "RegistryDelegation_delegatorId_fkey" FOREIGN KEY ("delegatorId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/libs/back/prisma/src/schema.prisma b/libs/back/prisma/src/schema.prisma index 89c5e6859a..1fe9678ab3 100644 --- a/libs/back/prisma/src/schema.prisma +++ b/libs/back/prisma/src/schema.prisma @@ -312,6 +312,9 @@ model Company { receivedAdministrativeTransfers AdministrativeTransfer[] @relation("AdministrativeTransferTo") webhookSettingsLimit Int @default(1) + delegatorRegistryDelegations RegistryDelegation[] @relation("RegistryDelegationDelegator") + delegateRegistryDelegations RegistryDelegation[] @relation("RegistryDelegationDelegate") + @@index([name], map: "_CompanyNameIdx") @@index([givenName], map: "_CompanyGivenNameIdx") @@index([createdAt], map: "_CompanyCreatedAtIdx") @@ -1085,6 +1088,32 @@ model Bsvhu { @@index([updatedAt], map: "_BsvhuUpdatedAtIdx") } +model RegistryDelegation { + id String @id @default(cuid()) + + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + + // Entreprise délégataire + delegateId String @db.VarChar(30) + delegate Company @relation("RegistryDelegationDelegate", fields: [delegateId], references: [id]) + + // Entreprise délégante + delegatorId String @db.VarChar(30) + delegator Company @relation("RegistryDelegationDelegator", fields: [delegatorId], references: [id]) + + // Dates de validité + startDate DateTime @db.Timestamptz(6) + endDate DateTime? @db.Timestamptz(6) + + comment String? @db.VarChar(500) + + isRevoked Boolean @default(false) + + @@index([delegateId], map: "_RegistryDelegationDelegateIdIdx") + @@index([delegatorId], map: "_RegistryDelegationDelegatorIdIdx") +} + model IntermediaryBsvhuAssociation { id String @id @default(cuid()) name String From 4921b888eb397d2008d769c878b63cc17b51495f Mon Sep 17 00:00:00 2001 From: GaelFerrand <45355989+GaelFerrand@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:58:00 +0200 Subject: [PATCH 033/114] =?UTF-8?q?[TRA-15092]=20ETQ=20utilisateur=20je=20?= =?UTF-8?q?peux=20cr=C3=A9er,=20r=C3=A9voquer=20et=20consulter=20mes=20dem?= =?UTF-8?q?andes=20de=20d=C3=A9l=C3=A9gation=20RNDTS=20(#3588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implemented create endpoint * feat: added endpoint rndtsDeclarationDelegation * refacto: improved condition check on overlap + tests * refacto * feat: checking that delegator & delegate are different * feat: small opti on queries * refacto: making endpoints session only * feat: removed isDeleted, using isRevoked instead + added status sub-resolver * feat: cleaned up dates to midnight * feat: huge refactoring, nesting companies within delegation + started revoke endpoint * test: added test for revocation * fix: fixed tests * feat: added endpoints delegationS * fix: fixes & refacto * fix: fixed test * fix: moved delegate & delegator to subResolvers + dates fixing to zod only * fix: changing where.id to where.orgId * feat: started implemeting front * fix: submitting create form * fix: using .nullish() instead of .optional() * feat: added prisma migration script * feat: started implementing lists * feat: starting table pagination. need backend fix first * feat: changed pagination args * feat: polishes the table * feat: added revoke * feat: trying to fix test * fix: trying to fix tests * fix: fixing typing issues due to sub-resolvers * fix: let's chill on the capslock * fix: trying to fix subresolvers (isDormant error) * feat: adding created delegation to table * feat: added givenName to CompanyPublic * feat: displaying givenName * feat: sending email on creation. Needing content though * fix: displaying error message on required for delegatorId (companySelector) * feat: added email content. Still some details missing * fix: lint * feat: added feature flag on companies * feat: not showing action buttons to non-admins * fix: fixes after reviews with the bowss * fix: fixed email with links * fix: fixed permissions using can() * fix: renamed rndtsDeclarationDelegation -> registryDelegation * feat: re-generated migration script with new name registryDelegation * feat: added events in repository methods * lint: format * feat: renamed rndtsDeclarationDelegation -> registryDelegation * fix: PR review fixes * feat: added endDate in mail --- front/src/Apps/Companies/CompanyDetails.tsx | 19 +- .../CompanyRegistryDelegation.tsx | 18 ++ .../CompanyRegistryDelegationAsDelegate.tsx | 23 ++ .../CompanyRegistryDelegationAsDelegator.tsx | 54 ++++ .../CreateRegistryDelegationModal.tsx | 231 +++++++++++++++++ .../RegistryDelegationsTable.tsx | 234 ++++++++++++++++++ .../RevokeRegistryDelegationModal.tsx | 95 +++++++ .../companyRegistryDelegation.scss | 17 ++ .../Creation/bspaoh/steps/Destination.tsx | 2 +- .../queries/registryDelegation/queries.ts | 68 +++++ 10 files changed, 757 insertions(+), 4 deletions(-) create mode 100644 front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegation.tsx create mode 100644 front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegationAsDelegate.tsx create mode 100644 front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegationAsDelegator.tsx create mode 100644 front/src/Apps/Companies/CompanyRegistryDelegation/CreateRegistryDelegationModal.tsx create mode 100644 front/src/Apps/Companies/CompanyRegistryDelegation/RegistryDelegationsTable.tsx create mode 100644 front/src/Apps/Companies/CompanyRegistryDelegation/RevokeRegistryDelegationModal.tsx create mode 100644 front/src/Apps/Companies/CompanyRegistryDelegation/companyRegistryDelegation.scss create mode 100644 front/src/Apps/common/queries/registryDelegation/queries.ts diff --git a/front/src/Apps/Companies/CompanyDetails.tsx b/front/src/Apps/Companies/CompanyDetails.tsx index bd8f678dbc..da2ff5bdaa 100644 --- a/front/src/Apps/Companies/CompanyDetails.tsx +++ b/front/src/Apps/Companies/CompanyDetails.tsx @@ -22,11 +22,14 @@ import CompanyMembers from "./CompanyMembers/CompanyMembers"; import CompanyDigestSheetForm from "./CompanyDigestSheet/CompanyDigestSheet"; import { Tabs, TabsProps } from "@codegouvfr/react-dsfr/Tabs"; import { FrIconClassName } from "@codegouvfr/react-dsfr"; +import { CompanyRegistryDelegation } from "./CompanyRegistryDelegation/CompanyRegistryDelegation"; export type TabContentProps = { company: CompanyPrivate; }; +const REGISTRY_V2_FLAG = "REGISTRY_V2"; + const buildTabs = ( company: CompanyPrivate ): { @@ -35,6 +38,9 @@ const buildTabs = ( } => { const isAdmin = company.userRole === UserRole.Admin; + // RNDTS features protected by feature flag + const canViewRndtsFeatures = company.featureFlags.includes(REGISTRY_V2_FLAG); + const iconId = "fr-icon-checkbox-line" as FrIconClassName; const tabs = [ { @@ -70,14 +76,21 @@ const buildTabs = ( tab4: CompanyContactForm, tab5: CompanyDigestSheetForm }; - - if (isAdmin) { + if (canViewRndtsFeatures) { tabs.push({ tabId: "tab6", + label: "Délégations RNDTS", + iconId + }); + tabsContent["tab6"] = CompanyRegistryDelegation; + } + if (isAdmin) { + tabs.push({ + tabId: "tab7", label: "Avancé", iconId }); - tabsContent["tab6"] = CompanyAdvanced; + tabsContent["tab7"] = CompanyAdvanced; } return { tabs, tabsContent }; diff --git a/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegation.tsx b/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegation.tsx new file mode 100644 index 0000000000..1443d70d09 --- /dev/null +++ b/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegation.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import "./companyRegistryDelegation.scss"; +import { CompanyPrivate } from "@td/codegen-ui"; +import { CompanyRegistryDelegationAsDelegator } from "./CompanyRegistryDelegationAsDelegator"; +import { CompanyRegistryDelegationAsDelegate } from "./CompanyRegistryDelegationAsDelegate"; + +interface Props { + company: CompanyPrivate; +} + +export const CompanyRegistryDelegation = ({ company }: Props) => { + return ( + <> + + + + ); +}; diff --git a/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegationAsDelegate.tsx b/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegationAsDelegate.tsx new file mode 100644 index 0000000000..5e7087ebd3 --- /dev/null +++ b/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegationAsDelegate.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import "./companyRegistryDelegation.scss"; +import { CompanyPrivate } from "@td/codegen-ui"; +import { RegistryDelegationsTable } from "./RegistryDelegationsTable"; + +interface Props { + company: CompanyPrivate; +} + +export const CompanyRegistryDelegationAsDelegate = ({ company }: Props) => { + return ( + <> +

    Délégataires

    +
    + Les entreprises ci-dessous m'autorisent à faire leurs déclarations au + Registre National des Déchets, Terres Excavées et Sédiments (RNDTS) +
    +
    + +
    + + ); +}; diff --git a/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegationAsDelegator.tsx b/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegationAsDelegator.tsx new file mode 100644 index 0000000000..4758d8901b --- /dev/null +++ b/front/src/Apps/Companies/CompanyRegistryDelegation/CompanyRegistryDelegationAsDelegator.tsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; +import "./companyRegistryDelegation.scss"; +import { CompanyPrivate, UserRole } from "@td/codegen-ui"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { CreateRegistryDelegationModal } from "./CreateRegistryDelegationModal"; +import { RegistryDelegationsTable } from "./RegistryDelegationsTable"; + +interface Props { + company: CompanyPrivate; +} + +export const CompanyRegistryDelegationAsDelegator = ({ company }: Props) => { + const isAdmin = company.userRole === UserRole.Admin; + + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> +

    Délégations

    +
    + J'autorise les entreprises ci-dessous à faire mes déclarations au + Registre National des Déchets, Terres Excavées et Sédiments (RNDTS) +
    + + {isAdmin && ( +
    + +
    + )} + +
    + +
    + + setIsModalOpen(false)} + /> + + ); +}; diff --git a/front/src/Apps/Companies/CompanyRegistryDelegation/CreateRegistryDelegationModal.tsx b/front/src/Apps/Companies/CompanyRegistryDelegation/CreateRegistryDelegationModal.tsx new file mode 100644 index 0000000000..7aea16b09e --- /dev/null +++ b/front/src/Apps/Companies/CompanyRegistryDelegation/CreateRegistryDelegationModal.tsx @@ -0,0 +1,231 @@ +import React from "react"; +import { + CompanyPrivate, + Mutation, + MutationCreateRegistryDelegationArgs +} from "@td/codegen-ui"; +import { FieldError, useForm } from "react-hook-form"; +import { Modal } from "../../../common/components"; +import CompanySelectorWrapper from "../../common/Components/CompanySelectorWrapper/CompanySelectorWrapper"; +import Input from "@codegouvfr/react-dsfr/Input"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { isSiret } from "@td/constants"; +import { datetimeToYYYYMMDD } from "../../Dashboard/Validation/BSPaoh/paohUtils"; +import { startOfDay } from "date-fns"; +import { useMutation } from "@apollo/client"; +import { + CREATE_REGISTRY_DELEGATION, + REGISTRY_DELEGATIONS +} from "../../common/queries/registryDelegation/queries"; +import toast from "react-hot-toast"; + +const displayError = (error: FieldError | undefined) => { + return error ? <>{error.message} : null; +}; + +const getSchema = () => + z + .object({ + delegateOrgId: z + .string({ required_error: "Ce champ est requis" }) + .refine(isSiret, "Siret non valide"), + startDate: z.coerce + .date({ + required_error: "La date de début est requise", + invalid_type_error: "La date de début est invalide" + }) + .min(startOfDay(new Date()), { + message: "La date de début ne peut pas être dans le passé" + }) + .transform(val => val.toISOString()) + .nullish(), + // Date & "" hack: https://github.com/colinhacks/zod/issues/1721 + endDate: z.preprocess( + arg => (arg === "" ? null : arg), + z.coerce + .date({ + invalid_type_error: "La date de fin est invalide" + }) + .min(new Date(), { + message: "La date de fin ne peut pas être dans le passé" + }) + .transform(val => { + if (val) return val.toISOString(); + return val; + }) + .nullish() + ), + comment: z.string().max(500).optional() + }) + .refine( + data => { + const { startDate, endDate } = data; + + if (startDate && endDate) { + return new Date(startDate) < new Date(endDate); + } + + return true; + }, + { + path: ["startDate"], + message: "La date de début doit être avant la date de fin." + } + ); + +interface Props { + company: CompanyPrivate; + isOpen: boolean; + onClose: () => void; +} + +export const CreateRegistryDelegationModal = ({ + company, + onClose, + isOpen +}: Props) => { + const [createRegistryDelegation, { loading }] = useMutation< + Pick, + MutationCreateRegistryDelegationArgs + >(CREATE_REGISTRY_DELEGATION, { + refetchQueries: [REGISTRY_DELEGATIONS] + }); + + const validationSchema = getSchema(); + const { + register, + handleSubmit, + setValue, + watch, + reset, + formState: { errors, isSubmitting } + } = useForm>({ + defaultValues: { + startDate: datetimeToYYYYMMDD(new Date()) + }, + resolver: zodResolver(validationSchema) + }); + + const closeModal = () => { + reset(); + onClose(); + }; + + const onSubmit = async input => { + await createRegistryDelegation({ + variables: { + input: { + ...input, + delegatorOrgId: company.orgId + } + }, + onCompleted: () => toast.success("Délégation créée!"), + onError: err => toast.error(err.message) + }); + + closeModal(); + }; + + const delegateOrgId = watch("delegateOrgId") ?? {}; + + const isLoading = loading || isSubmitting; + + return ( + +
    +
    +

    Créer une délégation

    + + { + if (selectedCompany?.orgId === company.orgId) { + return "Le délégant et le délégataire doivent être différents"; + } + if (!selectedCompany?.siret) { + return "L'entreprise doit avoir un n° de SIRET"; + } + return null; + }} + onCompanySelected={company => { + if (company) { + setValue("delegateOrgId", company.orgId); + } + }} + /> + {errors.delegateOrgId && ( + + {errors.delegateOrgId.message} + + )} + +
    +
    +
    + +
    +
    + +
    +
    +
    + + + +
    + + +
    + +
    +
    + ); +}; diff --git a/front/src/Apps/Companies/CompanyRegistryDelegation/RegistryDelegationsTable.tsx b/front/src/Apps/Companies/CompanyRegistryDelegation/RegistryDelegationsTable.tsx new file mode 100644 index 0000000000..7146e31b65 --- /dev/null +++ b/front/src/Apps/Companies/CompanyRegistryDelegation/RegistryDelegationsTable.tsx @@ -0,0 +1,234 @@ +import React, { useState } from "react"; +import { + CompanyPrivate, + Query, + QueryRegistryDelegationsArgs, + RegistryDelegation, + RegistryDelegationStatus, + UserRole +} from "@td/codegen-ui"; +import { isDefinedStrict } from "../../../common/helper"; +import { formatDateViewDisplay } from "../common/utils"; +import Pagination from "@codegouvfr/react-dsfr/Pagination"; +import "./companyRegistryDelegation.scss"; +import { useQuery } from "@apollo/client"; +import { REGISTRY_DELEGATIONS } from "../../common/queries/registryDelegation/queries"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { RevokeRegistryDelegationModal } from "./RevokeRegistryDelegationModal"; +import Badge from "@codegouvfr/react-dsfr/Badge"; +import { AlertProps } from "@codegouvfr/react-dsfr/Alert"; + +const getStatusLabel = (status: RegistryDelegationStatus) => { + switch (status) { + case RegistryDelegationStatus.Ongoing: + return "EN COURS"; + case RegistryDelegationStatus.Incoming: + return "À VENIR"; + case RegistryDelegationStatus.Closed: + return "CLÔTURÉE"; + } +}; + +const getStatusBadge = (status: RegistryDelegationStatus) => { + let severity: AlertProps.Severity = "success"; + if (status === RegistryDelegationStatus.Incoming) severity = "info"; + if (status === RegistryDelegationStatus.Closed) severity = "error"; + + return ( + + {getStatusLabel(status)} + + ); +}; + +const getTextTooltip = (id: string, value: string | undefined | null) => { + return ( + + ); +}; + +interface Props { + as: "delegator" | "delegate"; + company: CompanyPrivate; +} + +export const RegistryDelegationsTable = ({ as, company }: Props) => { + const [pageIndex, setPageIndex] = useState(0); + const [delegationToRevoke, setDelegationToRevoke] = + useState(null); + + const isAdmin = company.userRole === UserRole.Admin; + + const { data, loading, refetch } = useQuery< + Pick, + QueryRegistryDelegationsArgs + >(REGISTRY_DELEGATIONS, { + skip: !company.orgId, + fetchPolicy: "network-only", + variables: { + where: + as === "delegate" + ? { delegateOrgId: company.orgId } + : { delegatorOrgId: company.orgId } + } + }); + + const totalCount = data?.registryDelegations.totalCount; + const delegations = + data?.registryDelegations.edges.map(edge => edge.node) ?? []; + + const PAGE_SIZE = 10; + const pageCount = totalCount ? Math.ceil(totalCount / PAGE_SIZE) : 0; + + const gotoPage = (page: number) => { + setPageIndex(page); + + refetch({ + skip: page * PAGE_SIZE, + first: PAGE_SIZE + }); + }; + + return ( +
    +
    +
    +
    + + + + + + + + + + {isAdmin && } + + + + {delegations.map(delegation => { + const { + id, + delegate, + delegator, + startDate, + endDate, + comment, + status + } = delegation; + + const company = as === "delegate" ? delegator : delegate; + + const name = isDefinedStrict(company.givenName) + ? company.givenName + : company.name; + + return ( + + + + + + + + {isAdmin && ( + + )} + + ); + })} + + {loading &&

    Chargement...

    } + {!loading && !delegations.length && ( +

    Aucune délégation

    + )} + +
    + Établissement + SiretObjetDébutFinStatutRévoquer
    + {name} + + {getTextTooltip( + `company-name-${company.orgId}-${as}`, + `${name} ${ + company.givenName ? `(${company.name})` : "" + }` + )} + {company?.orgId} + {isDefinedStrict(comment) ? ( + <> + {comment} + {getTextTooltip( + `company-comment-${company.orgId}-${as}`, + comment + )} + + ) : ( + "-" + )} + {formatDateViewDisplay(startDate)} + {endDate ? formatDateViewDisplay(endDate) : "Illimité"} + {getStatusBadge(status)} + {status !== RegistryDelegationStatus.Closed && ( + + )} +
    +
    +
    +
    + +
    + ({ + onClick: event => { + event.preventDefault(); + gotoPage(pageNumber - 1); + }, + href: "#", + key: `pagination-link-${pageNumber}` + })} + className={"fr-mt-1w"} + /> +
    + + {delegationToRevoke && ( + setDelegationToRevoke(null)} + /> + )} +
    + ); +}; diff --git a/front/src/Apps/Companies/CompanyRegistryDelegation/RevokeRegistryDelegationModal.tsx b/front/src/Apps/Companies/CompanyRegistryDelegation/RevokeRegistryDelegationModal.tsx new file mode 100644 index 0000000000..f46a070aaa --- /dev/null +++ b/front/src/Apps/Companies/CompanyRegistryDelegation/RevokeRegistryDelegationModal.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { Mutation, MutationRevokeRegistryDelegationArgs } from "@td/codegen-ui"; +import { Modal } from "../../../common/components"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { useMutation } from "@apollo/client"; +import { REVOKE_REGISTRY_DELEGATION } from "../../common/queries/registryDelegation/queries"; +import toast from "react-hot-toast"; +import { isDefined } from "../../../common/helper"; + +const WarningIcon = () => ( + +); + +interface Props { + delegationId: string; + to: string | null | undefined; + from: string | null | undefined; + onClose: () => void; +} + +export const RevokeRegistryDelegationModal = ({ + delegationId, + to, + from, + onClose +}: Props) => { + const [revokeRegistryDelegation, { loading }] = useMutation< + Pick, + MutationRevokeRegistryDelegationArgs + >(REVOKE_REGISTRY_DELEGATION); + + const onRevoke = async () => { + await revokeRegistryDelegation({ + variables: { + delegationId + }, + onCompleted: () => toast.success("Délégation révoquée!"), + onError: err => toast.error(err.message) + }); + + // Delegation is automatically updated in Apollo's cache + onClose(); + }; + + // Wording changes if delegator or delegate + let title = "Révoquer la délégation"; + let content = `Vous vous apprêtez à révoquer la délégation pour ${to}.`; + let acceptLabel = "Révoquer"; + let refuseLabel = "Ne pas révoquer"; + let closeModalLabel = "Ne pas révoquer"; + + if (isDefined(from)) { + title = "Annuler la délégation"; + content = `Vous vous apprêtez à annuler la délégation de ${from}.`; + acceptLabel = "Annuler"; + refuseLabel = "Ne pas annuler"; + closeModalLabel = "Ne pas annuler"; + } + + return ( + +
    +

    + {title} +

    + +

    {content}

    + +
    + + +
    +
    +
    + ); +}; diff --git a/front/src/Apps/Companies/CompanyRegistryDelegation/companyRegistryDelegation.scss b/front/src/Apps/Companies/CompanyRegistryDelegation/companyRegistryDelegation.scss new file mode 100644 index 0000000000..5a7c9f39f1 --- /dev/null +++ b/front/src/Apps/Companies/CompanyRegistryDelegation/companyRegistryDelegation.scss @@ -0,0 +1,17 @@ +h4 { + font-size: 1.5rem; //24px; + font-weight: 700; + margin: 0; + padding: 0; + margin-bottom: 2vh; +} + +.delegations-table { + th, + td { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/front/src/Apps/Dashboard/Creation/bspaoh/steps/Destination.tsx b/front/src/Apps/Dashboard/Creation/bspaoh/steps/Destination.tsx index cd30c6aa30..347b56004c 100644 --- a/front/src/Apps/Dashboard/Creation/bspaoh/steps/Destination.tsx +++ b/front/src/Apps/Dashboard/Creation/bspaoh/steps/Destination.tsx @@ -98,7 +98,7 @@ export function Destination({ errors }) { ); const selectedCompanyError = (company?: CompanySearchResult) => { - // Le destinatiare doi être inscrit et avec un profil crématorium ou sous-type crémation + // Le destinatiare doit être inscrit et avec un profil crématorium ou sous-type crémation // Le profil crématorium sera bientôt supprimé if (company) { if (!company.isRegistered) { diff --git a/front/src/Apps/common/queries/registryDelegation/queries.ts b/front/src/Apps/common/queries/registryDelegation/queries.ts new file mode 100644 index 0000000000..2e341cfbbb --- /dev/null +++ b/front/src/Apps/common/queries/registryDelegation/queries.ts @@ -0,0 +1,68 @@ +import { gql } from "@apollo/client"; + +export const CREATE_REGISTRY_DELEGATION = gql` + mutation createRegistryDelegation($input: CreateRegistryDelegationInput!) { + createRegistryDelegation(input: $input) { + id + updatedAt + delegate { + orgId + } + delegator { + orgId + } + startDate + endDate + comment + status + } + } +`; + +export const REGISTRY_DELEGATIONS = gql` + query registryDelegations( + $skip: Int + $first: Int + $where: RegistryDelegationWhere + ) { + registryDelegations(skip: $skip, first: $first, where: $where) { + totalCount + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + edges { + node { + id + updatedAt + delegate { + name + givenName + orgId + } + delegator { + name + givenName + orgId + } + startDate + endDate + comment + status + } + } + } + } +`; + +export const REVOKE_REGISTRY_DELEGATION = gql` + mutation revokeRegistryDelegation($delegationId: ID!) { + revokeRegistryDelegation(delegationId: $delegationId) { + id + isRevoked + status + } + } +`; From 9d834aabe06130cecef7c8bd5cf38bd1ecf5f8b0 Mon Sep 17 00:00:00 2001 From: JulianaJM <37509748+JulianaJM@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:40:58 +0200 Subject: [PATCH 034/114] =?UTF-8?q?[tra-15002]Permettre=20de=20viser=20un?= =?UTF-8?q?=20=C3=A9metteur=20en=20situation=20irr=C3=A9guli=C3=A8re=20ave?= =?UTF-8?q?c=20ou=20sans=20SIRET=20et=20retrait=20de=20la=20signature=20?= =?UTF-8?q?=C3=A9metteur=20-=20FRONT=20(#3644)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Apps/Dashboard/Creation/bspaoh/schema.ts | 12 +-- .../Apps/Dashboard/Creation/bsvhu/schema.ts | 13 +--- .../Creation/bsvhu/steps/Emitter.tsx | 77 ++++++++++++++++++- .../Creation/bsvhu/utils/initial-state.ts | 4 +- .../Apps/common/queries/fragments/bsvhu.ts | 1 + .../detail/bsvhu/BsvhuDetailContent.tsx | 15 +++- .../dsfr-work-site/DsfrfWorkSiteAddress.tsx | 74 +++++++++--------- 7 files changed, 136 insertions(+), 60 deletions(-) diff --git a/front/src/Apps/Dashboard/Creation/bspaoh/schema.ts b/front/src/Apps/Dashboard/Creation/bspaoh/schema.ts index 2bc293a3ba..df0ce369d2 100644 --- a/front/src/Apps/Dashboard/Creation/bspaoh/schema.ts +++ b/front/src/Apps/Dashboard/Creation/bspaoh/schema.ts @@ -36,17 +36,7 @@ const zodWaste = z.object({ const zodEmitter = z .object({ - company: zodCompany.superRefine((val, ctx) => { - if (!val?.siret) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["orgId"], - message: - "Vous ne pouvez pas créer un bordereau sur lequel votre entreprise n'apparaît pas" - }); - } - }), - + company: zodCompany, emission: z.object({ detail: z.object({ quantity: z.coerce.number().nonnegative().nullish(), diff --git a/front/src/Apps/Dashboard/Creation/bsvhu/schema.ts b/front/src/Apps/Dashboard/Creation/bsvhu/schema.ts index 62e5d76981..fd7feb66b6 100644 --- a/front/src/Apps/Dashboard/Creation/bsvhu/schema.ts +++ b/front/src/Apps/Dashboard/Creation/bsvhu/schema.ts @@ -11,17 +11,10 @@ const zodCompany = z.object({ }); const zodEmitter = z.object({ - company: zodCompany.superRefine((val, ctx) => { - if (!val?.siret) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["orgId"], - message: - "Vous ne pouvez pas créer un bordereau sur lequel votre entreprise n'apparaît pas" - }); - } - }), + company: zodCompany, agrementNumber: z.string().nullish(), + irregularSituation: z.boolean(), + noSiret: z.boolean(), emission: z.object({ signature: z.object({ author: z.string().nullish(), diff --git a/front/src/Apps/Dashboard/Creation/bsvhu/steps/Emitter.tsx b/front/src/Apps/Dashboard/Creation/bsvhu/steps/Emitter.tsx index 704419a23c..5c99390411 100644 --- a/front/src/Apps/Dashboard/Creation/bsvhu/steps/Emitter.tsx +++ b/front/src/Apps/Dashboard/Creation/bsvhu/steps/Emitter.tsx @@ -4,12 +4,14 @@ import React, { useEffect, useMemo, useContext } from "react"; import { useFormContext } from "react-hook-form"; import CompanySelectorWrapper from "../../../../common/Components/CompanySelectorWrapper/RhfCompanySelectorWrapper"; -import { FavoriteType } from "@td/codegen-ui"; +import { CompanySearchResult, FavoriteType } from "@td/codegen-ui"; import { useParams } from "react-router-dom"; import CompanyContactInfo from "../../../../Forms/Components/RhfCompanyContactInfo/RhfCompanyContactInfo"; import DisabledParagraphStep from "../../DisabledParagraphStep"; import { SealedFieldsContext } from "../../../../Dashboard/Creation/context"; import { setFieldError } from "../../utils"; +import Checkbox from "@codegouvfr/react-dsfr/Checkbox"; +import DsfrfWorkSiteAddress from "../../../../../form/common/components/dsfr-work-site/DsfrfWorkSiteAddress"; const EmitterBsvhu = ({ errors }) => { const { siret } = useParams<{ siret: string }>(); @@ -26,6 +28,8 @@ const EmitterBsvhu = ({ errors }) => { register("emitter.company.name"); register("emitter.company.vatNumber"); register("emitter.company.address"); + register("emitter.irregularSituation"); + register("emitter.noSiret"); }, [register]); useEffect(() => { @@ -42,6 +46,13 @@ const EmitterBsvhu = ({ errors }) => { setError ); + setFieldError( + errors, + `${actor}.company.name`, + formState.errors?.[actor]?.["company"]?.name, + setError + ); + setFieldError( errors, `${actor}.company.contact`, @@ -101,16 +112,38 @@ const EmitterBsvhu = ({ errors }) => { [emitter?.company?.orgId, emitter?.company?.siret] ); + const selectedCompanyError = (company?: CompanySearchResult) => { + // L'émetteur est en situation irrégulière mais il a un SIRET et n'est pas inscrit sur Trackdéchets + if (company) { + if (!company.isRegistered) { + return "L'entreprise n'est pas inscrite sur Trackdéchets, la signature Producteur ne pourra pas se faire. Vous pouvez publier le bordereau, mais seul le transporteur pourra le signer."; + } + } + return null; + }; + return ( <> {!!sealedFields.length && }
    +

    Entreprise

    { if (company) { let companyData = { @@ -174,6 +207,48 @@ const EmitterBsvhu = ({ errors }) => { {formState.errors?.emitter?.["company"]?.siret?.message}

    )} + + + + { + // `address` is passed as `name` because of adresse api return fields + setValue(`emitter.company.address`, details.name); + setValue(`emitter.company.city`, details.city); + setValue(`emitter.company.postalCode`, details.postcode); + }} + /> + + {emitter.noSiret && ( +
    + +
    + )} + + {isIrregularSituation && ( + <> +
    +
    Installation en situation irrégulière
    + + )} @@ -136,7 +143,11 @@ function Emitter({ form }: { form: Bsvhu }) { return (
    - +
    - + dispatch({ type: "search_input", payload: e.target.value }) } @@ -112,7 +113,8 @@ export default function DsfrfWorkSiteAddress({ { setShowAdressFields(e); }} @@ -120,8 +122,8 @@ export default function DsfrfWorkSiteAddress({ /> {showAdressFields && ( -
    -
    +
    +
    -
    - - setManualAddress({ - address: state.address, - postalCode: state.postalCode, - city: e.target.value - }) - }} - /> -
    -
    - - setManualAddress({ - address: state.address, - city: state.city, - postalCode: e.target.value - }) - }} - /> +
    +
    + + setManualAddress({ + address: state.address, + city: state.city, + postalCode: e.target.value + }) + }} + /> +
    +
    + + setManualAddress({ + address: state.address, + postalCode: state.postalCode, + city: e.target.value + }) + }} + /> +
    )} - {state.searchResults.map(feature => ( + {state.searchResults?.map(feature => (
    Date: Mon, 14 Oct 2024 11:42:24 +0200 Subject: [PATCH 035/114] =?UTF-8?q?[tra-14931]Header=20-=20Corriger=20l'af?= =?UTF-8?q?fichage=20des=20=C3=A9l=C3=A9ments=20de=20navigation=20principa?= =?UTF-8?q?le=20et=20du=20tableau=20de=20bord=20lors=20d'un=20zoom=20texte?= =?UTF-8?q?=20=C3=A0=20200%=20(#3655)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Apps/common/Components/layout/Header.module.scss | 8 +++++++- front/src/Apps/common/Components/layout/Header.tsx | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/front/src/Apps/common/Components/layout/Header.module.scss b/front/src/Apps/common/Components/layout/Header.module.scss index 6ab45ff43e..d5e3dae3b6 100644 --- a/front/src/Apps/common/Components/layout/Header.module.scss +++ b/front/src/Apps/common/Components/layout/Header.module.scss @@ -4,7 +4,7 @@ .header { background-color: #fff; width: 100%; - height: 56px; + height: auto; border-bottom: 1px solid rgb(246, 246, 246); display: flex; box-shadow: 0px 2px 6px 0px rgba(0, 0, 12, 0.16); @@ -54,6 +54,12 @@ } } + .headerNavList { + @include desktop { + flex-direction: row; + } + } + .headerActions { margin-left: auto; display: none; diff --git a/front/src/Apps/common/Components/layout/Header.tsx b/front/src/Apps/common/Components/layout/Header.tsx index 3ef5e3bb1a..d7c3c36650 100644 --- a/front/src/Apps/common/Components/layout/Header.tsx +++ b/front/src/Apps/common/Components/layout/Header.tsx @@ -766,7 +766,7 @@ export default function Header({
    - {isDuplicating && } + {(isDuplicating || isCloningBsd) && } , MutationCloneBsdArgs> +) { + return useMutation, MutationCloneBsdArgs>( + CLONE_BSD, + { + ...options, + onCompleted: (...args) => { + toast.success(message); + + if (options.onCompleted) { + options.onCompleted(...args); + } + }, + onError: err => { + toastApolloError(err, startErrorMessage); + } + } + ); +} diff --git a/front/src/Apps/Dashboard/dashboardServices.ts b/front/src/Apps/Dashboard/dashboardServices.ts index 94a01e00a3..bccc1119af 100644 --- a/front/src/Apps/Dashboard/dashboardServices.ts +++ b/front/src/Apps/Dashboard/dashboardServices.ts @@ -1265,6 +1265,10 @@ export const canDuplicate = (bsd, siret) => canDuplicateBsvhu(bsd) || canDuplicateBspaoh(bsd); +export const canClone = () => { + return import.meta.env.VITE_ALLOW_CLONING_BSDS === "true"; +}; + const canDeleteBsff = (bsd, siret) => bsd.type === BsdType.Bsff && (bsd.status === BsdStatusCode.Initial || diff --git a/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts b/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts index 697050af8d..02b8c4a0af 100644 --- a/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts +++ b/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts @@ -51,6 +51,7 @@ export const modifier_action_label = "Modifier"; export const dupliquer_action_label = "Dupliquer"; export const revision_action_label = "Réviser"; export const supprimer_action_label = "Supprimer"; +export const clone_action_label = "Cloner"; export const completer_bsd_suite = "Compléter le BSD suite"; export const annexe1 = "Annexe 1"; From b6de3fa8cb1ae021348f63600dc7dabf3e588ba6 Mon Sep 17 00:00:00 2001 From: Julien Seren-Rosso Date: Mon, 14 Oct 2024 16:35:24 +0200 Subject: [PATCH 039/114] Adds keyboard navigation to the dashboard (#3658) --- front/index.html | 2 +- .../Dashboard/Components/BsdCard/BsdCard.tsx | 10 ++- .../Components/BsdCard/bsdCard.test.tsx | 32 ++++++++++ .../Components/BsdCard/bsdCardTypes.ts | 2 + .../Components/BsdCardList/BsdCardList.tsx | 62 ++++++++++++++++++- front/src/Pages/Dashboard.tsx | 2 +- 6 files changed, 105 insertions(+), 5 deletions(-) diff --git a/front/index.html b/front/index.html index a2acc7c7b9..07a122c849 100644 --- a/front/index.html +++ b/front/index.html @@ -24,7 +24,7 @@ -
    +
    {bsdDisplay && ( <>
    diff --git a/front/src/Apps/Dashboard/Components/BsdCard/bsdCard.test.tsx b/front/src/Apps/Dashboard/Components/BsdCard/bsdCard.test.tsx index 8d31ad3d09..f6aafa2371 100644 --- a/front/src/Apps/Dashboard/Components/BsdCard/bsdCard.test.tsx +++ b/front/src/Apps/Dashboard/Components/BsdCard/bsdCard.test.tsx @@ -164,6 +164,8 @@ describe("Bsd card primary action label", () => { { { { { { { { { { { { { { { { void; diff --git a/front/src/Apps/Dashboard/Components/BsdCardList/BsdCardList.tsx b/front/src/Apps/Dashboard/Components/BsdCardList/BsdCardList.tsx index 81ab79c1c9..2ab6dc5a57 100644 --- a/front/src/Apps/Dashboard/Components/BsdCardList/BsdCardList.tsx +++ b/front/src/Apps/Dashboard/Components/BsdCardList/BsdCardList.tsx @@ -45,6 +45,7 @@ import { formatBsd, mapBsdasri } from "../../bsdMapper"; import RevisionModal, { ActionType } from "../Revision/RevisionModal"; import "./bsdCardList.scss"; +import { useKeyboard } from "react-aria"; function BsdCardList({ siret, @@ -79,6 +80,61 @@ function BsdCardList({ [navigate, location, siret] ); + const { keyboardProps } = useKeyboard({ + onKeyDown: event => { + const key = event.key; + const focusedElement = document.activeElement; + + if (!focusedElement) { + return; + } + + const focusedArticle = + focusedElement.role === "article" + ? focusedElement + : focusedElement.closest("div[role=article]"); + + if (!focusedArticle) { + return; + } + + const focusedIndex = Number(focusedArticle.getAttribute("aria-posinset")); + + const articles = document.querySelectorAll("div[role=article]"); + + switch (key) { + case "PageUp": + event.preventDefault(); + if (focusedIndex > 0) { + const previousArticle = articles[focusedIndex - 1] as HTMLElement; + previousArticle.focus(); + } + break; + case "PageDown": + event.preventDefault(); + if (articles.length >= focusedIndex) { + const nextArticle = articles[focusedIndex + 1] as HTMLElement; + nextArticle.focus(); + } + break; + case "Home": + if (event.ctrlKey && articles.length > 0) { + event.preventDefault(); + const firstArticle = articles[0] as HTMLElement; + firstArticle.focus(); + } + break; + case "End": + if (event.ctrlKey) { + event.preventDefault(); + const lastArticle = articles[articles.length - 1] as HTMLElement; + lastArticle.focus(); + } + break; + } + } + }); + const [validationWorkflowType, setValidationWorkflowType] = useState(); const [bsdClicked, setBsdClicked] = useState(); @@ -383,8 +439,8 @@ function BsdCardList({ return ( <> -