diff --git a/.env.model b/.env.model index 5bae6ec850..b3dfc6ced4 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 @@ -87,6 +91,9 @@ VERIFIED_FOREIGN_TRANSPORTER_COMPANY_TEMPLATE_ID=********* # Prefer minutes different from 0 to avoid traffic congestion CRON_ONBOARDING_SCHEDULE=8 8 * * * +# Daily cron job to cleanup isReturnFor tab +CRON_CLEANUP_IS_RETURN_TAB_SCHEDULE=0 2 * * * + # SendInBlue configuration SIB_BASE_URL=******** SIB_APIKEY=******** 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 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 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 324e69d266..cd4ea983de 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -39,4 +39,5 @@ jobs: TD_COMPANY_ELASTICSEARCH_URL: ${{ secrets.TD_COMPANY_ELASTICSEARCH_URL }} TD_COMPANY_ELASTICSEARCH_CACERT: ${{ secrets.TD_COMPANY_ELASTICSEARCH_CACERT }} OTEL_SDK_DISABLED: true + ELASTIC_SEARCH_URL: http://elasticsearch:9200 - run: npx nx affected -t build --parallel=3 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3b009cc015..3fd919dad4 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 @@ -211,6 +215,7 @@ env: # Misc CRON_ONBOARDING_SCHEDULE: false + CRON_CLEANUP_IS_RETURN_TAB_SCHEDULE: false VERIFY_COMPANY: false ALLOW_TEST_COMPANY: true VITE_ALLOW_TEST_COMPANY: true diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index cdde3ffc45..b459231b23 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -33,9 +33,10 @@ jobs: sleep 5 - name: Run integration tests run: | - npx nx run back:test:integration -- --shard=${{ matrix.shard }}/${{ strategy.job-total }} + npx nx run-many --target=test --projects=back,cron --configuration=integration --parallel=1 -- --shard=${{ matrix.shard }}/${{ strategy.job-total }} env: + ALLOW_CLONING_BSDS: true TD_COMPANY_ELASTICSEARCH_URL: ${{ secrets.TD_COMPANY_ELASTICSEARCH_URL }} TD_COMPANY_ELASTICSEARCH_CACERT: ${{ secrets.TD_COMPANY_ELASTICSEARCH_CACERT }} NODE_ENV: test 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 03fc0fb303..e38bf6d0be 100644 --- a/Changelog.md +++ b/Changelog.md @@ -5,6 +5,43 @@ 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 + +#### :rocket: Nouvelles fonctionnalités + +- Ajout d'Eco-organisme sur BSVHU [PR 3619](https://github.com/MTES-MCT/trackdechets/pull/3619) +- Ajout des profils Négociant et Courtier sur BSVHU [PR 3645](https://github.com/MTES-MCT/trackdechets/pull/3645) +- Ajout d'un moteur de recherche sur la documentation développeurs [PR 3622](https://github.com/MTES-MCT/trackdechets/pull/3622) +- Ajout d'un nouvel onglet "Retours" pour les transporteurs [PR 3669](https://github.com/MTES-MCT/trackdechets/pull/3669) + +#### :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) +- ETQ membre d'un établissement, je peux gérer mes préférences de notifications (demandes de rattachement, demandes de révisions, renouvellement code signature, etc) en lien avec cet établissement [PR 3634](https://github.com/MTES-MCT/trackdechets/pull/3634) +- Amélioration du contenu de l'e-mail transactionnel envoyé au contact d'un établissement visé sur un bordereau en tant qu'émetteur [PR 3635](https://github.com/MTES-MCT/trackdechets/pull/3635) +- Rendre les brouillons BSVHU non accessibles aux entreprises mentionnées sur le bordereau mais qui n'en sont pas les auteurs [PR 3677](https://github.com/MTES-MCT/trackdechets/pull/3677) + +#### :boom: Breaking changes + +- La Raison Sociale, le SIRET et l'Adresse de la destination sont scellés à la signature émetteur, sauf pour l'émetteur qui doit pouvoir le modifier jusqu'à la prochaine signature [PR 3628](https://github.com/MTES-MCT/trackdechets/pull/3628) + +- La complétion du champ identificationNumbers est obligatoire à la publication d'un VHU [PR 3628](https://github.com/MTES-MCT/trackdechets/pull/3628) + +#### :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) +- Correction du poids d'une annexe 1 après la révision du poids d'un BSD enfant [PR 3631](https://github.com/MTES-MCT/trackdechets/pull/3631) +- Retrait du bouton de revision dans l'UI sur les DASRI regroupés [PR 3657](https://github.com/MTES-MCT/trackdechets/pull/3657) + +#### :house: Interne + +- Migration vers le nouveau portail API de l'INSEE [PR 3602](https://github.com/MTES-MCT/trackdechets/pull/3602) +- Suppression du champ isRegistreNational [PR 3652](https://github.com/MTES-MCT/trackdechets/pull/3652) +- ETQ utilisateur je peux cloner un BSD [PR 3637](https://github.com/MTES-MCT/trackdechets/pull/3637) +- ETQ utilisateur je peux créer, révoquer et consulter mes demandes de délégation RNDTS [PR 3561](https://github.com/MTES-MCT/trackdechets/pull/3561) [PR 3588](https://github.com/MTES-MCT/trackdechets/pull/3588) +- Ajout de la query controlBsds dédiée à la fiche établissment [PR 3694](https://github.com/MTES-MCT/trackdechets/pull/3694) + # [2024.9.1] 24/09/2024 #### :rocket: Nouvelles fonctionnalités @@ -1019,7 +1056,7 @@ et le projet suit un schéma de versionning inspiré de [Calendar Versioning](ht #### :house: Interne - Meilleure gestion des ré-indexations de BSD sans interruption du service avec `npm run reindex-all-bsds-bulk` et parallélisation avec la job queue avec l'option `--useQueue` [PR1706](https://github.com/MTES-MCT/trackdechets/pull/1706). -- Création de nouveau jobs `indexAllInBulk` et `indexChunk` pour la queue d'indexation, , création d'un groupe de workers de job spécifiques pour l'indexation `indexQueue` [PR1706](https://github.com/MTES-MCT/trackdechets/pull/1706). +- Création de nouveau jobs `indexAllInBulk` et `indexChunk` pour la queue d'indexation, création d'un groupe de workers de job spécifiques pour l'indexation `indexQueue` [PR1706](https://github.com/MTES-MCT/trackdechets/pull/1706). - Refonte d'un script de reindexation partielle avec interruption du service `npm run reindex-partial-in-place` avec une demande de confirmation dans la console [PR1706](https://github.com/MTES-MCT/trackdechets/pull/1706). - Création d'un nouveau script pour vider de force une queue par son nom `npm run queue:obliterate` [PR1706](https://github.com/MTES-MCT/trackdechets/pull/1706). - Suppression du script `bin/indexElasticSearch.ts` au profit des scripts `reindex*.ts` [PR1706](https://github.com/MTES-MCT/trackdechets/pull/1706). diff --git a/apps/cron/src/commands/__tests__/onboarding.helpers.integration.ts b/apps/cron/src/commands/__tests__/onboarding.helpers.integration.ts index 629a40b34c..7fda0353f3 100644 --- a/apps/cron/src/commands/__tests__/onboarding.helpers.integration.ts +++ b/apps/cron/src/commands/__tests__/onboarding.helpers.integration.ts @@ -2,6 +2,7 @@ import { BsdaRevisionRequest, BsdasriRevisionRequest, MembershipRequestStatus, + UserNotification, UserRole } from "@prisma/client"; import { prisma } from "@td/prisma"; @@ -24,12 +25,12 @@ import { bsdaFactory } from "back/src/bsda/__tests__/factories"; import { bsdasriFactory } from "back/src/bsdasris/__tests__/factories"; import { BsddRevisionRequestWithReadableId, - addPendingApprovalsCompanyAdmins, + addPendingApprovalsCompanySubscribers, getActiveUsersWithPendingMembershipRequests, - getPendingBSDARevisionRequestsWithAdmins, - getPendingBSDDRevisionRequestsWithAdmins, - getPendingBSDASRIRevisionRequestsWithAdmins, - getPendingMembershipRequestsAndAssociatedAdmins, + getPendingBSDARevisionRequestsWithSubscribers, + getPendingBSDDRevisionRequestsWithSubscribers, + getPendingBSDASRIRevisionRequestsWithSubscribers, + getPendingMembershipRequestsAndAssociatedSubscribers, getRecentlyRegisteredProducers, getRecentlyRegisteredProfesionals, getRecentlyRegisteredUsersWithNoCompanyNorMembershipRequest @@ -279,16 +280,40 @@ describe("getActiveUsersWithPendingMembershipRequests", () => { }); }); -describe("getPendingMembershipRequestsAndAssociatedAdmins", () => { +describe("getPendingMembershipRequestsAndAssociatedMailSubscribers ", () => { afterEach(resetDatabase); - it("should return pending membership requests created X days ago with all associated company admins", async () => { + it("should return pending membership requests created X days ago with all associated notification subscribers", async () => { const user0 = await userFactory(); const user1 = await userFactory(); const user2 = await userFactory(); const user3 = await userFactory({ isActive: false }); - const companyAndAdmin0 = await userWithCompanyFactory("ADMIN"); + const companyAndAdmin0 = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.MEMBERSHIP_REQUEST] } + ); + + // crée un second admin abonné aux notifications de demandes de rattachement + const admin01 = await userFactory(); + await associateUserToCompany( + admin01.id, + companyAndAdmin0.company.orgId, + UserRole.ADMIN, + { notifications: [UserNotification.MEMBERSHIP_REQUEST] } + ); + + // crée un troisième admin qui n'est pas abonné aux notifications de demandes de rattachement + const admin02 = await userFactory(); + await associateUserToCompany( + admin02.id, + companyAndAdmin0.company.orgId, + UserRole.ADMIN, + { notifications: [] } + ); + const companyAndAdmin1 = await userWithCompanyFactory("ADMIN"); const companyAndAdmin2 = await userWithCompanyFactory("ADMIN"); const companyAndAdmin3 = await userWithCompanyFactory("ADMIN"); @@ -321,87 +346,36 @@ describe("getPendingMembershipRequestsAndAssociatedAdmins", () => { status: MembershipRequestStatus.PENDING }); - const requests = await getPendingMembershipRequestsAndAssociatedAdmins(3); - - expect(requests.length).toEqual(1); - - const expectedResult = [ - { - requestId: request0.id, - email: user0.email, - orgId: companyAndAdmin0.company.orgId, - adminIds: [companyAndAdmin0.user.id] - } - ]; - - const actualResult = requests.map(r => ({ - requestId: r.id, - email: r.user.email, - orgId: r.company.orgId, - adminIds: r.company.companyAssociations.map(a => a.user.id).sort() - })); - - expect(expectedResult).toEqual(actualResult); - }); - - it("should return ALL active admins from request's company", async () => { - const user0 = await userFactory(); - - // Should be returned - const companyAndAdmin0 = await userWithCompanyFactory("ADMIN"); - - // Should be returned - const admin1 = await userFactory({ - companyAssociations: { - create: { - company: { connect: { id: companyAndAdmin0.company.id } }, - role: "ADMIN" - } - } - }); - - // Should not be returned, because unactive - await userFactory({ - isActive: false, - companyAssociations: { - create: { - company: { connect: { id: companyAndAdmin0.company.id } }, - role: "ADMIN" - } - } - }); - - // Should be returned, along with both admins - const request0 = await createMembershipRequest( - user0, - companyAndAdmin0.company, - { - createdAt: THREE_DAYS_AGO, - status: MembershipRequestStatus.PENDING - } + const requests = await getPendingMembershipRequestsAndAssociatedSubscribers( + 3 ); - const requests = await getPendingMembershipRequestsAndAssociatedAdmins(3); - expect(requests.length).toEqual(1); - const expectedResult = [ - { - requestId: request0.id, - email: user0.email, - orgId: companyAndAdmin0.company.orgId, - adminIds: [companyAndAdmin0.user.id, admin1.id].sort() - } - ]; - - const actualResult = requests.map(r => ({ - requestId: r.id, - email: r.user.email, - orgId: r.company.orgId, - adminIds: r.company.companyAssociations.map(a => a.user.id).sort() - })); - - expect(expectedResult).toEqual(actualResult); + expect(requests).toEqual([ + expect.objectContaining({ + id: request0.id, + user: expect.objectContaining({ email: user0.email }), + company: expect.objectContaining({ + orgId: companyAndAdmin0.company.orgId, + companyAssociations: [ + expect.objectContaining({ + user: expect.objectContaining({ + email: companyAndAdmin0.user.email + }) + }), + expect.objectContaining({ + user: expect.objectContaining({ + email: admin01.email + }) + }) + // admin02 ne doit pas être présent ici car + // il n'est pas abonné aux notifications de + // demandes de rattachement + ] + }) + }) + ]); }); it("should return all requests from a user, even if targeting different companies", async () => { @@ -433,39 +407,34 @@ describe("getPendingMembershipRequestsAndAssociatedAdmins", () => { } ); - const requests = await getPendingMembershipRequestsAndAssociatedAdmins(3); + const requests = await getPendingMembershipRequestsAndAssociatedSubscribers( + 3 + ); expect(requests.length).toEqual(2); - const expectedResult = [ - { - requestId: request0.id, - email: user0.email, - orgId: companyAndAdmin0.company.orgId, - adminIds: [companyAndAdmin0.user.id] - }, - { - requestId: request1.id, - email: user0.email, - orgId: companyAndAdmin1.company.orgId, - adminIds: [companyAndAdmin1.user.id] - } - ].sort((a, b) => a.requestId.localeCompare(b.requestId)); - - const actualResult = requests - .map(r => ({ - requestId: r.id, - email: r.user.email, - orgId: r.company.orgId, - adminIds: r.company.companyAssociations.map(a => a.user.id).sort() - })) - .sort((a, b) => a.requestId.localeCompare(b.requestId)); - - expect(expectedResult).toEqual(actualResult); + expect( + requests.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + ).toEqual([ + expect.objectContaining({ + id: request0.id, + company: expect.objectContaining({ + orgId: companyAndAdmin0.company.orgId + }), + user: expect.objectContaining({ email: user0.email }) + }), + expect.objectContaining({ + id: request1.id, + company: expect.objectContaining({ + orgId: companyAndAdmin1.company.orgId + }), + user: expect.objectContaining({ email: user0.email }) + }) + ]); }); }); -describe("getPendingBSDARevisionRequestsWithAdmins", () => { +describe("getPendingBSDARevisionRequestsWithSubscribers", () => { afterEach(resetDatabase); it("should return empty array if DB is empty", async () => { @@ -473,15 +442,20 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // When const pendingBSDARevisionRequest = - await getPendingBSDARevisionRequestsWithAdmins(2); + await getPendingBSDARevisionRequestsWithSubscribers(2); // Then expect(pendingBSDARevisionRequest.length).toEqual(0); }); - it("should return pending request and company admins", async () => { + it("should return pending request and subscribers to REVISION_REQUEST notifications", async () => { // Given - const { user, company } = await userWithCompanyFactory("ADMIN"); + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -505,7 +479,7 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // When const pendingBSDARevisionRequest = - await getPendingBSDARevisionRequestsWithAdmins(2); + await getPendingBSDARevisionRequestsWithSubscribers(2); // Then expect(pendingBSDARevisionRequest.length).toEqual(1); @@ -517,13 +491,15 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { expect(pendingBSDARevisionRequest[0].approvals[0].company.id).toEqual( company.id ); - expect(pendingBSDARevisionRequest[0].approvals[0].admins.length).toEqual(1); - expect(pendingBSDARevisionRequest[0].approvals[0].admins[0].id).toEqual( - user.id - ); + expect( + pendingBSDARevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect( + pendingBSDARevisionRequest[0].approvals[0].subscribers[0].id + ).toEqual(user.id); }); - it("should not return pending request and company admins NOT xDaysAgo", async () => { + it("should not return pending request and subscribers NOT xDaysAgo", async () => { // Given const { company } = await userWithCompanyFactory("ADMIN"); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( @@ -568,7 +544,7 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // When const pendingBSDARevisionRequest = - await getPendingBSDARevisionRequestsWithAdmins(2); + await getPendingBSDARevisionRequestsWithSubscribers(2); // Then expect(pendingBSDARevisionRequest.length).toEqual(0); @@ -602,7 +578,7 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // When const pendingBSDARevisionRequest = - await getPendingBSDARevisionRequestsWithAdmins(2); + await getPendingBSDARevisionRequestsWithSubscribers(2); // Then expect(pendingBSDARevisionRequest.length).toEqual(0); @@ -612,7 +588,12 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // Given // Request 1 - const { user, company } = await userWithCompanyFactory("ADMIN"); + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -636,7 +617,10 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // Request 2 const { user: user2, company: company2 } = await userWithCompanyFactory( - "ADMIN" + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } ); const { company: companyOfSomeoneElse2 } = await userWithCompanyFactory( "ADMIN" @@ -661,7 +645,7 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // When const pendingBSDARevisionRequest = - await getPendingBSDARevisionRequestsWithAdmins(2); + await getPendingBSDARevisionRequestsWithSubscribers(2); // Then expect(pendingBSDARevisionRequest.length).toEqual(2); @@ -671,10 +655,12 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { (pendingBSDARevisionRequest[0] as BsdaRevisionRequest).bsdaId ).toEqual(bsda.id); expect(pendingBSDARevisionRequest[0].approvals.length).toEqual(1); - expect(pendingBSDARevisionRequest[0].approvals[0].admins.length).toEqual(1); - expect(pendingBSDARevisionRequest[0].approvals[0].admins[0].id).toEqual( - user.id - ); + expect( + pendingBSDARevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect( + pendingBSDARevisionRequest[0].approvals[0].subscribers[0].id + ).toEqual(user.id); expect(pendingBSDARevisionRequest[0].approvals[0].company.id).toEqual( company.id ); @@ -684,20 +670,30 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { (pendingBSDARevisionRequest[1] as BsdaRevisionRequest).bsdaId ).toEqual(bsda2.id); expect(pendingBSDARevisionRequest[1].approvals.length).toEqual(1); - expect(pendingBSDARevisionRequest[1].approvals[0].admins.length).toEqual(1); - expect(pendingBSDARevisionRequest[1].approvals[0].admins[0].id).toEqual( - user2.id - ); + expect( + pendingBSDARevisionRequest[1].approvals[0].subscribers.length + ).toEqual(1); + expect( + pendingBSDARevisionRequest[1].approvals[0].subscribers[0].id + ).toEqual(user2.id); expect(pendingBSDARevisionRequest[1].approvals[0].company.id).toEqual( company2.id ); }); - it("should return all admins from pending companies", async () => { + it("should return all subscribers from pending companies", async () => { // Given - const { user, company } = await userWithCompanyFactory("ADMIN"); + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const user2 = await userFactory(); - await associateUserToCompany(user2.id, company.orgId, "ADMIN"); + await associateUserToCompany(user2.id, company.orgId, "ADMIN", { + notifications: [UserNotification.REVISION_REQUEST] + }); + const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -721,7 +717,7 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // When const pendingBSDARevisionRequest = - await getPendingBSDARevisionRequestsWithAdmins(2); + await getPendingBSDARevisionRequestsWithSubscribers(2); // Then expect(pendingBSDARevisionRequest.length).toEqual(1); @@ -733,20 +729,24 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { expect(pendingBSDARevisionRequest[0].approvals[0].company.id).toEqual( company.id ); - expect(pendingBSDARevisionRequest[0].approvals[0].admins.length).toEqual(2); + expect( + pendingBSDARevisionRequest[0].approvals[0].subscribers.length + ).toEqual(2); - const adminIds = pendingBSDARevisionRequest[0].approvals[0].admins.map( + const adminIds = pendingBSDARevisionRequest[0].approvals[0].subscribers.map( a => a.id ); expect(adminIds.includes(user.id)).toBe(true); expect(adminIds.includes(user2.id)).toBe(true); }); - it("should not return non-admins from pending companies", async () => { + it("should not return non subscribers from pending companies", async () => { // Given const { user, company } = await userWithCompanyFactory("ADMIN"); const user2 = await userFactory(); - await associateUserToCompany(user2.id, company.orgId, "MEMBER"); + await associateUserToCompany(user2.id, company.orgId, "ADMIN", { + notifications: [] + }); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -770,7 +770,7 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { // When const pendingBSDARevisionRequest = - await getPendingBSDARevisionRequestsWithAdmins(2); + await getPendingBSDARevisionRequestsWithSubscribers(2); // Then expect(pendingBSDARevisionRequest.length).toEqual(1); @@ -782,14 +782,16 @@ describe("getPendingBSDARevisionRequestsWithAdmins", () => { expect(pendingBSDARevisionRequest[0].approvals[0].company.id).toEqual( company.id ); - expect(pendingBSDARevisionRequest[0].approvals[0].admins.length).toEqual(1); - expect(pendingBSDARevisionRequest[0].approvals[0].admins[0].id).toEqual( - user.id - ); + expect( + pendingBSDARevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect( + pendingBSDARevisionRequest[0].approvals[0].subscribers[0].id + ).toEqual(user.id); }); }); -describe("getPendingBSDDRevisionRequestsWithAdmins", () => { +describe("getPendingBSDDRevisionRequestsWithSubscribers", () => { afterEach(resetDatabase); it("should return empty array if DB is empty", async () => { @@ -797,15 +799,20 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { // When const pendingBSDRevisionRequest = - await getPendingBSDDRevisionRequestsWithAdmins(2); + await getPendingBSDDRevisionRequestsWithSubscribers(2); // Then expect(pendingBSDRevisionRequest.length).toEqual(0); }); - it("should return pending request and company admins", async () => { + it("should return pending request and company subscribers to REVISION_REQUEST notification", async () => { // Given - const { user, company } = await userWithCompanyFactory("ADMIN"); + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -827,7 +834,7 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { // When const pendingBSDRevisionRequest = - await getPendingBSDDRevisionRequestsWithAdmins(2); + await getPendingBSDDRevisionRequestsWithSubscribers(2); // Then expect(pendingBSDRevisionRequest.length).toEqual(1); @@ -837,8 +844,10 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { .readableId ).toEqual(bsdd.readableId); expect(pendingBSDRevisionRequest[0].approvals.length).toEqual(1); - expect(pendingBSDRevisionRequest[0].approvals[0].admins.length).toEqual(1); - expect(pendingBSDRevisionRequest[0].approvals[0].admins[0].id).toEqual( + expect( + pendingBSDRevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect(pendingBSDRevisionRequest[0].approvals[0].subscribers[0].id).toEqual( user.id ); expect(pendingBSDRevisionRequest[0].approvals[0].company.id).toEqual( @@ -846,7 +855,7 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { ); }); - it("should not return pending request and company admins NOT xDaysAgo", async () => { + it("should not return pending request and subscribers NOT xDaysAgo", async () => { // Given const { user, company } = await userWithCompanyFactory("ADMIN"); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( @@ -887,7 +896,7 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { // When const pendingBSDRevisionRequest = - await getPendingBSDDRevisionRequestsWithAdmins(2); + await getPendingBSDDRevisionRequestsWithSubscribers(2); // Then expect(pendingBSDRevisionRequest.length).toEqual(0); @@ -919,17 +928,22 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { // When const pendingBSDRevisionRequest = - await getPendingBSDDRevisionRequestsWithAdmins(2); + await getPendingBSDDRevisionRequestsWithSubscribers(2); // Then expect(pendingBSDRevisionRequest.length).toEqual(0); }); - it("should return multiple pending request and company admins", async () => { + it("should return multiple pending request and subsrribers", async () => { // Given // Request 1 - const { user, company } = await userWithCompanyFactory("ADMIN"); + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -951,7 +965,10 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { // Request 2 const { user: user2, company: company2 } = await userWithCompanyFactory( - "ADMIN" + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } ); const { company: companyOfSomeoneElse2 } = await userWithCompanyFactory( "ADMIN" @@ -974,7 +991,7 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { // When const pendingBSDRevisionRequest = - await getPendingBSDDRevisionRequestsWithAdmins(2); + await getPendingBSDDRevisionRequestsWithSubscribers(2); // Then expect(pendingBSDRevisionRequest.length).toEqual(2); @@ -985,8 +1002,10 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { .readableId ).toEqual(bsdd.readableId); expect(pendingBSDRevisionRequest[0].approvals.length).toEqual(1); - expect(pendingBSDRevisionRequest[0].approvals[0].admins.length).toEqual(1); - expect(pendingBSDRevisionRequest[0].approvals[0].admins[0].id).toEqual( + expect( + pendingBSDRevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect(pendingBSDRevisionRequest[0].approvals[0].subscribers[0].id).toEqual( user.id ); expect(pendingBSDRevisionRequest[0].approvals[0].company.id).toEqual( @@ -999,8 +1018,10 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { .readableId ).toEqual(bsdd2.readableId); expect(pendingBSDRevisionRequest[1].approvals.length).toEqual(1); - expect(pendingBSDRevisionRequest[1].approvals[0].admins.length).toEqual(1); - expect(pendingBSDRevisionRequest[1].approvals[0].admins[0].id).toEqual( + expect( + pendingBSDRevisionRequest[1].approvals[0].subscribers.length + ).toEqual(1); + expect(pendingBSDRevisionRequest[1].approvals[0].subscribers[0].id).toEqual( user2.id ); expect(pendingBSDRevisionRequest[1].approvals[0].company.id).toEqual( @@ -1008,11 +1029,18 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { ); }); - it("should return all admins from pending companies", async () => { + it("should return all subscribers from pending companies", async () => { // Given - const { user, company } = await userWithCompanyFactory("ADMIN"); + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const user2 = await userFactory(); - await associateUserToCompany(user2.id, company.orgId, "ADMIN"); + await associateUserToCompany(user2.id, company.orgId, "ADMIN", { + notifications: [UserNotification.REVISION_REQUEST] + }); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -1034,7 +1062,7 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { // When const pendingBSDRevisionRequest = - await getPendingBSDDRevisionRequestsWithAdmins(2); + await getPendingBSDDRevisionRequestsWithSubscribers(2); // Then expect(pendingBSDRevisionRequest.length).toEqual(1); @@ -1047,20 +1075,24 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { expect(pendingBSDRevisionRequest[0].approvals[0].company.id).toEqual( company.id ); - expect(pendingBSDRevisionRequest[0].approvals[0].admins.length).toEqual(2); + expect( + pendingBSDRevisionRequest[0].approvals[0].subscribers.length + ).toEqual(2); - const adminIds = pendingBSDRevisionRequest[0].approvals[0].admins.map( + const adminIds = pendingBSDRevisionRequest[0].approvals[0].subscribers.map( a => a.id ); expect(adminIds.includes(user.id)).toBe(true); expect(adminIds.includes(user2.id)).toBe(true); }); - it("should not return non-admins from pending companies", async () => { + it("should not return non-subscribers from pending companies", async () => { // Given const { user, company } = await userWithCompanyFactory("ADMIN"); const user2 = await userFactory(); - await associateUserToCompany(user2.id, company.orgId, "MEMBER"); + await associateUserToCompany(user2.id, company.orgId, "ADMIN", { + notifications: [] + }); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -1082,7 +1114,7 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { // When const pendingBSDRevisionRequest = - await getPendingBSDDRevisionRequestsWithAdmins(2); + await getPendingBSDDRevisionRequestsWithSubscribers(2); // Then expect(pendingBSDRevisionRequest.length).toEqual(1); @@ -1095,24 +1127,26 @@ describe("getPendingBSDDRevisionRequestsWithAdmins", () => { expect(pendingBSDRevisionRequest[0].approvals[0].company.id).toEqual( company.id ); - expect(pendingBSDRevisionRequest[0].approvals[0].admins.length).toEqual(1); - expect(pendingBSDRevisionRequest[0].approvals[0].admins[0].id).toEqual( + expect( + pendingBSDRevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect(pendingBSDRevisionRequest[0].approvals[0].subscribers[0].id).toEqual( user.id ); }); }); -describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { +describe("getPendingBSDARIRevisionRequestsWithSubscribers", () => { afterEach(resetDatabase); it("should return empty array if DB is empty", async () => { const pendingBSDASRIRevisionRequest = - await getPendingBSDASRIRevisionRequestsWithAdmins(2); + await getPendingBSDASRIRevisionRequestsWithSubscribers(2); expect(pendingBSDASRIRevisionRequest.length).toEqual(0); }); - it("should return pending request and company admins", async () => { + it("should return pending request and company subscribers to notification REVISION_REQUEST", async () => { const { user, company } = await userWithCompanyFactory("ADMIN"); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" @@ -1136,7 +1170,7 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { }); const pendingBSDARIRevisionRequest = - await getPendingBSDASRIRevisionRequestsWithAdmins(2); + await getPendingBSDASRIRevisionRequestsWithSubscribers(2); expect(pendingBSDARIRevisionRequest.length).toEqual(1); expect(pendingBSDARIRevisionRequest[0].id).toEqual(request.id); @@ -1147,15 +1181,15 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { expect(pendingBSDARIRevisionRequest[0].approvals[0].company.id).toEqual( company.id ); - expect(pendingBSDARIRevisionRequest[0].approvals[0].admins.length).toEqual( - 1 - ); - expect(pendingBSDARIRevisionRequest[0].approvals[0].admins[0].id).toEqual( - user.id - ); + expect( + pendingBSDARIRevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect( + pendingBSDARIRevisionRequest[0].approvals[0].subscribers[0].id + ).toEqual(user.id); }); - it("should not return pending request and company admins NOT xDaysAgo", async () => { + it("should not return pending request and company subscribers NOT xDaysAgo", async () => { const { company } = await userWithCompanyFactory("ADMIN"); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" @@ -1198,7 +1232,7 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { }); const pendingBSDASRIRevisionRequest = - await getPendingBSDASRIRevisionRequestsWithAdmins(2); + await getPendingBSDASRIRevisionRequestsWithSubscribers(2); expect(pendingBSDASRIRevisionRequest.length).toEqual(0); }); @@ -1229,14 +1263,19 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { }); const pendingBSDASRIRevisionRequest = - await getPendingBSDASRIRevisionRequestsWithAdmins(2); + await getPendingBSDASRIRevisionRequestsWithSubscribers(2); expect(pendingBSDASRIRevisionRequest.length).toEqual(0); }); - it("should return multiple pending request and company admins", async () => { + it("should return multiple pending request and company subscribers", async () => { // Request 1 - const { user, company } = await userWithCompanyFactory("ADMIN"); + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -1260,7 +1299,10 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { // Request 2 const { user: user2, company: company2 } = await userWithCompanyFactory( - "ADMIN" + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } ); const { company: companyOfSomeoneElse2 } = await userWithCompanyFactory( "ADMIN" @@ -1284,7 +1326,7 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { }); const pendingBSDASRIRevisionRequest = - await getPendingBSDASRIRevisionRequestsWithAdmins(2); + await getPendingBSDASRIRevisionRequestsWithSubscribers(2); expect(pendingBSDASRIRevisionRequest.length).toEqual(2); @@ -1293,12 +1335,12 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { (pendingBSDASRIRevisionRequest[0] as BsdasriRevisionRequest).bsdasriId ).toEqual(bsdasri.id); expect(pendingBSDASRIRevisionRequest[0].approvals.length).toEqual(1); - expect(pendingBSDASRIRevisionRequest[0].approvals[0].admins.length).toEqual( - 1 - ); - expect(pendingBSDASRIRevisionRequest[0].approvals[0].admins[0].id).toEqual( - user.id - ); + expect( + pendingBSDASRIRevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect( + pendingBSDASRIRevisionRequest[0].approvals[0].subscribers[0].id + ).toEqual(user.id); expect(pendingBSDASRIRevisionRequest[0].approvals[0].company.id).toEqual( company.id ); @@ -1308,21 +1350,28 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { (pendingBSDASRIRevisionRequest[1] as BsdasriRevisionRequest).bsdasriId ).toEqual(bsdasri2.id); expect(pendingBSDASRIRevisionRequest[1].approvals.length).toEqual(1); - expect(pendingBSDASRIRevisionRequest[1].approvals[0].admins.length).toEqual( - 1 - ); - expect(pendingBSDASRIRevisionRequest[1].approvals[0].admins[0].id).toEqual( - user2.id - ); + expect( + pendingBSDASRIRevisionRequest[1].approvals[0].subscribers.length + ).toEqual(1); + expect( + pendingBSDASRIRevisionRequest[1].approvals[0].subscribers[0].id + ).toEqual(user2.id); expect(pendingBSDASRIRevisionRequest[1].approvals[0].company.id).toEqual( company2.id ); }); - it("should return all admins from pending companies", async () => { - const { user, company } = await userWithCompanyFactory("ADMIN"); + it("should return all subscribers from pending companies", async () => { + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const user2 = await userFactory(); - await associateUserToCompany(user2.id, company.orgId, "ADMIN"); + await associateUserToCompany(user2.id, company.orgId, "ADMIN", { + notifications: [UserNotification.REVISION_REQUEST] + }); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -1345,7 +1394,7 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { }); const pendingBSDASRIevisionRequest = - await getPendingBSDASRIRevisionRequestsWithAdmins(2); + await getPendingBSDASRIRevisionRequestsWithSubscribers(2); expect(pendingBSDASRIevisionRequest.length).toEqual(1); expect(pendingBSDASRIevisionRequest[0].approvals.length).toEqual(1); @@ -1356,21 +1405,22 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { expect(pendingBSDASRIevisionRequest[0].approvals[0].company.id).toEqual( company.id ); - expect(pendingBSDASRIevisionRequest[0].approvals[0].admins.length).toEqual( - 2 - ); + expect( + pendingBSDASRIevisionRequest[0].approvals[0].subscribers.length + ).toEqual(2); - const adminIds = pendingBSDASRIevisionRequest[0].approvals[0].admins.map( - a => a.id - ); + const adminIds = + pendingBSDASRIevisionRequest[0].approvals[0].subscribers.map(a => a.id); expect(adminIds.includes(user.id)).toBe(true); expect(adminIds.includes(user2.id)).toBe(true); }); - it("should not return non-admins from pending companies", async () => { + it("should not return non-subscribers from pending companies", async () => { const { user, company } = await userWithCompanyFactory("ADMIN"); const user2 = await userFactory(); - await associateUserToCompany(user2.id, company.orgId, "MEMBER"); + await associateUserToCompany(user2.id, company.orgId, "ADMIN", { + notifications: [] + }); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -1393,7 +1443,7 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { }); const pendingBSDASRIRevisionRequest = - await getPendingBSDASRIRevisionRequestsWithAdmins(2); + await getPendingBSDASRIRevisionRequestsWithSubscribers(2); expect(pendingBSDASRIRevisionRequest.length).toEqual(1); expect(pendingBSDASRIRevisionRequest[0].id).toEqual(request.id); @@ -1404,12 +1454,12 @@ describe("getPendingBSDARIRevisionRequestsWithAdmins", () => { expect(pendingBSDASRIRevisionRequest[0].approvals[0].company.id).toEqual( company.id ); - expect(pendingBSDASRIRevisionRequest[0].approvals[0].admins.length).toEqual( - 1 - ); - expect(pendingBSDASRIRevisionRequest[0].approvals[0].admins[0].id).toEqual( - user.id - ); + expect( + pendingBSDASRIRevisionRequest[0].approvals[0].subscribers.length + ).toEqual(1); + expect( + pendingBSDASRIRevisionRequest[0].approvals[0].subscribers[0].id + ).toEqual(user.id); }); }); @@ -2002,7 +2052,7 @@ describe("addPendingApprovalsCompanyAdmins", () => { include: { approvals: true, bsdd: { select: { readableId: true } } } }); - const wrappedRequests = await addPendingApprovalsCompanyAdmins( + const wrappedRequests = await addPendingApprovalsCompanySubscribers( requestsWithApprovals ); diff --git a/apps/cron/src/commands/__tests__/sendmails.integration.ts b/apps/cron/src/commands/__tests__/sendmails.integration.ts index 0efd74c17e..327c4d8a68 100644 --- a/apps/cron/src/commands/__tests__/sendmails.integration.ts +++ b/apps/cron/src/commands/__tests__/sendmails.integration.ts @@ -1,9 +1,11 @@ import axios from "axios"; import { resetDatabase } from "libs/back/tests-integration"; -import { CompanyType, MembershipRequestStatus } from "@prisma/client"; -import { addToMailQueue } from "back"; +import { + CompanyType, + MembershipRequestStatus, + UserNotification +} from "@prisma/client"; import { prisma } from "@td/prisma"; - import { companyFactory, createMembershipRequest, @@ -16,18 +18,12 @@ import { bsdaFactory } from "back/src/bsda/__tests__/factories"; import { sendMembershipRequestDetailsEmail, sendPendingMembershipRequestDetailsEmail, - sendPendingMembershipRequestToAdminDetailsEmail, - sendPendingRevisionRequestToAdminDetailsEmail, + sendPendingMembershipRequestEmail, + sendPendingRevisionRequestEmail, sendSecondOnboardingEmail } from "../onboarding.helpers"; import { xDaysAgo } from "../helpers"; -// Intercept calls -// Simulate queue error in order to test with sendMailSync -jest.mock("back"); -(addToMailQueue as jest.Mock).mockRejectedValue( - new Error("any queue error to bypass job queue and sendmail synchronously") -); -// Integration tests EMAIL_BACKEND is supposed to use axios. + jest.mock("axios"); const TODAY = new Date(); @@ -40,7 +36,6 @@ describe("sendSecondOnboardingEmail", () => { afterEach(resetDatabase); beforeEach(() => { (axios.post as jest.Mock).mockClear(); - (addToMailQueue as jest.Mock).mockClear(); }); it.each([ONE_DAY_AGO, TWO_DAYS_AGO, FOUR_DAYS_AGO])( @@ -80,7 +75,7 @@ describe("sendSecondOnboardingEmail", () => { }) ); - await sendSecondOnboardingEmail(3); + await sendSecondOnboardingEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(0); } @@ -107,7 +102,7 @@ describe("sendSecondOnboardingEmail", () => { }) ); - await sendSecondOnboardingEmail(3); + await sendSecondOnboardingEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(1); expect(axios.post as jest.Mock).toHaveBeenCalledWith( @@ -159,7 +154,7 @@ describe("sendSecondOnboardingEmail", () => { }) ); - await sendSecondOnboardingEmail(3); + await sendSecondOnboardingEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(1); expect(axios.post as jest.Mock).toHaveBeenCalledWith( @@ -195,7 +190,6 @@ describe("sendMembershipRequestDetailsEmail", () => { afterEach(resetDatabase); beforeEach(() => { (axios.post as jest.Mock).mockClear(); - (addToMailQueue as jest.Mock).mockClear(); }); it("no membership request > should not send any mail", async () => { @@ -205,7 +199,7 @@ describe("sendMembershipRequestDetailsEmail", () => { }) ); - await sendMembershipRequestDetailsEmail(3); + await sendMembershipRequestDetailsEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(0); }); @@ -220,7 +214,7 @@ describe("sendMembershipRequestDetailsEmail", () => { }) ); - await sendMembershipRequestDetailsEmail(3); + await sendMembershipRequestDetailsEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(1); expect(axios.post as jest.Mock).toHaveBeenCalledWith( @@ -255,7 +249,6 @@ describe("sendPendingMembershipRequestDetailsEmail", () => { afterEach(resetDatabase); beforeEach(() => { (axios.post as jest.Mock).mockClear(); - (addToMailQueue as jest.Mock).mockClear(); }); it("no pending membership request > should not send any mail", async () => { @@ -265,7 +258,7 @@ describe("sendPendingMembershipRequestDetailsEmail", () => { }) ); - await sendPendingMembershipRequestDetailsEmail(3); + await sendPendingMembershipRequestDetailsEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(0); }); @@ -286,7 +279,7 @@ describe("sendPendingMembershipRequestDetailsEmail", () => { }) ); - await sendPendingMembershipRequestDetailsEmail(3); + await sendPendingMembershipRequestDetailsEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(1); expect(axios.post as jest.Mock).toHaveBeenCalledWith( @@ -321,7 +314,6 @@ describe("sendPendingMembershipRequestToAdminDetailsEmail", () => { afterEach(resetDatabase); beforeEach(() => { (axios.post as jest.Mock).mockClear(); - (addToMailQueue as jest.Mock).mockClear(); }); it("no pending membership request > should not send any mail", async () => { @@ -331,15 +323,20 @@ describe("sendPendingMembershipRequestToAdminDetailsEmail", () => { }) ); - await sendPendingMembershipRequestToAdminDetailsEmail(3); + await sendPendingMembershipRequestEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(0); }); - it("pending membership > should send a mail to admin", async () => { + it("pending membership > should send a mail to subscriber", async () => { const user = await userFactory(); - const companyAndAdmin = await userWithCompanyFactory("ADMIN"); + const companyAndAdmin = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.MEMBERSHIP_REQUEST] } + ); await createMembershipRequest(user, companyAndAdmin.company, { createdAt: THREE_DAYS_AGO, @@ -352,7 +349,7 @@ describe("sendPendingMembershipRequestToAdminDetailsEmail", () => { }) ); - await sendPendingMembershipRequestToAdminDetailsEmail(3); + await sendPendingMembershipRequestEmail(3, true); expect(axios.post as jest.Mock).toHaveBeenCalledTimes(1); expect(axios.post as jest.Mock).toHaveBeenCalledWith( @@ -391,7 +388,6 @@ describe("sendPendingRevisionRequestToAdminDetailsEmail", () => { afterEach(resetDatabase); beforeEach(() => { (axios.post as jest.Mock).mockClear(); - (addToMailQueue as jest.Mock).mockClear(); }); it("no request > should not send anything", async () => { @@ -403,7 +399,7 @@ describe("sendPendingRevisionRequestToAdminDetailsEmail", () => { ); // When - await sendPendingRevisionRequestToAdminDetailsEmail(5); + await sendPendingRevisionRequestEmail(5, true); // Then expect(axios.post as jest.Mock).toHaveBeenCalledTimes(0); @@ -413,7 +409,12 @@ describe("sendPendingRevisionRequestToAdminDetailsEmail", () => { // Given // BSDD - const { user, company } = await userWithCompanyFactory("ADMIN"); + const { user, company } = await userWithCompanyFactory( + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } + ); const { company: companyOfSomeoneElse } = await userWithCompanyFactory( "ADMIN" ); @@ -434,7 +435,10 @@ describe("sendPendingRevisionRequestToAdminDetailsEmail", () => { }); const { user: user2, company: company2 } = await userWithCompanyFactory( - "ADMIN" + "ADMIN", + {}, + {}, + { notifications: [UserNotification.REVISION_REQUEST] } ); const { company: companyOfSomeoneElse2 } = await userWithCompanyFactory( "ADMIN" @@ -465,7 +469,7 @@ describe("sendPendingRevisionRequestToAdminDetailsEmail", () => { ); // When - await sendPendingRevisionRequestToAdminDetailsEmail(2); + await sendPendingRevisionRequestEmail(2, true); // Then expect(axios.post as jest.Mock).toHaveBeenCalledTimes(1); diff --git a/apps/cron/src/commands/onboarding.helpers.ts b/apps/cron/src/commands/onboarding.helpers.ts index 8159f1b0dd..9da63feda9 100644 --- a/apps/cron/src/commands/onboarding.helpers.ts +++ b/apps/cron/src/commands/onboarding.helpers.ts @@ -1,5 +1,5 @@ import { - getCompaniesAndActiveAdminsByCompanyOrgIds, + getCompaniesAndSubscribersByCompanyOrgIds, formatDate, sendMail } from "back"; @@ -16,7 +16,8 @@ import { Company, RevisionRequestApprovalStatus, RevisionRequestStatus, - User + User, + UserNotification } from "@prisma/client"; import * as COMPANY_CONSTANTS from "@td/constants"; import { @@ -24,10 +25,10 @@ import { MessageVersion, membershipRequestDetailsEmail, pendingMembershipRequestDetailsEmail, - pendingMembershipRequestAdminDetailsEmail, + pendingMembershipRequestEmail, profesionalsSecondOnboardingEmail, producersSecondOnboardingEmail, - pendingRevisionRequestAdminDetailsEmail + pendingRevisionRequestEmail } from "@td/mail"; import { xDaysAgo } from "./helpers"; @@ -121,7 +122,7 @@ export const getRecentlyRegisteredProducers = async (daysAgo = 2) => { /** * Second onboarding email. Different for profesionals & non-profesionals / producers */ -export const sendSecondOnboardingEmail = async (daysAgo = 2) => { +export const sendSecondOnboardingEmail = async (daysAgo = 2, sync = false) => { // Pros const profesionals = await getRecentlyRegisteredProfesionals(daysAgo); @@ -134,7 +135,7 @@ export const sendSecondOnboardingEmail = async (daysAgo = 2) => { messageVersions: proMessageVersions }); - await sendMail(proPayload); + await sendMail(proPayload, { sync }); } // Producers. If already in pro list, remove (only 1 email, pro has priority) @@ -155,7 +156,7 @@ export const sendSecondOnboardingEmail = async (daysAgo = 2) => { messageVersions: producersMessageVersions }); - await sendMail(producersPayload); + await sendMail(producersPayload, { sync }); } await prisma.$disconnect(); @@ -189,7 +190,10 @@ export const getRecentlyRegisteredUsersWithNoCompanyNorMembershipRequest = * Send a mail to users who registered recently and who haven't * issued a single MembershipRequest yet */ -export const sendMembershipRequestDetailsEmail = async (daysAgo = 7) => { +export const sendMembershipRequestDetailsEmail = async ( + daysAgo = 7, + sync = false +) => { const recipients = await getRecentlyRegisteredUsersWithNoCompanyNorMembershipRequest(daysAgo); @@ -202,7 +206,7 @@ export const sendMembershipRequestDetailsEmail = async (daysAgo = 7) => { messageVersions }); - await sendMail(payload); + await sendMail(payload, { sync }); } await prisma.$disconnect(); @@ -242,7 +246,8 @@ export const getActiveUsersWithPendingMembershipRequests = async ( * got no answer */ export const sendPendingMembershipRequestDetailsEmail = async ( - daysAgo = 14 + daysAgo = 14, + sync = false ) => { const recipients = await getActiveUsersWithPendingMembershipRequests(daysAgo); @@ -255,13 +260,20 @@ export const sendPendingMembershipRequestDetailsEmail = async ( messageVersions }); - await sendMail(payload); + await sendMail(payload, { sync }); } await prisma.$disconnect(); }; -export const getPendingMembershipRequestsAndAssociatedAdmins = async ( +/** + * Récupère toutes les demandes de rattachement qui sont en attente depuis `daysAgo` + * jours ainsi que : + * - les établissements de rattachement correspondants. + * - les utilisateurs au sein de ces établissements qui sont abonnées aux notifications + * de demandes de rattachement par e-mail. + */ +export const getPendingMembershipRequestsAndAssociatedSubscribers = async ( daysAgo: number ) => { const now = new Date(); @@ -280,7 +292,14 @@ export const getPendingMembershipRequestsAndAssociatedAdmins = async ( company: { include: { companyAssociations: { - where: { role: "ADMIN", user: { isActive: true } }, + where: { + notifications: { + has: UserNotification.MEMBERSHIP_REQUEST + }, + user: { + isActive: true + } + }, include: { user: true } } } @@ -291,47 +310,55 @@ export const getPendingMembershipRequestsAndAssociatedAdmins = async ( /** * For each unanswered membership request issued X days ago, send an - * email to all the admins of request's targeted company + * email to all the users inside the companies who are subsribed to + * membership requests notifications by e-mail. */ -export const sendPendingMembershipRequestToAdminDetailsEmail = async ( - daysAgo = 14 +export const sendPendingMembershipRequestEmail = async ( + daysAgo = 14, + sync = false ) => { - const requests = await getPendingMembershipRequestsAndAssociatedAdmins( + const requests = await getPendingMembershipRequestsAndAssociatedSubscribers( daysAgo ); if (requests.length) { - const messageVersions: MessageVersion[] = requests.map(request => { - const variables = { - requestId: request.id, - email: request.user.email, - orgName: request.company.name, - orgId: request.company.orgId - }; - - const template = renderMail(pendingMembershipRequestAdminDetailsEmail, { - variables, - messageVersions: [] - }); - - return { - to: request.company.companyAssociations.map(companyAssociation => ({ - email: companyAssociation.user.email, - name: companyAssociation.user.name - })), - ...(template.body && { - params: { - body: template.body - } - }) - }; - }); + const messageVersions: MessageVersion[] = requests + .map(request => { + const variables = { + requestId: request.id, + email: request.user.email, + orgName: request.company.name, + orgId: request.company.orgId + }; + + const template = renderMail(pendingMembershipRequestEmail, { + variables, + messageVersions: [] + }); - const payload = renderMail(pendingMembershipRequestAdminDetailsEmail, { + if (request.company.companyAssociations.length) { + return { + to: request.company.companyAssociations.map(companyAssociation => ({ + email: companyAssociation.user.email, + name: companyAssociation.user.name + })), + ...(template.body && { + params: { + body: template.body + } + }) + }; + } + + return null; + }) + .filter(Boolean); + + const payload = renderMail(pendingMembershipRequestEmail, { messageVersions }); - await sendMail(payload); + await sendMail(payload, { sync }); } await prisma.$disconnect(); @@ -355,19 +382,19 @@ type RequestWithApprovals = type RequestWithWrappedApprovals = | (BsddRevisionRequestWithReadableId & { approvals: (BsddRevisionRequestApproval & { - admins: User[]; + subscribers: User[]; company: Company; })[]; }) | (BsdaRevisionRequest & { approvals: (BsdaRevisionRequestApproval & { - admins: User[]; + subscribers: User[]; company: Company; })[]; }) | (BsdasriRevisionRequest & { approvals: (BsdasriRevisionRequestApproval & { - admins: User[]; + subscribers: User[]; company: Company; })[]; }); @@ -375,7 +402,7 @@ type RequestWithWrappedApprovals = /** * Will add pending approval companies' admins to requests */ -export const addPendingApprovalsCompanyAdmins = async ( +export const addPendingApprovalsCompanySubscribers = async ( requests: RequestWithApprovals[] ): Promise => { // Extract all pending company sirets @@ -389,11 +416,14 @@ export const addPendingApprovalsCompanyAdmins = async ( ) .flat(); - // Find companies and their respective admins + // Find companies and their respective mail subscribers const companiesAndAdminsByOrgIds = - await getCompaniesAndActiveAdminsByCompanyOrgIds(companySirets); + await getCompaniesAndSubscribersByCompanyOrgIds( + companySirets, + UserNotification.REVISION_REQUEST + ); - // Add admins to requests + // Add mail subscribers to requests return requests.map((request: RequestWithApprovals) => { return { ...request, @@ -407,13 +437,14 @@ export const addPendingApprovalsCompanyAdmins = async ( .map(approval => ({ ...approval, company: companiesAndAdminsByOrgIds[approval.approverSiret], - admins: companiesAndAdminsByOrgIds[approval.approverSiret].admins + subscribers: + companiesAndAdminsByOrgIds[approval.approverSiret].subscribers })) }; }); }; -export const getPendingBSDDRevisionRequestsWithAdmins = async ( +export const getPendingBSDDRevisionRequestsWithSubscribers = async ( daysAgo: number ): Promise => { const now = new Date(); @@ -431,10 +462,10 @@ export const getPendingBSDDRevisionRequestsWithAdmins = async ( }); // Add admins to requests - return await addPendingApprovalsCompanyAdmins(requests); + return await addPendingApprovalsCompanySubscribers(requests); }; -export const getPendingBSDARevisionRequestsWithAdmins = async ( +export const getPendingBSDARevisionRequestsWithSubscribers = async ( daysAgo: number ): Promise => { const now = new Date(); @@ -452,10 +483,10 @@ export const getPendingBSDARevisionRequestsWithAdmins = async ( }); // Add admins to requests - return await addPendingApprovalsCompanyAdmins(requests); + return await addPendingApprovalsCompanySubscribers(requests); }; -export const getPendingBSDASRIRevisionRequestsWithAdmins = async ( +export const getPendingBSDASRIRevisionRequestsWithSubscribers = async ( daysAgo: number ): Promise => { const now = new Date(); @@ -473,19 +504,20 @@ export const getPendingBSDASRIRevisionRequestsWithAdmins = async ( }); // Add admins to requests - return await addPendingApprovalsCompanyAdmins(requests); + return await addPendingApprovalsCompanySubscribers(requests); }; /** * Send an email to admins who didn't answer to a revision request */ -export const sendPendingRevisionRequestToAdminDetailsEmail = async ( - daysAgo = 5 +export const sendPendingRevisionRequestEmail = async ( + daysAgo = 5, + sync = false ) => { const pendingBsddRevisionRequest = - await getPendingBSDDRevisionRequestsWithAdmins(daysAgo); + await getPendingBSDDRevisionRequestsWithSubscribers(daysAgo); const pendingBsdaRevisionRequest = - await getPendingBSDARevisionRequestsWithAdmins(daysAgo); + await getPendingBSDARevisionRequestsWithSubscribers(daysAgo); const requests = [ ...pendingBsddRevisionRequest, @@ -510,13 +542,13 @@ export const sendPendingRevisionRequestToAdminDetailsEmail = async ( companyOrgId: approval.company.orgId }; - const template = renderMail(pendingRevisionRequestAdminDetailsEmail, { + const template = renderMail(pendingRevisionRequestEmail, { variables, messageVersions: [] }); return { - to: approval.admins.map(admin => ({ + to: approval.subscribers.map(admin => ({ email: admin.email, name: admin.name })), @@ -530,11 +562,11 @@ export const sendPendingRevisionRequestToAdminDetailsEmail = async ( }) .flat(); - const payload = renderMail(pendingRevisionRequestAdminDetailsEmail, { + const payload = renderMail(pendingRevisionRequestEmail, { messageVersions }); - await sendMail(payload); + await sendMail(payload, { sync }); } await prisma.$disconnect(); diff --git a/apps/cron/src/cron.test.ts b/apps/cron/src/cron.test.ts index 4bd2fad6c4..893f577b08 100644 --- a/apps/cron/src/cron.test.ts +++ b/apps/cron/src/cron.test.ts @@ -1,4 +1,4 @@ -import { validateOnbardingCronSchedule } from "./main"; +import { validateDailyCronSchedule } from "./main"; jest.mock("back", () => ({ initSentry: jest.fn() @@ -8,9 +8,9 @@ jest.mock("./commands/appendix1.helpers", () => ({ cleanUnusedAppendix1ProducerBsdds: jest.fn(() => Promise.resolve()) })); -describe("validateOnbardingCronSchedule", () => { +describe("validateDailyCronSchedule", () => { it("should throw error when invalid quartz expression", () => { - expect(() => validateOnbardingCronSchedule("80 8 * * *")).toThrow( + expect(() => validateDailyCronSchedule("80 8 * * *")).toThrow( "Invalid CRON expression : 80 8 * * *" ); }); @@ -18,15 +18,13 @@ describe("validateOnbardingCronSchedule", () => { it("should throw an error if valid CRON expression but not set to run once every day", () => { // At 00:00 on day-of-month 1. const everyFistOfMonthAtMidnight = "* * 1 * *"; - expect(() => - validateOnbardingCronSchedule(everyFistOfMonthAtMidnight) - ).toThrow( + expect(() => validateDailyCronSchedule(everyFistOfMonthAtMidnight)).toThrow( "CRON expression should be set to run once every day : {m} {h} * * *" ); }); it("should return true for valid expression", () => { - expect(validateOnbardingCronSchedule("8 8 * * *")).toEqual(true); - expect(validateOnbardingCronSchedule("08 08 * * *")).toEqual(true); + expect(validateDailyCronSchedule("8 8 * * *")).toEqual(true); + expect(validateDailyCronSchedule("08 08 * * *")).toEqual(true); }); }); diff --git a/apps/cron/src/main.ts b/apps/cron/src/main.ts index 07672e0272..d13bf2723c 100644 --- a/apps/cron/src/main.ts +++ b/apps/cron/src/main.ts @@ -1,16 +1,17 @@ import * as cron from "cron"; import cronValidator from "cron-validate"; -import { initSentry } from "back"; +import { cleanUpIsReturnForTab, initSentry } from "back"; import { sendMembershipRequestDetailsEmail, sendPendingMembershipRequestDetailsEmail, - sendPendingMembershipRequestToAdminDetailsEmail, - sendPendingRevisionRequestToAdminDetailsEmail, + sendPendingMembershipRequestEmail, + sendPendingRevisionRequestEmail, sendSecondOnboardingEmail } from "./commands/onboarding.helpers"; import { cleanAppendix1 } from "./commands/appendix1.helpers"; -const { CRON_ONBOARDING_SCHEDULE, TZ } = process.env; +const { CRON_ONBOARDING_SCHEDULE, CRON_CLEANUP_IS_RETURN_TAB_SCHEDULE, TZ } = + process.env; let jobs: cron.CronJob[] = [ new cron.CronJob({ @@ -23,7 +24,7 @@ let jobs: cron.CronJob[] = [ ]; if (CRON_ONBOARDING_SCHEDULE) { - validateOnbardingCronSchedule(CRON_ONBOARDING_SCHEDULE); + validateDailyCronSchedule(CRON_ONBOARDING_SCHEDULE); jobs = [ ...jobs, @@ -55,7 +56,7 @@ if (CRON_ONBOARDING_SCHEDULE) { new cron.CronJob({ cronTime: CRON_ONBOARDING_SCHEDULE, onTick: async () => { - await sendPendingMembershipRequestToAdminDetailsEmail(); + await sendPendingMembershipRequestEmail(); }, timeZone: TZ }), @@ -63,7 +64,23 @@ if (CRON_ONBOARDING_SCHEDULE) { new cron.CronJob({ cronTime: CRON_ONBOARDING_SCHEDULE, onTick: async () => { - await sendPendingRevisionRequestToAdminDetailsEmail(); + await sendPendingRevisionRequestEmail(); + }, + timeZone: TZ + }) + ]; +} + +if (CRON_CLEANUP_IS_RETURN_TAB_SCHEDULE) { + validateDailyCronSchedule(CRON_CLEANUP_IS_RETURN_TAB_SCHEDULE); + + jobs = [ + ...jobs, + // cleanup the isReturnFor tab + new cron.CronJob({ + cronTime: CRON_CLEANUP_IS_RETURN_TAB_SCHEDULE, + onTick: async () => { + await cleanUpIsReturnForTab(); }, timeZone: TZ }) @@ -88,7 +105,7 @@ if (Sentry) { jobs.forEach(job => job.start()); -export function validateOnbardingCronSchedule(cronExp: string) { +export function validateDailyCronSchedule(cronExp: string) { // checks it is a valid cron quartz expression const isValid = cronValidator(cronExp).isValid(); 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). ::: 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: { 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/back/src/__tests__/factories.ts b/back/src/__tests__/factories.ts index 742e666601..348b153e08 100644 --- a/back/src/__tests__/factories.ts +++ b/back/src/__tests__/factories.ts @@ -11,11 +11,14 @@ import { User, Prisma, Company, - TransportMode + TransportMode, + UserNotification } from "@prisma/client"; import { prisma } from "@td/prisma"; import { hashToken } from "../utils"; import { createUser, getUserCompanies } from "../users/database"; +import { getFormSiretsByRole, SIRETS_BY_ROLE_INCLUDE } from "../forms/database"; +import { CompanyRole } from "../common/validation/zod/schema"; /** * Create a user with name and email @@ -135,11 +138,23 @@ export const userWithCompanyFactory = async ( ): Promise => { const company = await companyFactory(companyOpts); + const notifications = + role === "ADMIN" + ? [ + UserNotification.MEMBERSHIP_REQUEST, + UserNotification.REVISION_REQUEST, + UserNotification.BSD_REFUSAL, + UserNotification.SIGNATURE_CODE_RENEWAL, + UserNotification.BSDA_FINAL_DESTINATION_UPDATE + ] + : []; + const user = await userFactory({ ...userOpts, companyAssociations: { create: { company: { connect: { id: company.id } }, + notifications, role: role, ...companyAssociationOpts } @@ -148,6 +163,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 = {} ) => { @@ -350,7 +386,7 @@ export const bsddTransporterFactory = async ({ opts: Omit; }) => { const count = await prisma.bsddTransporter.count({ where: { formId } }); - return prisma.bsddTransporter.create({ + const transporter = await prisma.bsddTransporter.create({ data: { form: { connect: { id: formId } }, ...bsddTransporterData, @@ -358,6 +394,24 @@ export const bsddTransporterFactory = async ({ ...opts } }); + + const form = await prisma.form.findFirstOrThrow({ + where: { id: formId }, + include: { + ...SIRETS_BY_ROLE_INCLUDE, + forwardedIn: true, + transporters: true + } + }); + + // Fix fields like "recipientsSirets" or "transportersSirets" + const denormalizedSirets = getFormSiretsByRole(form as any); // Ts doesn't infer correctly because of the boolean + await prisma.form.update({ + where: { id: formId }, + data: { ...denormalizedSirets, updatedAt: form.updatedAt } + }); + + return transporter; }; export const upsertBaseSiret = async siret => { @@ -423,14 +477,31 @@ export const formFactory = async ({ } }; - return prisma.form.create({ + const form = await prisma.form.create({ data: { readableId: getReadableId(), ...formParams, owner: { connect: { id: ownerId } } }, + include: { + ...SIRETS_BY_ROLE_INCLUDE, + forwardedIn: true, + transporters: true + } + }); + + // Fix fields like "recipientsSirets" or "transportersSirets" + const denormalizedSirets = getFormSiretsByRole(form as any); // Ts doesn't infer correctly because of the boolean + const updated = await prisma.form.update({ + where: { id: form.id }, + data: { + ...denormalizedSirets, + updatedAt: opt?.updatedAt ?? new Date() + }, include: { forwardedIn: true } }); + + return updated; }; export const formWithTempStorageFactory = async ({ @@ -521,20 +592,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; }; @@ -571,3 +658,69 @@ export const transporterReceiptFactory = async ({ return receipt; }; + +export const intermediaryReceiptFactory = async ({ + role = CompanyRole.Broker, + number = "el numero", + department = "69", + company +}: { + role: CompanyRole.Broker | CompanyRole.Trader; + number?: string; + department?: string; + company?: Company; +}) => { + let receipt; + if (role === CompanyRole.Broker) { + receipt = await prisma.brokerReceipt.create({ + data: { + receiptNumber: number, + validityLimit: "2055-01-01T00:00:00.000Z", + department: department + } + }); + if (!!company) { + await prisma.company.update({ + where: { id: company.id }, + data: { brokerReceipt: { connect: { id: receipt.id } } } + }); + } + } else if (role === CompanyRole.Trader) { + receipt = await prisma.traderReceipt.create({ + data: { + receiptNumber: number, + validityLimit: "2055-01-01T00:00:00.000Z", + department: department + } + }); + if (!!company) { + await prisma.company.update({ + where: { id: company.id }, + data: { traderReceipt: { connect: { id: receipt.id } } } + }); + } + } + return receipt; +}; + +export const bsddFinalOperationFactory = async ({ + bsddId, + opts = {} +}: { + bsddId: string; + opts?: Omit< + Partial, + "initialForm" | "finalForm" + >; +}) => { + return prisma.bsddFinalOperation.create({ + data: { + initialForm: { connect: { id: bsddId } }, + finalForm: { connect: { id: bsddId } }, + operationCode: "", + quantity: 1, + noTraceability: false, + ...opts + } + }); +}; 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/__tests__/elastic.integration.ts b/back/src/bsda/__tests__/elastic.integration.ts index e22f4d3ab0..d5be992465 100644 --- a/back/src/bsda/__tests__/elastic.integration.ts +++ b/back/src/bsda/__tests__/elastic.integration.ts @@ -1,10 +1,11 @@ -import { Company } from "@prisma/client"; +import { BsdaStatus, Company, WasteAcceptationStatus } from "@prisma/client"; import { resetDatabase } from "../../../integration-tests/helper"; import { prisma } from "@td/prisma"; import { companyFactory } from "../../__tests__/factories"; import { getBsdaForElastic, toBsdElastic } from "../elastic"; import { BsdElastic } from "../../common/elastic"; import { bsdaFactory } from "./factories"; +import { xDaysAgo } from "../../utils"; describe("toBsdElastic > companies Names & OrgIds", () => { afterEach(resetDatabase); @@ -112,4 +113,104 @@ describe("toBsdElastic > companies Names & OrgIds", () => { expect(elasticBsda.companyOrgIds).toContain(intermediary1.siret); expect(elasticBsda.companyOrgIds).toContain(intermediary2.siret); }); + + describe("isReturnFor", () => { + it.each([ + WasteAcceptationStatus.REFUSED, + WasteAcceptationStatus.PARTIALLY_REFUSED + ])( + "waste acceptation status is %p > bsda should belong to tab", + async destinationReceptionAcceptationStatus => { + // Given + const transporter = await companyFactory(); + const bsda = await bsdaFactory({ + opt: { + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus + }, + transporterOpt: { + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + transporterCompanyVatNumber: transporter.vatNumber + } + }); + + // When + const bsdaForElastic = await getBsdaForElastic(bsda); + const elasticBsda = toBsdElastic(bsdaForElastic); + + // Then + expect(elasticBsda.isReturnFor).toContain(transporter?.siret); + } + ); + + it("status is REFUSED > bsda should belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsda = await bsdaFactory({ + opt: { + destinationReceptionDate: new Date(), + status: BsdaStatus.REFUSED + }, + transporterOpt: { + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + transporterCompanyVatNumber: transporter.vatNumber + } + }); + + // When + const bsdaForElastic = await getBsdaForElastic(bsda); + const elasticBsda = toBsdElastic(bsdaForElastic); + + // Then + expect(elasticBsda.isReturnFor).toContain(transporter?.siret); + }); + + it("waste acceptation status is ACCEPTED > bsda should not belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsda = await bsdaFactory({ + opt: { + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus: WasteAcceptationStatus.ACCEPTED + }, + transporterOpt: { + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + transporterCompanyVatNumber: transporter.vatNumber + } + }); + + // When + const bsdaForElastic = await getBsdaForElastic(bsda); + const elasticBsda = toBsdElastic(bsdaForElastic); + + // Then + expect(elasticBsda.isReturnFor).toStrictEqual([]); + }); + + it("bsda has been received too long ago > should not belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsda = await bsdaFactory({ + opt: { + destinationReceptionDate: xDaysAgo(new Date(), 10), + destinationReceptionAcceptationStatus: WasteAcceptationStatus.REFUSED + }, + transporterOpt: { + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + transporterCompanyVatNumber: transporter.vatNumber + } + }); + + // When + const bsdaForElastic = await getBsdaForElastic(bsda); + const elasticBsda = toBsdElastic(bsdaForElastic); + + // Then + expect(elasticBsda.isReturnFor).toStrictEqual([]); + }); + }); }); diff --git a/back/src/bsda/__tests__/factories.ts b/back/src/bsda/__tests__/factories.ts index abf3fa10f5..cc2c91c80b 100644 --- a/back/src/bsda/__tests__/factories.ts +++ b/back/src/bsda/__tests__/factories.ts @@ -6,7 +6,7 @@ import { upsertBaseSiret, userWithCompanyFactory } from "../../__tests__/factories"; -import { getUserCompanies } from "../../users/database"; +import { getCanAccessDraftOrgIds } from "../utils"; /** * Permet de créer un BSDA avec des données par défaut et @@ -83,28 +83,10 @@ export const bsdaFactory = async ({ .filter(Boolean) : []; // For drafts, only the owner's sirets that appear on the bsd have access - const canAccessDraftOrgIds: string[] = []; - if (created.isDraft) { - const userCompanies = await getUserCompanies( - (userId || userAndCompany?.user?.id) as string - ); - const userOrgIds = userCompanies.map(company => company.orgId); - - const bsdaOrgIds = [ - ...intermediariesOrgIds, - ...transportersOrgIds, - created.emitterCompanySiret, - created.ecoOrganismeSiret, - created.destinationCompanySiret, - created.destinationOperationNextDestinationCompanySiret, - created.workerCompanySiret, - created.brokerCompanySiret - ].filter(Boolean); - const userOrgIdsInForm = userOrgIds.filter(orgId => - bsdaOrgIds.includes(orgId) - ); - canAccessDraftOrgIds.push(...userOrgIdsInForm); - } + const canAccessDraftOrgIds = await getCanAccessDraftOrgIds( + created, + (userId || userAndCompany?.user?.id) as string + ); return prisma.bsda.update({ where: { id: created.id }, @@ -140,6 +122,27 @@ export const bsdaTransporterFactory = async ({ }); }; +export const bsdaFinalOperationFactory = async ({ + bsdaId, + opts = {} +}: { + bsdaId: string; + opts?: Omit< + Partial, + "initialBsda" | "finalBsda" + >; +}) => { + return prisma.bsdaFinalOperation.create({ + data: { + initialBsda: { connect: { id: bsdaId } }, + finalBsda: { connect: { id: bsdaId } }, + operationCode: "", + quantity: 1, + ...opts + } + }); +}; + const bsdaTransporterData: Omit< Prisma.BsdaTransporterCreateWithoutBsdaInput, "number" diff --git a/back/src/bsda/database.ts b/back/src/bsda/database.ts index cd1e10c7da..3c741a2ca8 100644 --- a/back/src/bsda/database.ts +++ b/back/src/bsda/database.ts @@ -118,6 +118,15 @@ export function getFirstTransporterSync(bsda: { return firstTransporter ?? null; } +export function getLastTransporterSync(bsda: { + transporters: BsdaTransporter[] | null; +}): BsdaTransporter | null { + const transporters = getTransportersSync(bsda); + const greatestNumber = Math.max(...transporters.map(t => t.number)); + const lastTransporter = transporters.find(t => t.number === greatestNumber); + return lastTransporter ?? null; +} + export function getNthTransporterSync( bsda: BsdaWithTransporters, n: number diff --git a/back/src/bsda/elastic.ts b/back/src/bsda/elastic.ts index 250fbe6e0a..a96442758e 100644 --- a/back/src/bsda/elastic.ts +++ b/back/src/bsda/elastic.ts @@ -1,4 +1,10 @@ -import { Bsda, BsdaStatus, BsdaTransporter, BsdType } from "@prisma/client"; +import { + Bsda, + BsdaStatus, + BsdaTransporter, + BsdType, + WasteAcceptationStatus +} from "@prisma/client"; import { getTransporterCompanyOrgId } from "@td/constants"; import { BsdElastic, indexBsd, transportPlateFilter } from "../common/elastic"; import { buildAddress } from "../companies/sirene/utils"; @@ -20,10 +26,13 @@ import { prisma } from "@td/prisma"; import { getRevisionOrgIds } from "../common/elasticHelpers"; import { getFirstTransporterSync, + getLastTransporterSync, getNextTransporterSync, getTransportersSync } from "./database"; import { getBsdaSubType } from "../common/subTypes"; +import { isDefined } from "../common/helpers"; +import { xDaysAgo } from "../utils"; export type BsdaForElastic = Bsda & BsdaWithTransporters & @@ -380,6 +389,7 @@ export function toBsdElastic(bsda: BsdaForElastic): BsdElastic { destinationOperationDate: bsda.destinationOperationDate?.getTime(), ...where, ...getBsdaRevisionOrgIds(bsda), + ...getBsdaReturnOrgIds(bsda), revisionRequests: bsda.bsdaRevisionRequests, sirets: Object.values(where).flat(), ...getRegistryFields(bsda), @@ -428,3 +438,36 @@ export function getBsdaRevisionOrgIds( ): Pick { return getRevisionOrgIds(bsda.bsdaRevisionRequests); } + +/** + * BSDA belongs to isReturnFor tab if: + * - waste has been received in the last 48 hours + * - waste hasn't been fully accepted + */ +export const belongsToIsReturnForTab = (bsda: BsdaForElastic) => { + const hasBeenReceivedLately = + isDefined(bsda.destinationReceptionDate) && + bsda.destinationReceptionDate! > xDaysAgo(new Date(), 2); + + if (!hasBeenReceivedLately) return false; + + const hasNotBeenFullyAccepted = + bsda.status === BsdaStatus.REFUSED || + bsda.destinationReceptionAcceptationStatus !== + WasteAcceptationStatus.ACCEPTED; + + return hasNotBeenFullyAccepted; +}; + +function getBsdaReturnOrgIds(bsda: BsdaForElastic): { isReturnFor: string[] } { + // Return tab + if (belongsToIsReturnForTab(bsda)) { + const lastTransporter = getLastTransporterSync(bsda); + + return { + isReturnFor: [lastTransporter?.transporterCompanySiret].filter(Boolean) + }; + } + + return { isReturnFor: [] }; +} diff --git a/back/src/bsda/permissions.ts b/back/src/bsda/permissions.ts index 9a26dcd808..9bb08dd886 100644 --- a/back/src/bsda/permissions.ts +++ b/back/src/bsda/permissions.ts @@ -298,9 +298,10 @@ export async function checkCanDelete(user: User, bsda: BsdaWithTransporters) { ? [bsda.emitterCompanySiret] : []; - const errorMsg = bsda.isDraft - ? "Vous n'êtes pas autorisé à supprimer ce bordereau." - : "Seuls les bordereaux en brouillon ou n'ayant pas encore été signés peuvent être supprimés"; + const errorMsg = + bsda.status === BsdaStatus.INITIAL + ? "Vous n'êtes pas autorisé à supprimer ce bordereau." + : "Seuls les bordereaux en brouillon ou n'ayant pas encore été signés peuvent être supprimés"; return checkUserPermissions( user, authorizedOrgIds, diff --git a/back/src/bsda/registry.ts b/back/src/bsda/registry.ts index a9de2afbd3..16b1e26836 100644 --- a/back/src/bsda/registry.ts +++ b/back/src/bsda/registry.ts @@ -417,7 +417,7 @@ export function toIncomingWaste(bsda: RegistryBsda): Required { traderRecepisseNumber: null, brokerCompanyName: bsda.brokerCompanyName, brokerCompanySiret: bsda.brokerCompanySiret, - brokerRecepisseNumber: null, + brokerRecepisseNumber: bsda.brokerRecepisseNumber, emitterCompanyMail: bsda.emitterCompanyMail, ...getOperationData(bsda), nextDestinationProcessingOperation: diff --git a/back/src/bsda/repository/bsda/create.ts b/back/src/bsda/repository/bsda/create.ts index 4322037ae9..3701b64672 100644 --- a/back/src/bsda/repository/bsda/create.ts +++ b/back/src/bsda/repository/bsda/create.ts @@ -6,7 +6,7 @@ import { import { enqueueCreatedBsdToIndex } from "../../../queue/producers/elastic"; import { bsdaEventTypes } from "./eventTypes"; import { BsdaWithTransporters } from "../../types"; -import { getUserCompanies } from "../../../users/database"; +import { getCanAccessDraftOrgIds } from "../../utils"; export type CreateBsdaFn = ( data: Prisma.BsdaCreateInput, @@ -52,11 +52,7 @@ export function buildCreateBsda(deps: RepositoryFnDeps): CreateBsdaFn { ) ); } - const intermediariesOrgIds: string[] = bsda.intermediaries - ? bsda.intermediaries - .flatMap(intermediary => [intermediary.siret, intermediary.vatNumber]) - .filter(Boolean) - : []; + const transportersOrgIds: string[] = bsda.transporters ? bsda.transporters .flatMap(t => [ @@ -66,32 +62,13 @@ export function buildCreateBsda(deps: RepositoryFnDeps): CreateBsdaFn { .filter(Boolean) : []; // For drafts, only the owner's sirets that appear on the bsd have access - const canAccessDraftOrgIds: string[] = []; - if (bsda.isDraft) { - const userCompanies = await getUserCompanies(user.id); - const userOrgIds = userCompanies.map(company => company.orgId); - const bsdaOrgIds = [ - ...intermediariesOrgIds, - ...transportersOrgIds, - bsda.emitterCompanySiret, - bsda.ecoOrganismeSiret, - bsda.destinationCompanySiret, - bsda.destinationOperationNextDestinationCompanySiret, - bsda.workerCompanySiret, - bsda.brokerCompanySiret - ].filter(Boolean); - const userOrgIdsInForm = userOrgIds.filter(orgId => - bsdaOrgIds.includes(orgId) - ); - canAccessDraftOrgIds.push(...userOrgIdsInForm); - } + const canAccessDraftOrgIds = await getCanAccessDraftOrgIds(bsda, user.id); const updatedBsda = await prisma.bsda.update({ where: { id: bsda.id }, data: { ...(canAccessDraftOrgIds.length ? { canAccessDraftOrgIds } : {}), - ...(transportersOrgIds.length ? { transportersOrgIds } : {}), - transportersOrgIds + ...(transportersOrgIds.length ? { transportersOrgIds } : {}) }, include: { grouping: { select: { id: true } }, diff --git a/back/src/bsda/resolvers/mutations/sign.ts b/back/src/bsda/resolvers/mutations/sign.ts index 4b461fd352..df63b4c976 100644 --- a/back/src/bsda/resolvers/mutations/sign.ts +++ b/back/src/bsda/resolvers/mutations/sign.ts @@ -3,6 +3,7 @@ import { BsdaStatus, BsdaType, Prisma, + UserNotification, WasteAcceptationStatus } from "@prisma/client"; @@ -41,6 +42,7 @@ import { parseBsdaAsync } from "../../validation"; import { prismaToZodBsda } from "../../validation/helpers"; import { AlreadySignedError } from "../../../bsvhu/errors"; import { operationHook } from "../../operationHook"; +import { getNotificationSubscribers } from "../../../users/notifications"; const signBsda: MutationResolvers["signBsda"] = async ( _, @@ -391,36 +393,40 @@ async function sendAlertIfFollowingBsdaChangedPlannedDestination(bsda: Bsda) { } const previousBsdas = await getBsdaHistory(bsda); + for (const previousBsda of previousBsdas) { if ( previousBsda.destinationOperationNextDestinationCompanySiret && previousBsda.destinationOperationNextDestinationCompanySiret !== bsda.destinationCompanySiret ) { - const mail = renderMail(finalDestinationModified, { - to: [ - { - email: previousBsda.emitterCompanyMail!, - name: previousBsda.emitterCompanyName! - } - ], - variables: { - id: previousBsda.id, - emitter: { - siret: bsda.emitterCompanySiret!, - name: bsda.emitterCompanyName! - }, - destination: { - siret: bsda.destinationCompanySiret!, - name: bsda.destinationCompanyName! - }, - plannedDestination: { - siret: previousBsda.destinationOperationNextDestinationCompanySiret, - name: previousBsda.destinationOperationNextDestinationCompanyName! + const subscribers = await getNotificationSubscribers( + UserNotification.BSDA_FINAL_DESTINATION_UPDATE, + [previousBsda.emitterCompanySiret].filter(Boolean) + ); + + if (subscribers.length) { + const mail = renderMail(finalDestinationModified, { + to: subscribers, + variables: { + id: previousBsda.id, + emitter: { + siret: bsda.emitterCompanySiret!, + name: bsda.emitterCompanyName! + }, + destination: { + siret: bsda.destinationCompanySiret!, + name: bsda.destinationCompanyName! + }, + plannedDestination: { + siret: + previousBsda.destinationOperationNextDestinationCompanySiret, + name: previousBsda.destinationOperationNextDestinationCompanyName! + } } - } - }); - sendMail(mail); + }); + sendMail(mail); + } } } } diff --git a/back/src/bsda/utils.ts b/back/src/bsda/utils.ts new file mode 100644 index 0000000000..c9b4162867 --- /dev/null +++ b/back/src/bsda/utils.ts @@ -0,0 +1,42 @@ +import { getUserCompanies } from "../users/database"; +import { BsdaWithIntermediaries, BsdaWithTransporters } from "./types"; + +export const getCanAccessDraftOrgIds = async ( + bsda: BsdaWithIntermediaries & BsdaWithTransporters, + userId: string +): Promise => { + const intermediariesOrgIds: string[] = bsda.intermediaries + ? bsda.intermediaries + .flatMap(intermediary => [intermediary.siret, intermediary.vatNumber]) + .filter(Boolean) + : []; + const transportersOrgIds: string[] = bsda.transporters + ? bsda.transporters + .flatMap(t => [ + t.transporterCompanySiret, + t.transporterCompanyVatNumber + ]) + .filter(Boolean) + : []; + // For drafts, only the owner's sirets that appear on the bsd have access + const canAccessDraftOrgIds: string[] = []; + if (bsda.isDraft) { + const userCompanies = await getUserCompanies(userId); + const userOrgIds = userCompanies.map(company => company.orgId); + const bsdaOrgIds = [ + ...intermediariesOrgIds, + ...transportersOrgIds, + bsda.emitterCompanySiret, + bsda.ecoOrganismeSiret, + bsda.destinationCompanySiret, + bsda.destinationOperationNextDestinationCompanySiret, + bsda.workerCompanySiret, + bsda.brokerCompanySiret + ].filter(Boolean); + const userOrgIdsInForm = userOrgIds.filter(orgId => + bsdaOrgIds.includes(orgId) + ); + canAccessDraftOrgIds.push(...userOrgIdsInForm); + } + return canAccessDraftOrgIds; +}; diff --git a/back/src/bsda/validation/recipify.ts b/back/src/bsda/validation/recipify.ts new file mode 100644 index 0000000000..d7696f47c7 --- /dev/null +++ b/back/src/bsda/validation/recipify.ts @@ -0,0 +1,65 @@ +import { getTransporterCompanyOrgId } from "@td/constants"; +import { ParsedZodBsda } from "./schema"; +import { CompanyRole } from "../../common/validation/zod/schema"; +import { + buildRecipify, + RecipifyInputAccessor +} from "../../common/validation/recipify"; + +const recipifyBsdaAccessors = ( + bsd: ParsedZodBsda, + // Tranformations should not be run on sealed fields + sealedFields: string[] +): RecipifyInputAccessor[] => [ + ...(bsd.transporters ?? []).map( + (_, idx) => + ({ + role: CompanyRole.Transporter, + skip: !!bsd.transporters![idx].transporterTransportSignatureDate, + orgIdGetter: () => { + const orgId = getTransporterCompanyOrgId({ + transporterCompanySiret: + bsd.transporters![idx].transporterCompanySiret ?? null, + transporterCompanyVatNumber: + bsd.transporters![idx].transporterCompanyVatNumber ?? null + }); + return orgId ?? null; + }, + setter: async (bsda: ParsedZodBsda, receipt) => { + const transporter = bsda.transporters![idx]; + if (transporter.transporterRecepisseIsExempted) { + transporter.transporterRecepisseNumber = null; + transporter.transporterRecepisseValidityLimit = null; + transporter.transporterRecepisseDepartment = null; + } else { + transporter.transporterRecepisseNumber = + receipt?.receiptNumber ?? null; + transporter.transporterRecepisseValidityLimit = + receipt?.validityLimit ?? null; + transporter.transporterRecepisseDepartment = + receipt?.department ?? null; + } + } + } as RecipifyInputAccessor) + ), + { + role: CompanyRole.Broker, + skip: sealedFields.includes("brokerRecepisseNumber"), + orgIdGetter: () => { + return bsd.brokerCompanySiret ?? null; + }, + setter: async (bsda: ParsedZodBsda, receipt) => { + if (!bsda.brokerRecepisseNumber && receipt?.receiptNumber) { + bsda.brokerRecepisseNumber = receipt.receiptNumber; + } + if (!bsda.brokerRecepisseValidityLimit && receipt?.validityLimit) { + bsda.brokerRecepisseValidityLimit = receipt.validityLimit; + } + if (!bsda.brokerRecepisseDepartment && receipt?.department) { + bsda.brokerRecepisseDepartment = receipt.department; + } + } + } +]; + +export const recipifyBsda = buildRecipify(recipifyBsdaAccessors); diff --git a/back/src/bsda/validation/refinements.ts b/back/src/bsda/validation/refinements.ts index cc0364f0d1..027a1f1ed1 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,11 +27,13 @@ import { prisma } from "@td/prisma"; import { isWorker } from "../../companies/validation"; import { isDestinationRefinement, - isNotDormantRefinement, + isEcoOrganismeRefinement, + isEmitterNotDormantRefinement, isRegisteredVatNumberRefinement, isTransporterRefinement, refineSiretAndGetCompany } from "../../common/validation/zod/refinement"; +import { CompanyRole } from "../../common/validation/zod/schema"; export const checkOperationIsAfterReception: Refinement = ( bsda, @@ -259,36 +262,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,17 +300,19 @@ export const checkCompanies: Refinement = async ( ); }; - await isNotDormantRefinement(bsda.emitterCompanySiret, zodContext); + await isEmitterNotDormantRefinement(bsda.emitterCompanySiret, zodContext); await isDestinationRefinement( bsda.destinationCompanySiret, zodContext, "DESTINATION", + CompanyRole.Destination, isBsdaDestinationExemptFromVerification ); await isDestinationRefinement( bsda.destinationOperationNextDestinationCompanySiret, zodContext, "DESTINATION", + CompanyRole.DestinationOperationNextDestination, isBsdaDestinationExemptFromVerification ); for (const transporter of bsda.transporters ?? []) { @@ -355,7 +330,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/rules.ts b/back/src/bsda/validation/rules.ts index 336610be7e..ea32fdbd00 100644 --- a/back/src/bsda/validation/rules.ts +++ b/back/src/bsda/validation/rules.ts @@ -725,8 +725,7 @@ export const bsdaEditionRules: BsdaEditionRules = { sealed: { from: "OPERATION" } }, brokerRecepisseValidityLimit: { - readableFieldName: - "la date de validité de la certification de l'entreprise de travaux", + readableFieldName: "la date de validité du récépissé du courtier", sealed: { from: "OPERATION" } }, wasteCode: { diff --git a/back/src/bsda/validation/schema.ts b/back/src/bsda/validation/schema.ts index ddeb053516..143821b0fe 100644 --- a/back/src/bsda/validation/schema.ts +++ b/back/src/bsda/validation/schema.ts @@ -32,10 +32,10 @@ import { fillIntermediariesOrgIds, fillWasteConsistenceWhenForwarding, emptyWorkerCertificationWhenWorkerIsDisabled, - updateTransporterRecepisse + updateTransporterRecepisse, + runTransformers } from "./transformers"; -import { sirenifyBsda, sirenifyBsdaTransporter } from "./sirenify"; -import { updateTransportersRecepisse } from "../../common/validation/zod/transformers"; +import { sirenifyBsdaTransporter } from "./sirenify"; import { CompanyRole, foreignVatNumberSchema, @@ -291,8 +291,7 @@ export const contextualSchemaAsync = (context: BsdaValidationContext) => { ? // Transformations asynchrones qui ne sont pas // `enableCompletionTransformers=false`; transformedSyncSchema - .transform(sirenifyBsda(context)) - .transform(updateTransportersRecepisse) + .transform((bsda: ParsedZodBsda) => runTransformers(bsda, context)) .transform(fillWasteConsistenceWhenForwarding) : transformedSyncSchema; diff --git a/back/src/bsda/validation/sirenify.ts b/back/src/bsda/validation/sirenify.ts index 2711bb483f..68552c9092 100644 --- a/back/src/bsda/validation/sirenify.ts +++ b/back/src/bsda/validation/sirenify.ts @@ -1,21 +1,19 @@ import { ParsedZodBsda, ParsedZodBsdaTransporter } from "./schema"; import { CompanyInput } from "../../generated/graphql/types"; -import { nextBuildSirenify } from "../../companies/sirenify"; -import { getSealedFields } from "./rules"; import { - BsdaValidationContext, - ZodBsdaTransformer, - ZodBsdaTransporterTransformer -} from "./types"; + nextBuildSirenify, + NextCompanyInputAccessor +} from "../../companies/sirenify"; +import { ZodBsdaTransporterTransformer } from "./types"; 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 +21,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 +29,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,43 +37,58 @@ 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: ( - context: BsdaValidationContext -) => ZodBsdaTransformer = context => { - return async bsda => { - const sealedFields = await getSealedFields(bsda, context); - return nextBuildSirenify(sirenifyBsdaAccessors)( - bsda, - sealedFields - ); - }; -}; +export const sirenifyBsda = nextBuildSirenify( + sirenifyBsdaAccessors +); const sirenifyBsdaTransporterAccessors = ( bsdaTransporter: ParsedZodBsdaTransporter diff --git a/back/src/bsda/validation/transformers.ts b/back/src/bsda/validation/transformers.ts index 61e244cde3..ec3ba5ae66 100644 --- a/back/src/bsda/validation/transformers.ts +++ b/back/src/bsda/validation/transformers.ts @@ -1,6 +1,27 @@ import { prisma } from "@td/prisma"; -import { ZodBsdaTransformer, ZodBsdaTransporterTransformer } from "./types"; +import { + BsdaValidationContext, + ZodBsdaTransformer, + ZodBsdaTransporterTransformer +} from "./types"; import { recipifyTransporter } from "../../common/validation/zod/transformers"; +import { ParsedZodBsda } from "./schema"; +import { sirenifyBsda } from "./sirenify"; +import { recipifyBsda } from "./recipify"; +import { getSealedFields } from "./rules"; + +export const runTransformers = async ( + bsda: ParsedZodBsda, + context: BsdaValidationContext +): Promise => { + const transformers = [sirenifyBsda, recipifyBsda]; + const sealedFields = await getSealedFields(bsda, context); + + for (const transformer of transformers) { + bsda = await transformer(bsda, sealedFields); + } + return bsda; +}; export const fillIntermediariesOrgIds: ZodBsdaTransformer = bsda => { bsda.intermediariesOrgIds = bsda.intermediaries diff --git a/back/src/bsdasris/__tests__/elastic.integration.ts b/back/src/bsdasris/__tests__/elastic.integration.ts index 800aca9c2c..cd89fb5504 100644 --- a/back/src/bsdasris/__tests__/elastic.integration.ts +++ b/back/src/bsdasris/__tests__/elastic.integration.ts @@ -3,7 +3,8 @@ import { companyFactory } from "../../__tests__/factories"; import { getBsdasriForElastic, toBsdElastic } from "../elastic"; import { BsdElastic } from "../../common/elastic"; import { bsdasriFactory } from "./factories"; -import { Company } from "@prisma/client"; +import { BsdasriStatus, Company, WasteAcceptationStatus } from "@prisma/client"; +import { xDaysAgo } from "../../utils"; describe("toBsdElastic > companies Names & OrgIds", () => { afterEach(resetDatabase); @@ -60,4 +61,100 @@ describe("toBsdElastic > companies Names & OrgIds", () => { expect(elasticBsdasri.companyOrgIds).toContain(destination.siret); expect(elasticBsdasri.companyOrgIds).toContain(ecoOrganisme.siret); }); + + describe("isReturnFor", () => { + it.each([ + WasteAcceptationStatus.REFUSED, + WasteAcceptationStatus.PARTIALLY_REFUSED + ])( + "waste acceptation status is %p > bsvhu should belong to tab", + async destinationReceptionAcceptationStatus => { + // Given + const transporter = await companyFactory(); + const bsdasri = await bsdasriFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus + } + }); + + // When + const bsdasriForElastic = await getBsdasriForElastic(bsdasri); + const { isReturnFor } = toBsdElastic(bsdasriForElastic); + + // Then + expect(isReturnFor).toContain(transporter.siret); + } + ); + + it("status is REFUSED > bsvhu should belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsdasri = await bsdasriFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + destinationReceptionDate: new Date(), + status: BsdasriStatus.REFUSED + } + }); + + // When + const bsdasriForElastic = await getBsdasriForElastic(bsdasri); + const { isReturnFor } = toBsdElastic(bsdasriForElastic); + + // Then + expect(isReturnFor).toContain(transporter.siret); + }); + + it("waste acceptation status is ACCEPTED > bsvhu should not belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsdasri = await bsdasriFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus: WasteAcceptationStatus.ACCEPTED + } + }); + + // When + const bsdasriForElastic = await getBsdasriForElastic(bsdasri); + const { isReturnFor } = toBsdElastic(bsdasriForElastic); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + + it("bsda has been received too long ago > should not belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsdasri = await bsdasriFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + destinationReceptionDate: xDaysAgo(new Date(), 10), + destinationReceptionAcceptationStatus: WasteAcceptationStatus.REFUSED + } + }); + + // When + const bsdasriForElastic = await getBsdasriForElastic(bsdasri); + const { isReturnFor } = toBsdElastic(bsdasriForElastic); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + }); }); diff --git a/back/src/bsdasris/__tests__/factories.ts b/back/src/bsdasris/__tests__/factories.ts index e22567b281..56e53e4f0f 100644 --- a/back/src/bsdasris/__tests__/factories.ts +++ b/back/src/bsdasris/__tests__/factories.ts @@ -65,6 +65,27 @@ export const bsdasriFactory = async ({ return created; }; +export const bsdasriFinalOperationFactory = async ({ + bsdasriId, + opts = {} +}: { + bsdasriId: string; + opts?: Omit< + Partial, + "initialBsdasri" | "finalBsdasri" + >; +}) => { + return prisma.bsdasriFinalOperation.create({ + data: { + initialBsdasri: { connect: { id: bsdasriId } }, + finalBsdasri: { connect: { id: bsdasriId } }, + operationCode: "", + quantity: 1, + ...opts + } + }); +}; + export const initialData = company => ({ emitterCompanySiret: company.siret, emitterCompanyName: company.name, diff --git a/back/src/bsdasris/elastic.ts b/back/src/bsdasris/elastic.ts index 7670a7fb9f..73cf1a07f1 100644 --- a/back/src/bsdasris/elastic.ts +++ b/back/src/bsdasris/elastic.ts @@ -1,4 +1,10 @@ -import { BsdasriStatus, Bsdasri, BsdasriType, BsdType } from "@prisma/client"; +import { + BsdasriStatus, + Bsdasri, + BsdasriType, + BsdType, + WasteAcceptationStatus +} from "@prisma/client"; import { BsdElastic, indexBsd, transportPlateFilter } from "../common/elastic"; import { GraphQLContext } from "../types"; import { getRegistryFields } from "./registry"; @@ -15,6 +21,8 @@ import { import { prisma } from "@td/prisma"; import { getRevisionOrgIds } from "../common/elasticHelpers"; import { getBsdasriSubType } from "../common/subTypes"; +import { isDefined } from "../common/helpers"; +import { xDaysAgo } from "../utils"; export type BsdasriForElastic = Bsdasri & BsdasriWithGrouping & @@ -223,6 +231,7 @@ export function toBsdElastic(bsdasri: BsdasriForElastic): BsdElastic { ...where, ...getBsdasriRevisionOrgIds(bsdasri), + ...getBsdasriReturnOrgIds(bsdasri), revisionRequests: bsdasri.bsdasriRevisionRequests, sirets: Object.values(where).flat(), @@ -260,3 +269,34 @@ export function getBsdasriRevisionOrgIds( ): Pick { return getRevisionOrgIds(bsdasri.bsdasriRevisionRequests); } + +/** + * BSDASRI belongs to isReturnFor tab if: + * - waste has been received in the last 48 hours + * - waste hasn't been fully accepted at reception + */ +export const belongsToIsReturnForTab = (bsdasri: Bsdasri) => { + const hasBeenReceivedLately = + isDefined(bsdasri.destinationReceptionDate) && + bsdasri.destinationReceptionDate! > xDaysAgo(new Date(), 2); + + if (!hasBeenReceivedLately) return false; + + const hasNotBeenFullyAccepted = + bsdasri.status === BsdasriStatus.REFUSED || + bsdasri.destinationReceptionAcceptationStatus !== + WasteAcceptationStatus.ACCEPTED; + + return hasNotBeenFullyAccepted; +}; + +function getBsdasriReturnOrgIds(bsdasri: Bsdasri): { isReturnFor: string[] } { + // Return tab + if (belongsToIsReturnForTab(bsdasri)) { + return { + isReturnFor: [bsdasri.transporterCompanySiret].filter(Boolean) + }; + } + + return { isReturnFor: [] }; +} diff --git a/back/src/bsdasris/recipify.ts b/back/src/bsdasris/recipify.ts index 099cbda196..16444febb8 100644 --- a/back/src/bsdasris/recipify.ts +++ b/back/src/bsdasris/recipify.ts @@ -3,10 +3,19 @@ import { BsdasriRecepisseInput } from "../generated/graphql/types"; import { recipifyGeneric } from "../companies/recipify"; -import { - autocompletedRecepisse, - genericGetter -} from "../common/validation/recipify"; + +export const autocompletedRecepisse = ( + input: BsdasriInput, + recepisseInput: BsdasriRecepisseInput +) => ({ + ...input.transporter?.recepisse, + ...recepisseInput +}); + +const genericGetter = (input: BsdasriInput) => () => + input?.transporter?.recepisse?.isExempted !== true + ? input?.transporter?.company + : null; const dasriAccessors = (input: BsdasriInput) => [ { 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/bsds/indexation/__tests__/bulkIndexBsds.integration.ts b/back/src/bsds/indexation/__tests__/bulkIndexBsds.integration.ts index 0be92e230b..8eefe39fb3 100644 --- a/back/src/bsds/indexation/__tests__/bulkIndexBsds.integration.ts +++ b/back/src/bsds/indexation/__tests__/bulkIndexBsds.integration.ts @@ -255,10 +255,11 @@ describe("indexAllBsdTypeSync", () => { }); it("should index BSVHUs synchronously", async () => { + const user = await userFactory(); const bsvhus = await Promise.all([ - bsvhuFactory({}), - bsvhuFactory({}), - bsvhuFactory({}) + bsvhuFactory({ userId: user.id }), + bsvhuFactory({ userId: user.id }), + bsvhuFactory({ userId: user.id }) ]); await indexAllBsdTypeSync({ bsdName: "bsvhu", @@ -407,10 +408,11 @@ describe("indexAllBsdTypeConcurrently", () => { }); it("should index BSVHUs using the index queue", async () => { + const user = await userFactory(); const bsvhus = await Promise.all([ - bsvhuFactory({}), - bsvhuFactory({}), - bsvhuFactory({}) + bsvhuFactory({ userId: user.id }), + bsvhuFactory({ userId: user.id }), + bsvhuFactory({ userId: user.id }) ]); const jobs = await indexAllBsdTypeConcurrentJobs({ bsdName: "bsvhu", diff --git a/back/src/bsds/indexation/bulkIndexBsds.ts b/back/src/bsds/indexation/bulkIndexBsds.ts index 5b91dbe5a9..08412811f9 100644 --- a/back/src/bsds/indexation/bulkIndexBsds.ts +++ b/back/src/bsds/indexation/bulkIndexBsds.ts @@ -29,7 +29,10 @@ import { BsvhuForElasticInclude, toBsdElastic as bsvhuToBsdElastic } from "../../bsvhu/elastic"; -import { toBsdElastic as bspaohToBsdElastic } from "../../bspaoh/elastic"; +import { + BspaohForElasticInclude, + toBsdElastic as bspaohToBsdElastic +} from "../../bspaoh/elastic"; import { bulkIndexQueue } from "../../queue/producers/elastic"; import { IndexAllFnSignature, FindManyAndIndexBsdsFnSignature } from "./types"; @@ -78,6 +81,9 @@ const prismaFindManyOptions = { }, bsdd: { include: FormForElasticInclude + }, + bspaoh: { + include: BspaohForElasticInclude } }; diff --git a/back/src/bsds/indexation/reindexBsdHelpers.ts b/back/src/bsds/indexation/reindexBsdHelpers.ts index 259704f229..fe15007ace 100644 --- a/back/src/bsds/indexation/reindexBsdHelpers.ts +++ b/back/src/bsds/indexation/reindexBsdHelpers.ts @@ -3,7 +3,7 @@ import { BsdaForElasticInclude, indexBsda } from "../../bsda/elastic"; import { BsdasriForElasticInclude, indexBsdasri } from "../../bsdasris/elastic"; import { getBsffForElastic, indexBsff } from "../../bsffs/elastic"; import { getBsvhuForElastic, indexBsvhu } from "../../bsvhu/elastic"; -import { indexBspaoh } from "../../bspaoh/elastic"; +import { getBspaohForElastic, indexBspaoh } from "../../bspaoh/elastic"; import { getFormForElastic, indexForm } from "../../forms/elastic"; import { deleteBsd } from "../../common/elastic"; import { getReadonlyBsdaRepository } from "../../bsda/repository"; @@ -75,7 +75,7 @@ export async function reindex(bsdId, exitFn) { }); if (!!bspaoh && !bspaoh.isDeleted) { - await indexBspaoh(bspaoh); + await indexBspaoh(await getBspaohForElastic(bspaoh)); } else { await deleteBsd({ id: bsdId }); } diff --git a/back/src/bsds/resolvers/Mutation.ts b/back/src/bsds/resolvers/Mutation.ts index 453f21a0fa..7572a813b4 100644 --- a/back/src/bsds/resolvers/Mutation.ts +++ b/back/src/bsds/resolvers/Mutation.ts @@ -1,7 +1,12 @@ import { MutationResolvers } from "../../generated/graphql/types"; +import cloneBsdResolver from "./mutations/clone"; import createPdfAccessTokenResolver from "./mutations/createPdfAccessToken"; import reindexBsds from "./mutations/reindexBsds"; + export const Mutation: MutationResolvers = { createPdfAccessToken: createPdfAccessTokenResolver, - reindexBsds: reindexBsds + reindexBsds: reindexBsds, + ...(process.env.ALLOW_CLONING_BSDS === "true" + ? { cloneBsd: cloneBsdResolver } + : {}) }; diff --git a/back/src/bsds/resolvers/index.ts b/back/src/bsds/resolvers/index.ts index 49dc97bda7..bdd3f998fc 100644 --- a/back/src/bsds/resolvers/index.ts +++ b/back/src/bsds/resolvers/index.ts @@ -2,10 +2,12 @@ import { QueryResolvers } from "../../generated/graphql/types"; import bsds from "./queries/bsds"; import { bsdResolver } from "./queries/bsd"; import { Mutation } from "./Mutation"; +import controlBsdsResolver from "./queries/controlBsds"; const Query: QueryResolvers = { bsds, - bsd: bsdResolver + bsd: bsdResolver, + controlBsds: controlBsdsResolver }; export default { Query, Mutation }; diff --git a/back/src/bsds/resolvers/mutations/__tests__/cloneBsd.integration.ts b/back/src/bsds/resolvers/mutations/__tests__/cloneBsd.integration.ts new file mode 100644 index 0000000000..35f27b1a3e --- /dev/null +++ b/back/src/bsds/resolvers/mutations/__tests__/cloneBsd.integration.ts @@ -0,0 +1,450 @@ +import gql from "graphql-tag"; +import { + bsddFinalOperationFactory, + formFactory, + userFactory +} from "../../../../__tests__/factories"; +import { Mutation } from "../../../../generated/graphql/types"; +import makeClient from "../../../../__tests__/testClient"; +import { prisma } from "@td/prisma"; +import { + bsdaInclude, + bsdasriInclude, + bsddInclude, + bsffInclude, + bspaohInclude, + bsvhuInclude +} from "../utils/clone.utils"; +import { isDefinedStrict } from "../../../../common/helpers"; +import { + bsdaFactory, + bsdaFinalOperationFactory +} from "../../../../bsda/__tests__/factories"; +import { + bsdasriFactory, + bsdasriFinalOperationFactory +} from "../../../../bsdasris/__tests__/factories"; +import { + createBsff, + createBsffPackagingFinalOperation +} from "../../../../bsffs/__tests__/factories"; +import { bsvhuFactory } from "../../../../bsvhu/__tests__/factories.vhu"; +import { bspaohFactory } from "../../../../bspaoh/__tests__/factories"; + +const CLONE_BSD = gql` + mutation cloneBsd($id: String!) { + cloneBsd(id: $id) { + id + } + } +`; + +const NON_CLONED_KEYS = [ + "id", + "readableId", + "rowNumber", + "createdAt", + "updatedAt", + "formId", + "bsdaId", + "bsffId", + "bspaohId", + "finalBsdaId", + "initialBsdaId", + "finalBsdasriId", + "initialBsdasriId", + "initialFormId", + "finalFormId", + "finalBsffPackagingId", + "initialBsffPackagingId" +]; +/** + * Removes null, undefined and empty strings / arrays from object, AND keys that we + * do NOT clone on purpose + */ +const removeIrrelevantKeys = obj => { + for (const key in obj) { + if ( + !isDefinedStrict(obj[key]) || + NON_CLONED_KEYS.includes(key) || + (Array.isArray(obj[key]) && !obj[key].length) + ) { + delete obj[key]; + } else if (typeof obj[key] === "object") { + removeIrrelevantKeys(obj[key]); + } + } + return obj; +}; + +const clone = obj => JSON.parse(JSON.stringify(obj)); + +const expectBsdsToMatch = (bsd1, bsd2) => { + const cleanedUpBsd1 = removeIrrelevantKeys(clone(bsd1)); + const cleanedUpBsd2 = removeIrrelevantKeys(clone(bsd2)); + + expect(cleanedUpBsd1).toEqual(cleanedUpBsd2); +}; + +describe("mutation cloneBsd", () => { + it("should clone regular BSDD", async () => { + // Given + const user = await userFactory(); + const bsdd = await formFactory({ ownerId: user.id }); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsdd.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsdd = await prisma.form.findFirstOrThrow({ + where: { id: bsdd.id }, + include: bsddInclude + }); + + const newBsdd = await prisma.form.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsddInclude + }); + + expectBsdsToMatch(initialBsdd, newBsdd); + }); + + it("should clone BSDD with final operations", async () => { + // Given + const user = await userFactory(); + const bsdd = await formFactory({ ownerId: user.id }); + await bsddFinalOperationFactory({ + bsddId: bsdd.id, + opts: { + noTraceability: true, + operationCode: "OP CODE", + quantity: 101 + } + }); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsdd.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsdd = await prisma.form.findFirstOrThrow({ + where: { id: bsdd.id }, + include: bsddInclude + }); + + const newBsdd = await prisma.form.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsddInclude + }); + + expectBsdsToMatch(initialBsdd, newBsdd); + }); + + it("should clone regular BSDA", async () => { + // Given + const user = await userFactory(); + const bsda = await bsdaFactory({ userId: user.id }); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsda.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsda = await prisma.bsda.findFirstOrThrow({ + where: { id: bsda.id }, + include: bsdaInclude + }); + + const newBsda = await prisma.bsda.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsdaInclude + }); + + expectBsdsToMatch(initialBsda, newBsda); + }); + + it("should clone BSDA with final operations", async () => { + // Given + const user = await userFactory(); + const bsda = await bsdaFactory({ userId: user.id }); + await bsdaFinalOperationFactory({ + bsdaId: bsda.id, + opts: { + operationCode: "OP CODE", + quantity: 77 + } + }); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsda.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsda = await prisma.bsda.findFirstOrThrow({ + where: { id: bsda.id }, + include: bsdaInclude + }); + + const newBsda = await prisma.bsda.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsdaInclude + }); + + expectBsdsToMatch(initialBsda, newBsda); + }); + + it("should clone regular BSDASRI", async () => { + // Given + const user = await userFactory(); + const bsdasri = await bsdasriFactory({}); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsdasri.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsdasri = await prisma.bsdasri.findFirstOrThrow({ + where: { id: bsdasri.id }, + include: bsdasriInclude + }); + + const newBsdasri = await prisma.bsdasri.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsdasriInclude + }); + + expectBsdsToMatch(initialBsdasri, newBsdasri); + }); + + it("should clone BSDASRI with final operations", async () => { + // Given + const user = await userFactory(); + const bsdasri = await bsdasriFactory({}); + await bsdasriFinalOperationFactory({ + bsdasriId: bsdasri.id, + opts: { + operationCode: "OP CODE", + quantity: 88 + } + }); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsdasri.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsdasri = await prisma.bsdasri.findFirstOrThrow({ + where: { id: bsdasri.id }, + include: bsdasriInclude + }); + + const newBsdasri = await prisma.bsdasri.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsdasriInclude + }); + + expectBsdsToMatch(initialBsdasri, newBsdasri); + }); + + it("should clone regular BSFF", async () => { + // Given + const user = await userFactory(); + const bsff = await createBsff(); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsff.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsff = await prisma.bsff.findFirstOrThrow({ + where: { id: bsff.id }, + include: bsffInclude + }); + + const newBsff = await prisma.bsff.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsffInclude + }); + + expectBsdsToMatch(initialBsff, newBsff); + }); + + it("should clone BSFF with final operations in packagings", async () => { + // Given + const user = await userFactory(); + const bsff = await createBsff(); + await createBsffPackagingFinalOperation({ + bsffPackagingId: bsff.packagings[0].id, + opts: { + noTraceability: true, + quantity: 17 + } + }); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsff.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsff = await prisma.bsff.findFirstOrThrow({ + where: { id: bsff.id }, + include: bsffInclude + }); + + const newBsff = await prisma.bsff.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsffInclude + }); + + expectBsdsToMatch(initialBsff, newBsff); + + const initialPackagings = await prisma.bsffPackaging.findMany({ + where: { bsffId: initialBsff.id }, + include: { finalOperations: true } + }); + + const newPackagings = await prisma.bsffPackaging.findMany({ + where: { bsffId: newBsff.id }, + include: { finalOperations: true } + }); + + expectBsdsToMatch(initialPackagings, newPackagings); + }); + + it("should clone regular BSVHU", async () => { + // Given + const user = await userFactory(); + const bsvhu = await bsvhuFactory({}); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bsvhu.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBsvhu = await prisma.bsvhu.findFirstOrThrow({ + where: { id: bsvhu.id }, + include: bsvhuInclude + }); + + const newBsvhu = await prisma.bsvhu.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bsvhuInclude + }); + + expectBsdsToMatch(initialBsvhu, newBsvhu); + }); + + it("should clone regular BSPAOH", async () => { + // Given + const user = await userFactory(); + const bspaoh = await bspaohFactory({}); + + // When + const { mutate } = makeClient(user); + const { errors, data } = await mutate>( + CLONE_BSD, + { + variables: { + id: bspaoh.id + } + } + ); + + // Then + expect(errors).toBeUndefined(); + + const initialBspaoh = await prisma.bspaoh.findFirstOrThrow({ + where: { id: bspaoh.id }, + include: bspaohInclude + }); + + const newBspaoh = await prisma.bspaoh.findFirstOrThrow({ + where: { id: data.cloneBsd.id }, + include: bspaohInclude + }); + + expectBsdsToMatch(initialBspaoh, newBspaoh); + }); +}); diff --git a/back/src/bsds/resolvers/mutations/__tests__/createPdfAccessToken.integration.ts b/back/src/bsds/resolvers/mutations/__tests__/createPdfAccessToken.integration.ts index 7cec571b65..24cf7bff4b 100644 --- a/back/src/bsds/resolvers/mutations/__tests__/createPdfAccessToken.integration.ts +++ b/back/src/bsds/resolvers/mutations/__tests__/createPdfAccessToken.integration.ts @@ -92,7 +92,7 @@ describe("Mutation.creatPdfAccessToken", () => { }) ]); }); - it("should deny bsdd token creation for non SENT status", async () => { + it("should deny bsdd token creation for non SENT status and no citerne/ADR business", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const bsdd = await formFactory({ ownerId: user.id, @@ -117,7 +117,7 @@ describe("Mutation.creatPdfAccessToken", () => { expect(errors).toEqual([ expect.objectContaining({ message: - "Seuls les bordereaux pris en charge par un transporteur peuvent être consulté via un accès temporaire.", + "Seuls les bordereaux pris en charge par un transporteur peuvent être consultés via un accès temporaire.", extensions: expect.objectContaining({ code: ErrorCode.FORBIDDEN }) @@ -157,6 +157,45 @@ describe("Mutation.creatPdfAccessToken", () => { // token lifespan should be 30' expect(token.expiresAt.getTime() > addMinutes(new Date(), 29).getTime()); }); + + it("should create a token for a bsdd if status is not OK but belongs to isReturnTab", async () => { + // Given + const { user, company } = await userWithCompanyFactory("MEMBER"); + const bsdd = await formFactory({ + ownerId: user.id, + opt: { + transporters: { + create: { transporterCompanySiret: company.siret, number: 1 } + }, + status: Status.RECEIVED, + receivedAt: new Date(), + emptyReturnADR: "EMPTY_CITERNE" + } + }); + + // When + const { mutate } = makeClient(user); + const { data } = await mutate>( + CREATE_PDF_TOKEN, + { + variables: { + input: { bsdId: bsdd.id } + } + } + ); + + // Then + const token = await prisma.pdfAccessToken.findFirstOrThrow({ + where: { bsdType: BsdType.BSDD, userId: user.id, bsdId: bsdd.id } + }); + + expect(data.createPdfAccessToken).toEqual( + `http://api.trackdechets.local/road-control/${token.token}` + ); + // token lifespan should be 30' + expect(token.expiresAt.getTime() > addMinutes(new Date(), 29).getTime()); + }); + it("should deny token creation for a bsda if user does not belong to it", async () => { const { company } = await userWithCompanyFactory("MEMBER"); const user = await userFactory(); @@ -211,7 +250,7 @@ describe("Mutation.creatPdfAccessToken", () => { expect(errors).toEqual([ expect.objectContaining({ message: - "Seuls les bordereaux pris en charge par un transporteur peuvent être consulté via un accès temporaire.", + "Seuls les bordereaux pris en charge par un transporteur peuvent être consultés via un accès temporaire.", extensions: expect.objectContaining({ code: ErrorCode.FORBIDDEN }) @@ -298,7 +337,7 @@ describe("Mutation.creatPdfAccessToken", () => { expect(errors).toEqual([ expect.objectContaining({ message: - "Seuls les bordereaux pris en charge par un transporteur peuvent être consulté via un accès temporaire.", + "Seuls les bordereaux pris en charge par un transporteur peuvent être consultés via un accès temporaire.", extensions: expect.objectContaining({ code: ErrorCode.FORBIDDEN }) @@ -380,7 +419,7 @@ describe("Mutation.creatPdfAccessToken", () => { expect(errors).toEqual([ expect.objectContaining({ message: - "Seuls les bordereaux pris en charge par un transporteur peuvent être consulté via un accès temporaire.", + "Seuls les bordereaux pris en charge par un transporteur peuvent être consultés via un accès temporaire.", extensions: expect.objectContaining({ code: ErrorCode.FORBIDDEN }) @@ -464,7 +503,7 @@ describe("Mutation.creatPdfAccessToken", () => { expect(errors).toEqual([ expect.objectContaining({ message: - "Seuls les bordereaux pris en charge par un transporteur peuvent être consulté via un accès temporaire.", + "Seuls les bordereaux pris en charge par un transporteur peuvent être consultés via un accès temporaire.", extensions: expect.objectContaining({ code: ErrorCode.FORBIDDEN }) diff --git a/back/src/bsds/resolvers/mutations/clone.ts b/back/src/bsds/resolvers/mutations/clone.ts new file mode 100644 index 0000000000..74454b82fd --- /dev/null +++ b/back/src/bsds/resolvers/mutations/clone.ts @@ -0,0 +1,44 @@ +import { MutationResolvers } from "../../../generated/graphql/types"; +import { applyAuthStrategies, AuthType } from "../../../auth"; +import { z } from "zod"; +import { + cloneBsda, + cloneBsdasri, + cloneBsff, + cloneBsvhu, + cloneBspaoh, + cloneBsdd +} from "./utils/clone.utils"; +import { checkIsAuthenticated } from "../../../common/permissions"; + +const idSchema = z.string().min(15).max(50); + +const cloneBsdResolver: MutationResolvers["cloneBsd"] = async ( + _, + { id }, + context +) => { + applyAuthStrategies(context, [AuthType.Session]); + const user = checkIsAuthenticated(context); + + await idSchema.parse(id); + + let newBsd; + if (id.startsWith("BSDA-")) { + newBsd = await cloneBsda(user, id); + } else if (id.startsWith("DASRI-")) { + newBsd = await cloneBsdasri(user, id); + } else if (id.startsWith("FF-")) { + newBsd = await cloneBsff(user, id); + } else if (id.startsWith("VHU-")) { + newBsd = await cloneBsvhu(user, id); + } else if (id.startsWith("PAOH-")) { + newBsd = await cloneBspaoh(user, id); + } else { + newBsd = await cloneBsdd(user, id); + } + + return { id: newBsd.id }; +}; + +export default cloneBsdResolver; diff --git a/back/src/bsds/resolvers/mutations/createPdfAccessToken.ts b/back/src/bsds/resolvers/mutations/createPdfAccessToken.ts index 868a58b4ce..5f70c764ac 100644 --- a/back/src/bsds/resolvers/mutations/createPdfAccessToken.ts +++ b/back/src/bsds/resolvers/mutations/createPdfAccessToken.ts @@ -27,6 +27,12 @@ import { ROAD_CONTROL_SLUG } from "@td/constants"; import { checkCanRead as checkCanReadForm } from "../../../forms/permissions"; import { checkCanRead as checkCanReadBsdasri } from "../../../bsdasris/permissions"; import { ForbiddenError } from "../../../common/errors"; +import { belongsToIsReturnForTab as formBelongsToIsReturnForTab } from "../../../forms/elasticHelpers"; +import { belongsToIsReturnForTab as bsdaBelongsToIsReturnForTab } from "../../../bsda/elastic"; +import { belongsToIsReturnForTab as bsdasriBelongsToIsReturnForTab } from "../../../bsdasris/elastic"; +import { belongsToIsReturnForTab as bsffBelongsToIsReturnForTab } from "../../../bsffs/elastic"; +import { belongsToIsReturnForTab as bsvhuBelongsToIsReturnForTab } from "../../../bsvhu/elastic"; +import { belongsToIsReturnForTab as bspaohBelongsToIsReturnForTab } from "../../../bspaoh/elastic"; const accessors = { [BsdType.BSDD]: id => @@ -56,6 +62,15 @@ const checkStatus = { [BsdType.BSPAOH]: bsd => bsd.status === BspaohStatus.SENT }; +const checkBelongsToIsReturnForTab = { + [BsdType.BSDD]: bsd => formBelongsToIsReturnForTab(bsd), + [BsdType.BSDA]: bsd => bsdaBelongsToIsReturnForTab(bsd), + [BsdType.BSDASRI]: bsd => bsdasriBelongsToIsReturnForTab(bsd), + [BsdType.BSFF]: bsd => bsffBelongsToIsReturnForTab(bsd), + [BsdType.BSVHU]: bsd => bsvhuBelongsToIsReturnForTab(bsd), + [BsdType.BSPAOH]: bsd => bspaohBelongsToIsReturnForTab(bsd) +}; + const permissions = { [BsdType.BSDD]: (user, bsdd) => checkCanReadForm(user, bsdd), [BsdType.BSDA]: (user, bsda) => checkCanReadBsda(user, bsda), @@ -97,10 +112,13 @@ const createPdfAccessToken: MutationResolvers["createPdfAccessToken"] = async ( const bsd = await accessors[bsdType](input.bsdId); - // check status - if (!checkStatus[bsdType](bsd)) { + // Check status + if ( + !checkStatus[bsdType](bsd) && + !checkBelongsToIsReturnForTab[bsdType](bsd) + ) { throw new ForbiddenError( - "Seuls les bordereaux pris en charge par un transporteur peuvent être consulté via un accès temporaire." + "Seuls les bordereaux pris en charge par un transporteur peuvent être consultés via un accès temporaire." ); } // check perms diff --git a/back/src/bsds/resolvers/mutations/utils/clone.utils.ts b/back/src/bsds/resolvers/mutations/utils/clone.utils.ts new file mode 100644 index 0000000000..489c1919f9 --- /dev/null +++ b/back/src/bsds/resolvers/mutations/utils/clone.utils.ts @@ -0,0 +1,967 @@ +import { Form, Prisma } from "@prisma/client"; +import { prisma } from "@td/prisma"; +import getReadableId, { ReadableIdPrefix } from "../../../../forms/readableId"; +import { getFormRepository } from "../../../../forms/repository"; +import { prismaJsonNoNull } from "../../../../common/converter"; +import { FormWithTransporters } from "../../../../forms/types"; +import { UserInputError } from "../../../../common/errors"; +import { getBsdaRepository } from "../../../../bsda/repository"; +import { getBsdasriRepository } from "../../../../bsdasris/repository"; +import { getBsffRepository } from "../../../../bsffs/repository"; +import { getBsvhuRepository } from "../../../../bsvhu/repository"; +import { getBspaohRepository } from "../../../../bspaoh/repository"; + +export const bsdaInclude = { + transporters: true, + intermediaries: true, + finalOperations: true, + grouping: true, + groupedIn: true, + forwarding: true, + forwardedIn: true +}; + +export const cloneBsda = async (user: Express.User, id: string) => { + const bsda = await prisma.bsda.findFirstOrThrow({ + where: { id }, + include: bsdaInclude + }); + + if (!bsda) { + throw new UserInputError(`ID invalide ${id}`); + } + + if ( + bsda.grouping?.length || + bsda.groupedIn || + bsda.forwarding || + bsda.forwardedIn + ) { + throw new UserInputError( + "Impossible de cloner ce type de BSD pour le moment" + ); + } + + const { create } = getBsdaRepository(user); + + const newBsdaCreateInput: Omit< + Required, + // Ignored for the time being + | "rowNumber" + | "bsdaRevisionRequests" + | "FinalOperationToFinalForm" + | "forwardedIn" + | "forwarding" + | "groupedIn" + | "grouping" + | "rowNumber" + // Done manually + | "finalOperations" + > = { + id: getReadableId(ReadableIdPrefix.BSDA), + brokerCompanyAddress: bsda.brokerCompanyAddress, + brokerCompanyContact: bsda.brokerCompanyContact, + brokerCompanyMail: bsda.brokerCompanyMail, + brokerCompanyName: bsda.brokerCompanyName, + brokerCompanyPhone: bsda.brokerCompanyPhone, + brokerCompanySiret: bsda.brokerCompanySiret, + brokerRecepisseDepartment: bsda.brokerRecepisseDepartment, + brokerRecepisseNumber: bsda.brokerRecepisseNumber, + brokerRecepisseValidityLimit: bsda.brokerRecepisseValidityLimit, + canAccessDraftOrgIds: bsda.canAccessDraftOrgIds, + createdAt: bsda.createdAt, + destinationCap: bsda.destinationCap, + destinationCompanyAddress: bsda.destinationCompanyAddress, + destinationCompanyContact: bsda.destinationCompanyContact, + destinationCompanyMail: bsda.destinationCompanyMail, + destinationCompanyName: bsda.destinationCompanyName, + destinationCompanyPhone: bsda.destinationCompanyPhone, + destinationCompanySiret: bsda.destinationCompanySiret, + destinationCustomInfo: bsda.destinationCustomInfo, + destinationOperationCode: bsda.destinationOperationCode, + destinationOperationDate: bsda.destinationOperationDate, + destinationOperationDescription: bsda.destinationOperationDescription, + destinationOperationMode: bsda.destinationOperationMode, + destinationOperationNextDestinationCap: + bsda.destinationOperationNextDestinationCap, + destinationOperationNextDestinationCompanyAddress: + bsda.destinationOperationNextDestinationCompanyAddress, + destinationOperationNextDestinationCompanyContact: + bsda.destinationOperationNextDestinationCompanyContact, + destinationOperationNextDestinationCompanyMail: + bsda.destinationOperationNextDestinationCompanyMail, + destinationOperationNextDestinationCompanyName: + bsda.destinationOperationNextDestinationCompanyName, + destinationOperationNextDestinationCompanyPhone: + bsda.destinationOperationNextDestinationCompanyPhone, + destinationOperationNextDestinationCompanySiret: + bsda.destinationOperationNextDestinationCompanySiret, + destinationOperationNextDestinationCompanyVatNumber: + bsda.destinationOperationNextDestinationCompanyVatNumber, + destinationOperationNextDestinationPlannedOperationCode: + bsda.destinationOperationNextDestinationPlannedOperationCode, + destinationOperationSignatureAuthor: + bsda.destinationOperationSignatureAuthor, + destinationOperationSignatureDate: bsda.destinationOperationSignatureDate, + destinationPlannedOperationCode: bsda.destinationPlannedOperationCode, + destinationReceptionAcceptationStatus: + bsda.destinationReceptionAcceptationStatus, + destinationReceptionDate: bsda.destinationReceptionDate, + destinationReceptionRefusalReason: bsda.destinationReceptionRefusalReason, + destinationReceptionWeight: bsda.destinationReceptionWeight, + ecoOrganismeName: bsda.ecoOrganismeName, + ecoOrganismeSiret: bsda.ecoOrganismeSiret, + emitterCompanyAddress: bsda.emitterCompanyAddress, + emitterCompanyContact: bsda.emitterCompanyContact, + emitterCompanyMail: bsda.emitterCompanyMail, + emitterCompanyName: bsda.emitterCompanyName, + emitterCompanyPhone: bsda.emitterCompanyPhone, + emitterCompanySiret: bsda.emitterCompanySiret, + emitterCustomInfo: bsda.emitterCustomInfo, + emitterEmissionSignatureAuthor: bsda.emitterEmissionSignatureAuthor, + emitterEmissionSignatureDate: bsda.emitterEmissionSignatureDate, + emitterIsPrivateIndividual: bsda.emitterIsPrivateIndividual, + emitterPickupSiteAddress: bsda.emitterPickupSiteAddress, + emitterPickupSiteCity: bsda.emitterPickupSiteCity, + emitterPickupSiteInfos: bsda.emitterPickupSiteInfos, + emitterPickupSiteName: bsda.emitterPickupSiteName, + emitterPickupSitePostalCode: bsda.emitterPickupSitePostalCode, + intermediaries: bsda.intermediaries.length + ? { + createMany: { + data: bsda.intermediaries + } + } + : {}, + intermediariesOrgIds: bsda.intermediariesOrgIds, + isDeleted: bsda.isDeleted, + isDraft: bsda.isDraft, + packagings: prismaJsonNoNull(bsda.packagings), + status: bsda.status, + transporters: bsda.transporters.length + ? { + createMany: { + data: bsda.transporters!.map((t, idx) => { + const { id, ...data } = t; + return { ...data, bsdaId: undefined, number: idx + 1 }; + }) + } + } + : {}, + transportersOrgIds: bsda.transportersOrgIds, + transporterTransportSignatureDate: bsda.transporterTransportSignatureDate, + type: bsda.type, + updatedAt: bsda.updatedAt, + wasteAdr: bsda.wasteAdr, + wasteCode: bsda.wasteCode, + wasteConsistence: bsda.wasteConsistence, + wasteFamilyCode: bsda.wasteFamilyCode, + wasteMaterialName: bsda.wasteMaterialName, + wastePop: bsda.wastePop, + wasteSealNumbers: bsda.wasteSealNumbers, + weightIsEstimate: bsda.weightIsEstimate, + weightValue: bsda.weightValue, + workerCertificationCertificationNumber: + bsda.workerCertificationCertificationNumber, + workerCertificationHasSubSectionFour: + bsda.workerCertificationHasSubSectionFour, + workerCertificationHasSubSectionThree: + bsda.workerCertificationHasSubSectionThree, + workerCertificationOrganisation: bsda.workerCertificationOrganisation, + workerCertificationValidityLimit: bsda.workerCertificationValidityLimit, + workerCompanyAddress: bsda.workerCompanyAddress, + workerCompanyContact: bsda.workerCompanyContact, + workerCompanyMail: bsda.workerCompanyMail, + workerCompanyName: bsda.workerCompanyName, + workerCompanyPhone: bsda.workerCompanyPhone, + workerCompanySiret: bsda.workerCompanySiret, + workerIsDisabled: bsda.workerIsDisabled, + workerWorkHasEmitterPaperSignature: bsda.workerWorkHasEmitterPaperSignature, + workerWorkSignatureAuthor: bsda.workerWorkSignatureAuthor, + workerWorkSignatureDate: bsda.workerWorkSignatureDate + }; + + const newBsda = await create(newBsdaCreateInput); + + // Final operations + for await (const operation of bsda.finalOperations) { + const finalOperation: Prisma.BsdaFinalOperationCreateInput = { + initialBsda: { connect: { id: newBsda.id } }, + finalBsda: { connect: { id: newBsda.id } }, + operationCode: operation.operationCode, + quantity: operation.quantity + }; + + await prisma.bsdaFinalOperation.create({ + data: finalOperation + }); + } + + return newBsda; +}; + +export const bsdasriInclude = { + finalOperations: true, + grouping: true, + groupedIn: true, + synthesizedIn: true, + synthesizing: true +}; + +export const cloneBsdasri = async (user: Express.User, id: string) => { + const bsdasri = await prisma.bsdasri.findFirstOrThrow({ + where: { id }, + include: bsdasriInclude + }); + + if (!bsdasri) { + throw new UserInputError(`ID invalide ${id}`); + } + + if ( + bsdasri.grouping?.length || + bsdasri.groupedIn || + bsdasri.synthesizedInId || + bsdasri.synthesizedIn || + bsdasri.synthesizing.length + ) { + throw new UserInputError( + "Impossible de cloner ce type de BSD pour le moment" + ); + } + + const { create } = getBsdasriRepository(user); + + const newBsdasriCreateInput: Omit< + Required, + // Ignored for the time being + | "bsdasriRevisionRequests" + | "FinalOperationToFinalBsdasri" + | "groupedIn" + | "grouping" + | "synthesizedIn" + | "synthesizing" + | "rowNumber" + // Done manually + | "finalOperations" + > = { + id: getReadableId(ReadableIdPrefix.DASRI), + createdAt: bsdasri.createdAt, + destinationCompanyAddress: bsdasri.destinationCompanyAddress, + destinationCompanyContact: bsdasri.destinationCompanyContact, + destinationCompanyMail: bsdasri.destinationCompanyMail, + destinationCompanyName: bsdasri.destinationCompanyName, + destinationCompanyPhone: bsdasri.destinationCompanyPhone, + destinationCompanySiret: bsdasri.destinationCompanySiret, + destinationCustomInfo: bsdasri.destinationCustomInfo, + destinationOperationCode: bsdasri.destinationOperationCode, + destinationOperationDate: bsdasri.destinationOperationDate, + destinationOperationMode: bsdasri.destinationOperationMode, + destinationOperationSignatureAuthor: + bsdasri.destinationOperationSignatureAuthor, + destinationOperationSignatureDate: + bsdasri.destinationOperationSignatureDate, + destinationReceptionAcceptationStatus: + bsdasri.destinationReceptionAcceptationStatus, + destinationReceptionDate: bsdasri.destinationReceptionDate, + destinationReceptionSignatureAuthor: + bsdasri.destinationReceptionSignatureAuthor, + destinationReceptionSignatureDate: + bsdasri.destinationReceptionSignatureDate, + destinationReceptionWasteRefusalReason: + bsdasri.destinationReceptionWasteRefusalReason, + destinationReceptionWasteRefusedWeightValue: + bsdasri.destinationReceptionWasteRefusedWeightValue, + destinationReceptionWasteVolume: bsdasri.destinationReceptionWasteVolume, + destinationReceptionWasteWeightValue: + bsdasri.destinationReceptionWasteWeightValue, + destinationWastePackagings: prismaJsonNoNull( + bsdasri.destinationWastePackagings + ), + ecoOrganismeName: bsdasri.ecoOrganismeName, + ecoOrganismeSiret: bsdasri.ecoOrganismeSiret, + emissionSignatory: bsdasri.emissionSignatoryId + ? { + connect: { + id: bsdasri.emissionSignatoryId + } + } + : {}, + emittedByEcoOrganisme: bsdasri.emittedByEcoOrganisme, + emitterCompanyAddress: bsdasri.emitterCompanyAddress, + emitterCompanyContact: bsdasri.emitterCompanyContact, + emitterCompanyMail: bsdasri.emitterCompanyMail, + emitterCompanyName: bsdasri.emitterCompanyName, + emitterCompanyPhone: bsdasri.emitterCompanyPhone, + emitterCompanySiret: bsdasri.emitterCompanySiret, + emitterCustomInfo: bsdasri.emitterCustomInfo, + emitterEmissionSignatureAuthor: bsdasri.emitterEmissionSignatureAuthor, + emitterEmissionSignatureDate: bsdasri.emitterEmissionSignatureDate, + emitterPickupSiteAddress: bsdasri.emitterPickupSiteAddress, + emitterPickupSiteCity: bsdasri.emitterPickupSiteCity, + emitterPickupSiteInfos: bsdasri.emitterPickupSiteInfos, + emitterPickupSiteName: bsdasri.emitterPickupSiteName, + emitterPickupSitePostalCode: bsdasri.emitterPickupSitePostalCode, + emitterWastePackagings: prismaJsonNoNull(bsdasri.emitterWastePackagings), + emitterWasteVolume: bsdasri.emitterWasteVolume, + emitterWasteWeightIsEstimate: bsdasri.emitterWasteWeightIsEstimate, + emitterWasteWeightValue: bsdasri.emitterWasteWeightValue, + groupingEmitterSirets: bsdasri.groupingEmitterSirets, + handedOverToRecipientAt: bsdasri.handedOverToRecipientAt, + identificationNumbers: bsdasri.identificationNumbers, + isDeleted: bsdasri.isDeleted, + isDraft: bsdasri.isDraft, + isEmissionDirectTakenOver: bsdasri.isEmissionDirectTakenOver, + isEmissionTakenOverWithSecretCode: + bsdasri.isEmissionTakenOverWithSecretCode, + operationSignatory: bsdasri.operationSignatoryId + ? { + connect: { + id: bsdasri.operationSignatoryId + } + } + : {}, + receptionSignatory: bsdasri.receptionSignatoryId + ? { + connect: { + id: bsdasri.receptionSignatoryId + } + } + : {}, + status: bsdasri.status, + synthesisEmitterSirets: bsdasri.synthesisEmitterSirets, + transporterAcceptationStatus: bsdasri.transporterAcceptationStatus, + transporterCompanyAddress: bsdasri.transporterCompanyAddress, + transporterCompanyContact: bsdasri.transporterCompanyContact, + transporterCompanyMail: bsdasri.transporterCompanyMail, + transporterCompanyName: bsdasri.transporterCompanyName, + transporterCompanyPhone: bsdasri.transporterCompanyPhone, + transporterCompanySiret: bsdasri.transporterCompanySiret, + transporterCompanyVatNumber: bsdasri.transporterCompanyVatNumber, + transporterCustomInfo: bsdasri.transporterCustomInfo, + transporterRecepisseDepartment: bsdasri.transporterRecepisseDepartment, + transporterRecepisseIsExempted: bsdasri.transporterRecepisseIsExempted, + transporterRecepisseNumber: bsdasri.transporterRecepisseNumber, + transporterRecepisseValidityLimit: + bsdasri.transporterRecepisseValidityLimit, + transporterTakenOverAt: bsdasri.transporterTakenOverAt, + transporterTransportMode: bsdasri.transporterTransportMode, + transporterTransportPlates: bsdasri.transporterTransportPlates, + transporterTransportSignatureAuthor: + bsdasri.transporterTransportSignatureAuthor, + transporterTransportSignatureDate: + bsdasri.transporterTransportSignatureDate, + transporterWastePackagings: prismaJsonNoNull( + bsdasri.transporterWastePackagings + ), + transporterWasteRefusalReason: bsdasri.transporterWasteRefusalReason, + transporterWasteRefusedWeightValue: + bsdasri.transporterWasteRefusedWeightValue, + transporterWasteVolume: bsdasri.transporterWasteVolume, + transporterWasteWeightIsEstimate: bsdasri.transporterWasteWeightIsEstimate, + transporterWasteWeightValue: bsdasri.transporterWasteWeightValue, + transportSignatory: bsdasri.transportSignatoryId + ? { + connect: { + id: bsdasri.transportSignatoryId + } + } + : {}, + type: bsdasri.type, + updatedAt: bsdasri.updatedAt, + wasteAdr: bsdasri.wasteAdr, + wasteCode: bsdasri.wasteCode + }; + + const newBsdasri = await create(newBsdasriCreateInput); + + // Final operations + for await (const operation of bsdasri.finalOperations) { + const finalOperation: Prisma.BsdasriFinalOperationCreateInput = { + initialBsdasri: { connect: { id: newBsdasri.id } }, + finalBsdasri: { connect: { id: newBsdasri.id } }, + operationCode: operation.operationCode, + quantity: operation.quantity + }; + + await prisma.bsdasriFinalOperation.create({ + data: finalOperation + }); + } + + return newBsdasri; +}; + +export const bsffInclude = { + transporters: true, + ficheInterventions: true, + packagings: true +}; + +export const cloneBsff = async (user: Express.User, id: string) => { + const bsff = await prisma.bsff.findFirstOrThrow({ + where: { id }, + include: bsffInclude + }); + + if (!bsff) { + throw new UserInputError(`ID invalide ${id}`); + } + + const { create } = getBsffRepository(user); + + const newBsffCreateInput: Omit< + Required, + // Ignored for the time being + "rowNumber" + > = { + id: getReadableId(ReadableIdPrefix.FF), + createdAt: bsff.createdAt, + destinationCap: bsff.destinationCap, + destinationCompanyAddress: bsff.destinationCompanyAddress, + destinationCompanyContact: bsff.destinationCompanyContact, + destinationCompanyMail: bsff.destinationCompanyMail, + destinationCompanyName: bsff.destinationCompanyName, + destinationCompanyPhone: bsff.destinationCompanyPhone, + destinationCompanySiret: bsff.destinationCompanySiret, + destinationCustomInfo: bsff.destinationCustomInfo, + destinationPlannedOperationCode: bsff.destinationPlannedOperationCode, + destinationReceptionDate: bsff.destinationReceptionDate, + destinationReceptionSignatureAuthor: + bsff.destinationReceptionSignatureAuthor, + destinationReceptionSignatureDate: bsff.destinationReceptionSignatureDate, + detenteurCompanySirets: bsff.detenteurCompanySirets, + emitterCompanyAddress: bsff.emitterCompanyAddress, + emitterCompanyContact: bsff.emitterCompanyContact, + emitterCompanyMail: bsff.emitterCompanyMail, + emitterCompanyName: bsff.emitterCompanyName, + emitterCompanyPhone: bsff.emitterCompanyPhone, + emitterCompanySiret: bsff.emitterCompanySiret, + emitterCustomInfo: bsff.emitterCustomInfo, + emitterEmissionSignatureAuthor: bsff.emitterEmissionSignatureAuthor, + emitterEmissionSignatureDate: bsff.emitterEmissionSignatureDate, + ficheInterventions: bsff.ficheInterventions.length + ? { create: bsff.ficheInterventions[0] } + : {}, + isDeleted: bsff.isDeleted, + isDraft: bsff.isDraft, + packagings: bsff.packagings.length + ? { + createMany: { + data: bsff.packagings!.map(t => { + const { id, bsffId, ...data } = t; + return data; + }) + } + } + : {}, + status: bsff.status, + transporters: bsff.transporters.length + ? { + createMany: { + data: bsff.transporters!.map((t, idx) => { + const { id, bsffId, ...data } = t; + return { ...data, number: idx + 1 }; + }) + } + } + : {}, + transportersOrgIds: bsff.transportersOrgIds, + transporterTransportSignatureDate: bsff.transporterTransportSignatureDate, + type: bsff.type, + updatedAt: bsff.updatedAt, + wasteAdr: bsff.wasteAdr, + wasteCode: bsff.wasteCode, + wasteDescription: bsff.wasteDescription, + weightIsEstimate: bsff.weightIsEstimate, + weightValue: bsff.weightValue + }; + + const newBsff = await create({ data: newBsffCreateInput }); + + // Final operations in packagings + const initialBsffPackagings = await prisma.bsffPackaging.findMany({ + where: { bsffId: bsff.id }, + include: { finalOperations: true } + }); + const newBsffPackagings = await prisma.bsffPackaging.findMany({ + where: { bsffId: newBsff.id } + }); + + for (let i = 0; i < initialBsffPackagings.length; i++) { + if (initialBsffPackagings[i].finalOperations.length) { + for await (const finalOperation of initialBsffPackagings[i] + .finalOperations) { + const finalOperationCreateInput: Prisma.BsffPackagingFinalOperationCreateInput = + { + initialBsffPackaging: { connect: { id: newBsffPackagings[i].id } }, + finalBsffPackaging: { connect: { id: newBsffPackagings[i].id } }, + quantity: finalOperation.quantity, + noTraceability: finalOperation.noTraceability, + operationCode: finalOperation.operationCode + }; + + await prisma.bsffPackagingFinalOperation.create({ + data: finalOperationCreateInput + }); + } + } + } + + return newBsff; +}; + +export const bsvhuInclude = { + intermediaries: true +}; + +export const cloneBsvhu = async (user: Express.User, id: string) => { + const bsvhu = await prisma.bsvhu.findFirstOrThrow({ + where: { id }, + include: bsvhuInclude + }); + + if (!bsvhu) { + throw new UserInputError(`ID invalide ${id}`); + } + + const { create } = getBsvhuRepository(user); + + const newBsvhuCreateInput: Omit< + Required, + // Ignored for the time being + "rowNumber" + > = { + id: getReadableId(ReadableIdPrefix.VHU), + createdAt: bsvhu.createdAt, + destinationAgrementNumber: bsvhu.destinationAgrementNumber, + destinationCompanyAddress: bsvhu.destinationCompanyAddress, + destinationCompanyContact: bsvhu.destinationCompanyContact, + destinationCompanyMail: bsvhu.destinationCompanyMail, + destinationCompanyName: bsvhu.destinationCompanyName, + destinationCompanyPhone: bsvhu.destinationCompanyPhone, + destinationCompanySiret: bsvhu.destinationCompanySiret, + destinationCustomInfo: bsvhu.destinationCustomInfo, + destinationOperationCode: bsvhu.destinationOperationCode, + destinationOperationDate: bsvhu.destinationOperationDate, + destinationOperationMode: bsvhu.destinationOperationMode, + destinationOperationNextDestinationCompanyAddress: + bsvhu.destinationOperationNextDestinationCompanyAddress, + destinationOperationNextDestinationCompanyContact: + bsvhu.destinationOperationNextDestinationCompanyContact, + destinationOperationNextDestinationCompanyMail: + bsvhu.destinationOperationNextDestinationCompanyMail, + destinationOperationNextDestinationCompanyName: + bsvhu.destinationOperationNextDestinationCompanyName, + destinationOperationNextDestinationCompanyPhone: + bsvhu.destinationOperationNextDestinationCompanyPhone, + destinationOperationNextDestinationCompanySiret: + bsvhu.destinationOperationNextDestinationCompanySiret, + destinationOperationNextDestinationCompanyVatNumber: + bsvhu.destinationOperationNextDestinationCompanyVatNumber, + destinationOperationSignatureAuthor: + bsvhu.destinationOperationSignatureAuthor, + destinationOperationSignatureDate: bsvhu.destinationOperationSignatureDate, + destinationPlannedOperationCode: bsvhu.destinationPlannedOperationCode, + destinationReceptionAcceptationStatus: + bsvhu.destinationReceptionAcceptationStatus, + destinationReceptionDate: bsvhu.destinationReceptionDate, + destinationReceptionIdentificationNumbers: + bsvhu.destinationReceptionIdentificationNumbers, + destinationReceptionIdentificationType: + bsvhu.destinationReceptionIdentificationType, + destinationReceptionQuantity: bsvhu.destinationReceptionQuantity, + destinationReceptionRefusalReason: bsvhu.destinationReceptionRefusalReason, + destinationReceptionWeight: bsvhu.destinationReceptionWeight, + destinationType: bsvhu.destinationType, + emitterAgrementNumber: bsvhu.emitterAgrementNumber, + emitterCompanyAddress: bsvhu.emitterCompanyAddress, + emitterCompanyCity: bsvhu.emitterCompanyCity, + emitterCompanyContact: bsvhu.emitterCompanyContact, + emitterCompanyCountry: bsvhu.emitterCompanyCountry, + emitterCompanyMail: bsvhu.emitterCompanyMail, + emitterCompanyName: bsvhu.emitterCompanyName, + emitterCompanyPhone: bsvhu.emitterCompanyPhone, + emitterCompanyPostalCode: bsvhu.emitterCompanyPostalCode, + emitterCompanySiret: bsvhu.emitterCompanySiret, + emitterCompanyStreet: bsvhu.emitterCompanyStreet, + emitterCustomInfo: bsvhu.emitterCustomInfo, + emitterEmissionSignatureAuthor: bsvhu.emitterEmissionSignatureAuthor, + emitterEmissionSignatureDate: bsvhu.emitterEmissionSignatureDate, + emitterIrregularSituation: bsvhu.emitterIrregularSituation, + emitterNoSiret: bsvhu.emitterNoSiret, + identificationNumbers: bsvhu.identificationNumbers, + identificationType: bsvhu.identificationType, + intermediaries: bsvhu.intermediaries.length + ? { + createMany: { + data: bsvhu.intermediaries + } + } + : {}, + intermediariesOrgIds: bsvhu.intermediariesOrgIds, + canAccessDraftOrgIds: bsvhu.canAccessDraftOrgIds, + isDeleted: bsvhu.isDeleted, + isDraft: bsvhu.isDraft, + packaging: bsvhu.packaging, + quantity: bsvhu.quantity, + status: bsvhu.status, + transporterCompanyAddress: bsvhu.transporterCompanyAddress, + transporterCompanyContact: bsvhu.transporterCompanyContact, + transporterCompanyMail: bsvhu.transporterCompanyMail, + transporterCompanyName: bsvhu.transporterCompanyName, + transporterCompanyPhone: bsvhu.transporterCompanyPhone, + transporterCompanySiret: bsvhu.transporterCompanySiret, + transporterCompanyVatNumber: bsvhu.transporterCompanyVatNumber, + transporterCustomInfo: bsvhu.transporterCustomInfo, + transporterRecepisseDepartment: bsvhu.transporterRecepisseDepartment, + transporterRecepisseIsExempted: bsvhu.transporterRecepisseIsExempted, + transporterRecepisseNumber: bsvhu.transporterRecepisseNumber, + transporterRecepisseValidityLimit: bsvhu.transporterRecepisseValidityLimit, + transporterTransportPlates: bsvhu.transporterTransportPlates, + transporterTransportSignatureAuthor: + bsvhu.transporterTransportSignatureAuthor, + transporterTransportSignatureDate: bsvhu.transporterTransportSignatureDate, + transporterTransportTakenOverAt: bsvhu.transporterTransportTakenOverAt, + brokerCompanyName: bsvhu.brokerCompanyName, + brokerCompanySiret: bsvhu.brokerCompanySiret, + brokerCompanyAddress: bsvhu.brokerCompanyAddress, + brokerCompanyContact: bsvhu.brokerCompanyContact, + brokerCompanyPhone: bsvhu.brokerCompanyPhone, + brokerCompanyMail: bsvhu.brokerCompanyMail, + brokerRecepisseNumber: bsvhu.brokerRecepisseNumber, + brokerRecepisseDepartment: bsvhu.brokerRecepisseDepartment, + brokerRecepisseValidityLimit: bsvhu.brokerRecepisseValidityLimit, + traderCompanyName: bsvhu.traderCompanyName, + traderCompanySiret: bsvhu.traderCompanySiret, + traderCompanyAddress: bsvhu.traderCompanyAddress, + traderCompanyContact: bsvhu.traderCompanyContact, + traderCompanyPhone: bsvhu.traderCompanyPhone, + traderCompanyMail: bsvhu.traderCompanyMail, + traderRecepisseNumber: bsvhu.traderRecepisseNumber, + traderRecepisseDepartment: bsvhu.traderRecepisseDepartment, + traderRecepisseValidityLimit: bsvhu.traderRecepisseValidityLimit, + updatedAt: bsvhu.updatedAt, + wasteCode: bsvhu.wasteCode, + weightIsEstimate: bsvhu.weightIsEstimate, + weightValue: bsvhu.weightValue, + ecoOrganismeName: bsvhu.ecoOrganismeName, + ecoOrganismeSiret: bsvhu.ecoOrganismeSiret + }; + + const newBsvhu = await create(newBsvhuCreateInput); + + return newBsvhu; +}; + +export const bspaohInclude = { + transporters: true +}; + +export const cloneBspaoh = async (user: Express.User, id: string) => { + const bspaoh = await prisma.bspaoh.findFirstOrThrow({ + where: { id }, + include: bspaohInclude + }); + + if (!bspaoh) { + throw new UserInputError(`ID invalide ${id}`); + } + + const { create } = getBspaohRepository(user); + + const newBspaohCreateInput: Omit< + Required, + // Ignored for the time being + "rowNumber" + > = { + id: getReadableId(ReadableIdPrefix.PAOH), + canAccessDraftSirets: bspaoh.canAccessDraftSirets, + createdAt: bspaoh.createdAt, + currentTransporterOrgId: bspaoh.currentTransporterOrgId, + destinationCap: bspaoh.destinationCap, + destinationCompanyAddress: bspaoh.destinationCompanyAddress, + destinationCompanyContact: bspaoh.destinationCompanyContact, + destinationCompanyMail: bspaoh.destinationCompanyMail, + destinationCompanyName: bspaoh.destinationCompanyName, + destinationCompanyPhone: bspaoh.destinationCompanyPhone, + destinationCompanySiret: bspaoh.destinationCompanySiret, + destinationCustomInfo: bspaoh.destinationCustomInfo, + destinationOperationCode: bspaoh.destinationOperationCode, + destinationOperationDate: bspaoh.destinationOperationDate, + destinationOperationSignatureAuthor: + bspaoh.destinationOperationSignatureAuthor, + destinationOperationSignatureDate: bspaoh.destinationOperationSignatureDate, + destinationReceptionAcceptationStatus: + bspaoh.destinationReceptionAcceptationStatus, + destinationReceptionDate: bspaoh.destinationReceptionDate, + destinationReceptionSignatureAuthor: + bspaoh.destinationReceptionSignatureAuthor, + destinationReceptionSignatureDate: bspaoh.destinationReceptionSignatureDate, + destinationReceptionWasteAcceptedWeightValue: + bspaoh.destinationReceptionWasteAcceptedWeightValue, + destinationReceptionWastePackagingsAcceptation: prismaJsonNoNull( + bspaoh.destinationReceptionWastePackagingsAcceptation + ), + destinationReceptionWasteQuantityValue: + bspaoh.destinationReceptionWasteQuantityValue, + destinationReceptionWasteReceivedWeightValue: + bspaoh.destinationReceptionWasteReceivedWeightValue, + destinationReceptionWasteRefusalReason: + bspaoh.destinationReceptionWasteRefusalReason, + destinationReceptionWasteRefusedWeightValue: + bspaoh.destinationReceptionWasteRefusedWeightValue, + emitterCompanyAddress: bspaoh.emitterCompanyAddress, + emitterCompanyContact: bspaoh.emitterCompanyContact, + emitterCompanyMail: bspaoh.emitterCompanyMail, + emitterCompanyName: bspaoh.emitterCompanyName, + emitterCompanyPhone: bspaoh.emitterCompanyPhone, + emitterCompanySiret: bspaoh.emitterCompanySiret, + emitterCustomInfo: bspaoh.emitterCustomInfo, + emitterEmissionSignatureAuthor: bspaoh.emitterEmissionSignatureAuthor, + emitterEmissionSignatureDate: bspaoh.emitterEmissionSignatureDate, + emitterPickupSiteAddress: bspaoh.emitterPickupSiteAddress, + emitterPickupSiteCity: bspaoh.emitterPickupSiteCity, + emitterPickupSiteInfos: bspaoh.emitterPickupSiteInfos, + emitterPickupSiteName: bspaoh.emitterPickupSiteName, + emitterPickupSitePostalCode: bspaoh.emitterPickupSitePostalCode, + emitterWasteQuantityValue: bspaoh.emitterWasteQuantityValue, + emitterWasteWeightIsEstimate: bspaoh.emitterWasteWeightIsEstimate, + emitterWasteWeightValue: bspaoh.emitterWasteWeightValue, + handedOverToDestinationSignatureAuthor: + bspaoh.handedOverToDestinationSignatureAuthor, + handedOverToDestinationSignatureDate: + bspaoh.handedOverToDestinationSignatureDate, + isDeleted: bspaoh.isDeleted, + nextTransporterOrgId: bspaoh.nextTransporterOrgId, + status: bspaoh.status, + transporters: bspaoh.transporters.length + ? { + createMany: { + data: bspaoh.transporters!.map((t, idx) => { + const { id, ...data } = t; + return { ...data, bspaohId: undefined, number: idx + 1 }; + }) + } + } + : {}, + transportersSirets: bspaoh.transportersSirets, + transporterTransportTakenOverAt: bspaoh.transporterTransportTakenOverAt, + updatedAt: bspaoh.updatedAt, + wasteAdr: bspaoh.wasteAdr, + wasteCode: bspaoh.wasteCode, + wastePackagings: prismaJsonNoNull(bspaoh.wastePackagings), + wasteType: bspaoh.wasteType + }; + + const newBspaoh = await create(newBspaohCreateInput); + + return newBspaoh; +}; + +export const bsddInclude = { + transporters: true, + intermediaries: true, + finalOperations: true, + grouping: true, + groupedIn: true, + forwarding: true +}; + +export const cloneBsdd = async ( + user: Express.User, + id: string +): Promise
=> { + const bsdd = await prisma.form.findFirstOrThrow({ + where: { id }, + include: bsddInclude + }); + + if (!bsdd) { + throw new UserInputError(`ID invalide ${id}`); + } + + if ( + bsdd.grouping?.length || + bsdd.groupedIn?.length || + bsdd.forwarding || + bsdd.forwardedInId + ) { + throw new UserInputError( + "Impossible de cloner ce type de BSD pour le moment" + ); + } + + const { create } = getFormRepository(user); + + const newBsddCreateInput: Omit< + Required, + // Ignored for the time being + | "FinalOperationToFinalForm" + | "forwardedIn" + | "forwarding" + | "groupedIn" + | "grouping" + | "id" + | "rowNumber" + | "StatusLog" + | "bsddRevisionRequests" + // Done manually + | "finalOperations" + > = { + owner: { + connect: { + id: bsdd.ownerId + } + }, + readableId: getReadableId(), + brokerCompanyAddress: bsdd.brokerCompanyAddress, + brokerCompanyContact: bsdd.brokerCompanyContact, + brokerCompanyMail: bsdd.brokerCompanyMail, + brokerCompanyName: bsdd.brokerCompanyName, + brokerCompanyPhone: bsdd.brokerCompanyPhone, + brokerCompanySiret: bsdd.brokerCompanySiret, + brokerDepartment: bsdd.brokerDepartment, + brokerReceipt: bsdd.brokerReceipt, + brokerValidityLimit: bsdd.brokerValidityLimit, + canAccessDraftSirets: bsdd.canAccessDraftSirets, + citerneNotWashedOutReason: bsdd.citerneNotWashedOutReason, + createdAt: bsdd.createdAt, + currentTransporterOrgId: bsdd.currentTransporterOrgId, + customId: bsdd.customId, + destinationOperationMode: bsdd.destinationOperationMode, + ecoOrganismeName: bsdd.ecoOrganismeName, + ecoOrganismeSiret: bsdd.ecoOrganismeSiret, + emittedAt: bsdd.emittedAt, + emittedBy: bsdd.emittedBy, + emittedByEcoOrganisme: bsdd.emittedByEcoOrganisme, + emitterCompanyAddress: bsdd.emitterCompanyAddress, + emitterCompanyContact: bsdd.emitterCompanyContact, + emitterCompanyMail: bsdd.emitterCompanyMail, + emitterCompanyName: bsdd.emitterCompanyName, + emitterCompanyOmiNumber: bsdd.emitterCompanyOmiNumber, + emitterCompanyPhone: bsdd.emitterCompanyPhone, + emitterCompanySiret: bsdd.emitterCompanySiret, + emitterIsForeignShip: bsdd.emitterIsForeignShip, + emitterIsPrivateIndividual: bsdd.emitterIsPrivateIndividual, + emitterPickupSite: bsdd.emitterPickupSite, + emitterType: bsdd.emitterType, + emitterWorkSiteAddress: bsdd.emitterWorkSiteAddress, + emitterWorkSiteCity: bsdd.emitterWorkSiteCity, + emitterWorkSiteInfos: bsdd.emitterWorkSiteInfos, + emitterWorkSiteName: bsdd.emitterWorkSiteName, + emitterWorkSitePostalCode: bsdd.emitterWorkSitePostalCode, + emptyReturnADR: bsdd.emptyReturnADR, + hasCiterneBeenWashedOut: bsdd.hasCiterneBeenWashedOut, + intermediaries: bsdd.intermediaries.length + ? { + createMany: { + data: bsdd.intermediaries + } + } + : {}, + intermediariesSirets: bsdd.intermediariesSirets, + isAccepted: bsdd.isAccepted, + isDeleted: bsdd.isDeleted, + isImportedFromPaper: bsdd.isImportedFromPaper, + nextDestinationCompanyAddress: bsdd.nextDestinationCompanyAddress, + nextDestinationCompanyContact: bsdd.nextDestinationCompanyContact, + nextDestinationCompanyCountry: bsdd.nextDestinationCompanyCountry, + nextDestinationCompanyExtraEuropeanId: + bsdd.nextDestinationCompanyExtraEuropeanId, + nextDestinationCompanyMail: bsdd.nextDestinationCompanyMail, + nextDestinationCompanyName: bsdd.nextDestinationCompanyName, + nextDestinationCompanyPhone: bsdd.nextDestinationCompanyPhone, + nextDestinationCompanySiret: bsdd.nextDestinationCompanySiret, + nextDestinationCompanyVatNumber: bsdd.nextDestinationCompanyVatNumber, + nextDestinationNotificationNumber: bsdd.nextDestinationNotificationNumber, + nextDestinationProcessingOperation: bsdd.nextDestinationProcessingOperation, + nextTransporterOrgId: bsdd.nextTransporterOrgId, + noTraceability: bsdd.noTraceability, + processedAt: bsdd.processedAt, + processedBy: bsdd.processedBy, + processingOperationDescription: bsdd.processingOperationDescription, + processingOperationDone: bsdd.processingOperationDone, + quantityGrouped: bsdd.quantityGrouped, + quantityReceived: bsdd.quantityReceived, + quantityReceivedType: bsdd.quantityReceivedType, + quantityRefused: bsdd.quantityRefused, + receivedAt: bsdd.receivedAt, + receivedBy: bsdd.receivedBy, + recipientCap: bsdd.recipientCap, + recipientCompanyAddress: bsdd.recipientCompanyAddress, + recipientCompanyContact: bsdd.recipientCompanyContact, + recipientCompanyMail: bsdd.recipientCompanyMail, + recipientCompanyName: bsdd.recipientCompanyName, + recipientCompanyPhone: bsdd.recipientCompanyPhone, + recipientCompanySiret: bsdd.recipientCompanySiret, + recipientIsTempStorage: bsdd.recipientIsTempStorage, + recipientProcessingOperation: bsdd.recipientProcessingOperation, + recipientsSirets: bsdd.recipientsSirets, + sentAt: bsdd.sentAt, + sentBy: bsdd.sentBy, + signedAt: bsdd.signedAt, + signedBy: bsdd.signedBy, + signedByTransporter: bsdd.signedByTransporter, + status: bsdd.status, + takenOverAt: bsdd.takenOverAt, + takenOverBy: bsdd.takenOverBy, + traderCompanyAddress: bsdd.traderCompanyAddress, + traderCompanyContact: bsdd.traderCompanyContact, + traderCompanyMail: bsdd.traderCompanyMail, + traderCompanyName: bsdd.traderCompanyName, + traderCompanyPhone: bsdd.traderCompanyPhone, + traderCompanySiret: bsdd.traderCompanySiret, + traderDepartment: bsdd.traderDepartment, + traderReceipt: bsdd.traderReceipt, + traderValidityLimit: bsdd.traderValidityLimit, + transporters: bsdd.transporters.length + ? { + createMany: { + data: bsdd.transporters!.map((t, idx) => { + const { id, ...data } = t; + return { ...data, formId: undefined, number: idx + 1 }; + }) + } + } + : {}, + transportersSirets: bsdd.transportersSirets, + updatedAt: bsdd.updatedAt, + wasteAcceptationStatus: bsdd.wasteAcceptationStatus, + wasteDetailsAnalysisReferences: bsdd.wasteDetailsAnalysisReferences, + wasteDetailsCode: bsdd.wasteDetailsCode, + wasteDetailsConsistence: bsdd.wasteDetailsConsistence, + wasteDetailsIsDangerous: bsdd.wasteDetailsIsDangerous, + wasteDetailsLandIdentifiers: bsdd.wasteDetailsLandIdentifiers, + wasteDetailsName: bsdd.wasteDetailsName, + wasteDetailsOnuCode: bsdd.wasteDetailsOnuCode, + wasteDetailsPackagingInfos: prismaJsonNoNull( + bsdd.wasteDetailsPackagingInfos + ), + wasteDetailsParcelNumbers: prismaJsonNoNull(bsdd.wasteDetailsParcelNumbers), + wasteDetailsPop: bsdd.wasteDetailsPop, + wasteDetailsQuantity: bsdd.wasteDetailsQuantity, + wasteDetailsQuantityType: bsdd.wasteDetailsQuantityType, + wasteDetailsSampleNumber: bsdd.wasteDetailsSampleNumber, + wasteRefusalReason: bsdd.wasteRefusalReason + }; + + const newBsdd = await create(newBsddCreateInput); + + // Final operations + for await (const operation of bsdd.finalOperations) { + const finalOperation: Prisma.BsddFinalOperationCreateInput = { + initialForm: { connect: { id: newBsdd.id } }, + finalForm: { connect: { id: newBsdd.id } }, + operationCode: operation.operationCode, + quantity: operation.quantity, + noTraceability: operation.noTraceability + }; + + await prisma.bsddFinalOperation.create({ + data: finalOperation + }); + } + + return newBsdd; +}; diff --git a/back/src/bsds/resolvers/queries/__tests__/bsds.bsvhu.integration.ts b/back/src/bsds/resolvers/queries/__tests__/bsds.bsvhu.integration.ts index 4044789116..ce4bbfb842 100644 --- a/back/src/bsds/resolvers/queries/__tests__/bsds.bsvhu.integration.ts +++ b/back/src/bsds/resolvers/queries/__tests__/bsds.bsvhu.integration.ts @@ -92,6 +92,9 @@ describe("Query.bsds.vhus base workflow", () => { destination = await userWithCompanyFactory(UserRole.ADMIN, { companyTypes: { set: ["WASTE_VEHICLES"] + }, + wasteVehiclesTypes: { + set: ["BROYEUR", "DEMOLISSEUR"] } }); }); @@ -200,7 +203,7 @@ describe("Query.bsds.vhus base workflow", () => { expect.objectContaining({ node: { id: vhuId } }) ]); }); - it("draft vhu should be isDraftFor transporter", async () => { + it("draft vhu should not be isDraftFor transporter", async () => { const { query } = makeClient(transporter.user); const { data } = await query, QueryBsdsArgs>( GET_BSDS, @@ -213,11 +216,9 @@ describe("Query.bsds.vhus base workflow", () => { } ); - expect(data.bsds.edges).toEqual([ - expect.objectContaining({ node: { id: vhuId } }) - ]); + expect(data.bsds.edges).toEqual([]); }); - it("draft vhu should be isDraftFor destination", async () => { + it("draft vhu should not be isDraftFor destination", async () => { const { query } = makeClient(destination.user); const { data } = await query, QueryBsdsArgs>( GET_BSDS, @@ -230,9 +231,7 @@ describe("Query.bsds.vhus base workflow", () => { } ); - expect(data.bsds.edges).toEqual([ - expect.objectContaining({ node: { id: vhuId } }) - ]); + expect(data.bsds.edges).toEqual([]); }); }); diff --git a/back/src/bsds/resolvers/queries/__tests__/bsds.integration.ts b/back/src/bsds/resolvers/queries/__tests__/bsds.integration.ts index ffa650aaa7..04079aab85 100644 --- a/back/src/bsds/resolvers/queries/__tests__/bsds.integration.ts +++ b/back/src/bsds/resolvers/queries/__tests__/bsds.integration.ts @@ -16,7 +16,7 @@ import { getFormForElastic, indexForm } from "../../../../forms/elastic"; import { faker } from "@faker-js/faker"; -describe("query bsds: governement accoutns permissions", () => { +describe("query bsds: governement accounts permissions", () => { afterEach(resetDatabase); it("should allow user authenticated with a token when tied to a government account with relevant perms", async () => { @@ -103,8 +103,7 @@ describe("query bsds: governement accoutns permissions", () => { expect(errors[0].message).toEqual(`Vous n'êtes pas connecté.`); }); - it("should forbid user authenticated with a token if no gov government is associated", async () => { - // the company and owner to build a registry + it("should forbid user authenticated with a token if no government account is associated", async () => { const { user: owner, company: someCompany } = await userWithCompanyFactory( "MEMBER" ); diff --git a/back/src/bsds/resolvers/queries/__tests__/controlbsds.integration.ts b/back/src/bsds/resolvers/queries/__tests__/controlbsds.integration.ts new file mode 100644 index 0000000000..b42fcb0dcc --- /dev/null +++ b/back/src/bsds/resolvers/queries/__tests__/controlbsds.integration.ts @@ -0,0 +1,695 @@ +import { GovernmentPermission } from "@prisma/client"; +import { + refreshElasticSearch, + resetDatabase +} from "../../../../../integration-tests/helper"; +import supertest from "supertest"; + +import { app } from "../../../../server"; +import { + formFactory, + userWithCompanyFactory, + userWithAccessTokenFactory, + companyFactory, + siretify, + bsddTransporterData +} from "../../../../__tests__/factories"; +import { Status, WasteAcceptationStatus } from "@prisma/client"; +import { getFormForElastic, indexForm } from "../../../../forms/elastic"; + +import { faker } from "@faker-js/faker"; + +describe("query controlbsds: governement accounts permissions", () => { + afterEach(resetDatabase); + + it("should allow user authenticated with a token when tied to a government account with relevant perms", async () => { + const { user: owner } = await userWithCompanyFactory("MEMBER"); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory({ + governmentAccount: { + create: { + name: "GERICO", + permissions: [GovernmentPermission.BSDS_CAN_READ_ALL], + authorizedOrgIds: ["ALL"], + authorizedIPs: [allowedIP] + } + } + }); + + const transporter = await companyFactory(); + const form = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + receivedAt: new Date(), + transporters: { + create: { + ...bsddTransporterData, + number: 1, + transporterCompanySiret: transporter.siret + } + } + } + }); + + await indexForm(await getFormForElastic(form)); + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${transporter.siret}" }) {pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors, data } = res.body; + + expect(errors).toBeUndefined(); + expect(data.controlBsds.pageInfo).toEqual(1); + }); + + it("should forbid user authenticated with a token when tied to a government account without relevant perms", async () => { + const { user: owner, company: someCompany } = await userWithCompanyFactory( + "MEMBER" + ); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory({ + governmentAccount: { + create: { + name: "GERICO", + permissions: [GovernmentPermission.REGISTRY_CAN_READ_ALL], // wrong permission + authorizedOrgIds: ["ALL"], + authorizedIPs: [allowedIP] + } + } + }); + const form = await formFactory({ + ownerId: owner.id, + opt: { + emitterCompanySiret: someCompany.siret, + status: "SENT", + sentAt: new Date(), + receivedAt: new Date() + } + }); + await indexForm(await getFormForElastic(form)); + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${someCompany.siret}" }) {pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors, data } = res.body; + expect(data).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0].message).toEqual(`Vous n'êtes pas connecté.`); + }); + + it("should forbid user authenticated with a token if no government account is associated", async () => { + const { user: owner, company: someCompany } = await userWithCompanyFactory( + "MEMBER" + ); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory(); + const form = await formFactory({ + ownerId: owner.id, + opt: { + emitterCompanySiret: someCompany.siret, + status: Status.SENT, + sentAt: new Date(), + receivedAt: new Date() + } + }); + await indexForm(await getFormForElastic(form)); + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${someCompany.siret}" }) {pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors, data } = res.body; + expect(data).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0].message).toEqual(`Vous n'êtes pas connecté.`); + }); + + it("should forbid user authenticated with a token tied to a government account when IPs do not match", async () => { + const { user: owner, company: someCompany } = await userWithCompanyFactory( + "MEMBER" + ); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + const userIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory({ + governmentAccount: { + create: { + name: "GERICO", + permissions: [GovernmentPermission.BSDS_CAN_READ_ALL], + authorizedOrgIds: ["ALL"], + authorizedIPs: [allowedIP] // not user ip + } + } + }); + const form = await formFactory({ + ownerId: owner.id, + opt: { + emitterCompanySiret: someCompany.siret, + status: "SENT", + sentAt: new Date(), + receivedAt: new Date() + } + }); + await indexForm(await getFormForElastic(form)); + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${someCompany.siret}" }) {pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", userIP); // IPs do not match + const { errors, data } = res.body; + expect(data).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0].message).toEqual(`Vous n'êtes pas connecté.`); + }); + + it("should filter by siret", async () => { + const { user: owner } = await userWithCompanyFactory("MEMBER"); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory({ + governmentAccount: { + create: { + name: "GERICO", + permissions: [GovernmentPermission.BSDS_CAN_READ_ALL], + authorizedOrgIds: ["ALL"], + authorizedIPs: [allowedIP] + } + } + }); + const siret1 = siretify(1); + const testInput_1 = { + transporterCompanySiret: siret1, + number: 1, + takenOverAt: new Date() + }; + const testInput_2 = { + transporterCompanySiret: siretify(2), + number: 1, + takenOverAt: new Date() + }; + // the siret we'll search after + const form1 = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + transporters: { create: testInput_1 } + } + }); + // another siret + const form2 = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + transporters: { create: testInput_2 } + } + }); + + await indexForm(await getFormForElastic(form1)); + await indexForm(await getFormForElastic(form2)); + + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${siret1}" }) { + edges { + node { + ... on Form { + id + } + } + } + pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors, data } = res.body; + + expect(errors).toBeUndefined(); + expect(data.controlBsds.pageInfo).toEqual(1); + // the form matches the siret + expect(data.controlBsds.edges[0].node.id).toEqual(form1.id); + }); + + it("should only return isCollectedFor and isReturnFor", async () => { + const { user: owner } = await userWithCompanyFactory("MEMBER"); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory({ + governmentAccount: { + create: { + name: "GERICO", + permissions: [GovernmentPermission.BSDS_CAN_READ_ALL], + authorizedOrgIds: ["ALL"], + authorizedIPs: [allowedIP] + } + } + }); + const siret1 = siretify(1); + const trsInput = { + transporterCompanySiret: siret1, + number: 1 + }; + + const formSent = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.SENT, + sentAt: new Date(), + transporters: { create: { ...trsInput, takenOverAt: new Date() } } + } + }); + + const formReceived = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.RECEIVED, + wasteAcceptationStatus: WasteAcceptationStatus.ACCEPTED, + + transporters: { create: trsInput }, + receivedAt: new Date() + } + }); + + const formSealed = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.SEALED, + + transporters: { create: trsInput } + } + }); + + const formRefused = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.REFUSED, + receivedAt: new Date(), + wasteAcceptationStatus: WasteAcceptationStatus.REFUSED, + transporters: { create: trsInput } + } + }); + + await indexForm(await getFormForElastic(formSent)); + await indexForm(await getFormForElastic(formRefused)); + + await indexForm(await getFormForElastic(formReceived)); + await indexForm(await getFormForElastic(formSealed)); + + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${siret1}" }) { + edges { + node { + ... on Form { + id + } + } + } + pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors, data } = res.body; + + expect(errors).toBeUndefined(); + expect(data.controlBsds.pageInfo).toEqual(2); + // the form matches the siret + expect(data.controlBsds.edges.map(e => e.node.id).sort()).toEqual([ + formSent.id, + + formRefused.id + ]); + }); + + it("should filter by siret and plate", async () => { + const { user: owner } = await userWithCompanyFactory("MEMBER"); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory({ + governmentAccount: { + create: { + name: "GERICO", + permissions: [GovernmentPermission.BSDS_CAN_READ_ALL], + authorizedOrgIds: ["ALL"], + authorizedIPs: [allowedIP] + } + } + }); + const siret1 = siretify(1); + const trsInput = { + transporterCompanySiret: siret1, + number: 1 + }; + + const form1 = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + transporters: { + create: { + ...trsInput, + takenOverAt: new Date(), + transporterNumberPlate: "AZ 23 99" + } + } + } + }); + + const form2 = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + transporters: { + create: { + ...trsInput, + takenOverAt: new Date(), + transporterNumberPlate: "AZ 23 99" + } + } + } + }); + + const form3 = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + transporters: { + create: { + ...trsInput, + takenOverAt: new Date(), + transporterNumberPlate: "QS 23 99" + } + } + } + }); + + await indexForm(await getFormForElastic(form1)); + await indexForm(await getFormForElastic(form2)); + await indexForm(await getFormForElastic(form3)); + + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${siret1}" , plate: "AZ 23 99"}) { + edges { + node { + ... on Form { + id + } + } + } + pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors, data } = res.body; + + expect(errors).toBeUndefined(); + expect(data.controlBsds.pageInfo).toEqual(2); + // the form matches the plate + expect(data.controlBsds.edges.map(e => e.node.id).sort()).toEqual([ + form1.id, + form2.id + ]); + }); + + it("should filter by plate", async () => { + const { user: owner } = await userWithCompanyFactory("MEMBER"); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory({ + governmentAccount: { + create: { + name: "GERICO", + permissions: [GovernmentPermission.BSDS_CAN_READ_ALL], + authorizedOrgIds: ["ALL"], + authorizedIPs: [allowedIP] + } + } + }); + const siret1 = siretify(1); + const siret2 = siretify(2); + const siret3 = siretify(3); + const siret4 = siretify(3); + + const form1 = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + transporters: { + create: { + transporterCompanySiret: siret1, + number: 1, + takenOverAt: new Date(), + transporterNumberPlate: "AZ 23 99" + } + } + } + }); + + const form2 = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + transporters: { + create: { + transporterCompanySiret: siret2, + number: 1, + takenOverAt: new Date(), + transporterNumberPlate: "AZ 23 99" + } + } + } + }); + + const form3 = await formFactory({ + ownerId: owner.id, + opt: { + status: "SENT", + sentAt: new Date(), + transporters: { + create: { + transporterCompanySiret: siret3, + number: 1, + takenOverAt: new Date(), + transporterNumberPlate: "QS 23 99" + } + } + } + }); + + const form4 = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.REFUSED, + receivedAt: new Date(), + wasteAcceptationStatus: WasteAcceptationStatus.REFUSED, + transporters: { + create: { + transporterCompanySiret: siret4, + number: 1, + takenOverAt: new Date(), + transporterNumberPlate: "AZ 23 99" + } + } + } + }); + + await indexForm(await getFormForElastic(form1)); + await indexForm(await getFormForElastic(form2)); + await indexForm(await getFormForElastic(form3)); + await indexForm(await getFormForElastic(form4)); + + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {plate: "AZ 23 99"}) { + edges { + node { + ... on Form { + id + } + } + } + pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors, data } = res.body; + + expect(errors).toBeUndefined(); + expect(data.controlBsds.pageInfo).toEqual(3); + // the form matches the + expect(data.controlBsds.edges.map(e => e.node.id).sort()).toEqual([ + form1.id, + form2.id, + form4.id + ]); + }); + + it("should paginate controlbsds query", async () => { + const { user: owner } = await userWithCompanyFactory("MEMBER"); + const request = supertest(app); + + const allowedIP = faker.internet.ipv4(); + // the gov account running the query + const { accessToken } = await userWithAccessTokenFactory({ + governmentAccount: { + create: { + name: "GERICO", + permissions: [GovernmentPermission.BSDS_CAN_READ_ALL], + authorizedOrgIds: ["ALL"], + authorizedIPs: [allowedIP] + } + } + }); + const siret1 = siretify(1); + const trsInput = { + transporterCompanySiret: siret1, + number: 1 + }; + + const form1 = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.SENT, + sentAt: new Date(), + transporters: { create: { ...trsInput, takenOverAt: new Date() } } + } + }); + + const form2 = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.SENT, + sentAt: new Date(), + transporters: { create: { ...trsInput, takenOverAt: new Date() } } + } + }); + + const form3 = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.SENT, + sentAt: new Date(), + transporters: { create: { ...trsInput, takenOverAt: new Date() } } + } + }); + + const form4 = await formFactory({ + ownerId: owner.id, + opt: { + status: Status.SENT, + sentAt: new Date(), + transporters: { create: { ...trsInput, takenOverAt: new Date() } } + } + }); + + await indexForm(await getFormForElastic(form1)); + await indexForm(await getFormForElastic(form2)); + await indexForm(await getFormForElastic(form3)); + await indexForm(await getFormForElastic(form4)); + + await refreshElasticSearch(); + const res = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${siret1}" }, first: 2) { + edges { + node { + ... on Form { + id + } + } + } + pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors, data } = res.body; + + expect(errors).toBeUndefined(); + + expect(data.controlBsds.pageInfo).toEqual(4); + // the form matches the siret + expect(data.controlBsds.edges.map(e => e.node.id).sort()).toEqual([ + form1.id, + form2.id + ]); + + // second page + + const res2 = await request + .post("/") + .send({ + query: `{ controlBsds(where: {siret: "${siret1}" }, first: 2, after:"${form2.id}") { + edges { + node { + ... on Form { + id + } + } + } + pageInfo: totalCount}}` + }) + .set("Authorization", `Bearer ${accessToken}`) + .set("X-Forwarded-For", allowedIP); + const { errors: errors2, data: data2 } = res2.body; + + expect(errors2).toBeUndefined(); + + expect(data2.controlBsds.pageInfo).toEqual(4); + // the form matches the siret + expect(data2.controlBsds.edges.map(e => e.node.id).sort()).toEqual([ + form3.id, + form4.id + ]); + }); +}); diff --git a/back/src/bsds/resolvers/queries/bsds.ts b/back/src/bsds/resolvers/queries/bsds.ts index e74bcecaf3..b4081d1002 100644 --- a/back/src/bsds/resolvers/queries/bsds.ts +++ b/back/src/bsds/resolvers/queries/bsds.ts @@ -1,27 +1,13 @@ -// OLD import { ApiResponse, estypes } from "@elastic/elasticsearch"; import { QueryResolvers, - Bsd, QueryBsdsArgs, - BsdType, OrderType } from "../../../generated/graphql/types"; import { applyAuthStrategies, AuthType } from "../../../auth"; import { checkIsAuthenticated } from "../../../common/permissions"; -import { - client, - BsdElastic, - index, - groupByBsdType -} from "../../../common/elastic"; -import { prisma } from "@td/prisma"; -import { expandFormFromElastic } from "../../../forms/converter"; -import { expandBsdasriFromElastic } from "../../../bsdasris/converter"; -import { expandVhuFormFromDb } from "../../../bsvhu/converter"; -import { expandBsdaFromElastic } from "../../../bsda/converter"; -import { expandBsffFromElastic } from "../../../bsffs/converter"; -import { expandBspaohFromElastic } from "../../../bspaoh/converter"; +import { client, BsdElastic, index } from "../../../common/elastic"; + import { bsdSearchSchema } from "../../validation"; import { toElasticQuery } from "../../where"; import { @@ -30,61 +16,15 @@ import { getUserRoles, hasGovernmentReadAllBsdsPermOrThrow } from "../../../permissions"; -import { distinct } from "../../../common/arrays"; -import { FormForElastic } from "../../../forms/elastic"; -import { BsdasriForElastic } from "../../../bsdasris/elastic"; -import { BsvhuForElastic } from "../../../bsvhu/elastic"; -import { BsdaForElastic } from "../../../bsda/elastic"; -import { BsffForElastic } from "../../../bsffs/elastic"; -import { BspaohForElastic } from "../../../bspaoh/elastic"; + import { QueryContainer } from "@elastic/elasticsearch/api/types"; import { GraphQLContext } from "../../../types"; - -// complete Typescript example: -// https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/6.x/_a_complete_example.html -export interface SearchResponse { - hits: { - total: { - value: number; - }; - hits: Array<{ - _source: T; - }>; - }; -} +import { buildResponse } from "./helpers"; export interface GetResponse { _source: T; } -type PrismaBsdMap = { - bsdds: FormForElastic[]; - bsdasris: BsdasriForElastic[]; - bsvhus: BsvhuForElastic[]; - bsdas: BsdaForElastic[]; - bsffs: BsffForElastic[]; - bspaohs: BspaohForElastic[]; -}; - -/** - * Convert a list of BsdElastic to a mapping of prisma-like Bsds by retrieving rawBsd elastic field - */ -async function toRawBsds(bsdsElastic: BsdElastic[]): Promise { - const { BSDD, BSDA, BSDASRI, BSFF, BSVHU, BSPAOH } = - groupByBsdType(bsdsElastic); - - return { - bsdds: BSDD.map(bsdElastic => bsdElastic.rawBsd as FormForElastic), - bsdasris: BSDASRI.map( - bsdsElastic => bsdsElastic.rawBsd as BsdasriForElastic - ), - bsvhus: BSVHU.map(bsdElastic => bsdElastic.rawBsd as BsvhuForElastic), - bsdas: BSDA.map(bsdElastic => bsdElastic.rawBsd as BsdaForElastic), - bsffs: BSFF.map(bsdsElastic => bsdsElastic.rawBsd as BsffForElastic), - bspaohs: BSPAOH.map(bsdsElastic => bsdsElastic.rawBsd as BspaohForElastic) - }; -} - /** * * @param param0 @@ -118,7 +58,8 @@ async function buildQuery( isToCollectFor: where?.isToCollectFor, isCollectedFor: where?.isCollectedFor, isInRevisionFor: where?.isInRevisionFor, - isRevisedFor: where?.isRevisedFor + isRevisedFor: where?.isRevisedFor, + isReturnFor: where?.isReturnFor }) .filter(([_, value]) => value != null) .forEach(([key, value]) => { @@ -293,39 +234,6 @@ async function buildSearchAfter( ); } -/** - * This function takes an array of dasris and, expand them and add `allowDirectTakeOver` boolean field by - * requesting emittercompany to know wether direct takeover is allowed - */ -async function buildDasris(dasris: BsdasriForElastic[]) { - // build a list of emitter siret from dasris, non-INITIAL bsds are ignored - const emitterSirets = dasris - .filter(bsd => !!bsd.emitterCompanySiret && bsd.status === "INITIAL") - .map(bsd => bsd.emitterCompanySiret) - .filter(Boolean); - - const uniqueSirets = distinct(emitterSirets); - - // build an array of sirets allowing direct takeover - const allows = ( - await prisma.company.findMany({ - where: { - siret: { in: uniqueSirets }, - allowBsdasriTakeOverWithoutSignature: true - }, - select: { - siret: true - } - }) - ).map(comp => comp.siret); - - // expand dasris and insert `allowDirectTakeOver` - return dasris.map(bsd => ({ - ...expandBsdasriFromElastic(bsd), - allowDirectTakeOver: allows.includes(bsd.emitterCompanySiret) - })); -} - const bsdsResolver: QueryResolvers["bsds"] = async (_, args, context) => { // This query is restricted to Session users (UI) and government accounts (gerico) @@ -356,77 +264,7 @@ const bsdsResolver: QueryResolvers["bsds"] = async (_, args, context) => { const query = await buildQuery(args, user, bypassOrgIdsWithListPermission); const sort = buildSort(args); const search_after = await buildSearchAfter(args, sort); - - const { body }: ApiResponse> = await client.search( - { - index: index.alias, - body: { - size: - size + - // Take one more result to know if there's a next page - // it's removed from the actual results though - 1, - query, - sort, - search_after - } - } - ); - - const hits = body.hits.hits.slice(0, size); - - const { - bsdds: concreteBsdds, - bsdasris: concreteBsdasris, - bsvhus: concreteBsvhus, - bsdas: concreteBsdas, - bsffs: concreteBsffs, - bspaohs: concreteBspaohs - } = await toRawBsds(hits.map(hit => hit._source)); - - const bsds: Record = { - BSDD: concreteBsdds.map(expandFormFromElastic), - BSDASRI: await buildDasris(concreteBsdasris), - BSVHU: concreteBsvhus.map(expandVhuFormFromDb), - BSDA: concreteBsdas.map(expandBsdaFromElastic), - BSFF: concreteBsffs.map(expandBsffFromElastic), - BSPAOH: concreteBspaohs.map(expandBspaohFromElastic) - }; - - const edges = hits - .reduce>((acc, { _source: { type, id } }) => { - const bsd = bsds[type].find(bsd => bsd.id === id); - - if (bsd) { - // filter out null values in case Elastic Search - // is desynchronized with the actual database - return acc.concat(bsd); - } - - return acc; - }, []) - .map(node => ({ - cursor: node.id, - node - })); - - const pageInfo = { - // startCursor and endCursor are null if the list is empty - // this is not 100% spec compliant but there are discussions to change that: - // https://github.com/facebook/relay/issues/1852 - // https://github.com/facebook/relay/pull/2655 - startCursor: edges[0]?.cursor || null, - endCursor: edges[edges.length - 1]?.cursor || null, - - hasNextPage: body.hits.hits.length > size, - hasPreviousPage: false - }; - - return { - edges, - pageInfo, - totalCount: body.hits.total.value - }; + return buildResponse({ query, size, sort, search_after }); }; /** diff --git a/back/src/bsds/resolvers/queries/controlBsds.ts b/back/src/bsds/resolvers/queries/controlBsds.ts new file mode 100644 index 0000000000..a08969e9af --- /dev/null +++ b/back/src/bsds/resolvers/queries/controlBsds.ts @@ -0,0 +1,144 @@ +import { + QueryResolvers, + QueryControlBsdsArgs +} from "../../../generated/graphql/types"; + +import { checkIsAuthenticated } from "../../../common/permissions"; + +import { hasGovernmentReadAllBsdsPermOrThrow } from "../../../permissions"; +import { buildResponse } from "./helpers"; +import { controlBsdSearchSchema } from "../../validation"; + +/** + * + * @param where + * plate + * readableId + * siret + * @returns + */ +function buildQuery({ where }: QueryControlBsdsArgs) { + const must: any[] = []; + const should: any[] = []; + + if (where?.siret) { + must.push({ + bool: { + should: [ + { + terms: { + isCollectedFor: [where.siret] + } + }, + { + terms: { + isReturnFor: [where.siret] + } + } + ] + } + }); + } else { + must.push({ + bool: { + should: [ + { + bool: { + must: { + exists: { + field: "isReturnFor" + } + } + } + }, + { + bool: { + must: { + exists: { + field: "isCollectedFor" + } + } + } + } + ] + } + }); + } + if (where?.plate) { + must.push({ + match: { + "transporterTransportPlates.ngram": { + query: + where.plate + .replace(/-/g, "") + .match(/.{1,5}/g) + ?.join(" ") ?? "", + operator: "and" + } + } + }); + } + + if (where?.readableId) { + must.push({ + match: { + "readableId.ngram": { + query: where.readableId.match(/.{1,5}/g)?.join(" "), + operator: "and" + } + } + }); + } + const query = { + bool: { + must, + filter: [ + { + bool: { + should + } + } + ] + } + }; + + return query; +} + +/** + * Recherche des bds indexés dans ES pour l'api destinée à la fiche d'inspection + * Version simplifiée et modifiée de la query bsds. + * + * Query limitée aux users associés à un government account disposant des permissions adéquates (BSDS_CAN_READ_ALL) + * + * Les bsds retournés figurent obligatoirement dans l'onglet collectés ou retour + * Recherche possible par siret (transporteur), plaque d'immatriculation ou numéro de bsd. + * + */ +const controlBsdsResolver: QueryResolvers["controlBsds"] = async ( + _, + args, + context +) => { + // This query is dedicated to government accounts (gerico) + + const user = checkIsAuthenticated(context); + + await hasGovernmentReadAllBsdsPermOrThrow(user); + + const MIN_SIZE = 0; + const MAX_SIZE = 300; + const { first = MAX_SIZE } = args; + const size = Math.max(Math.min(first!, MAX_SIZE), MIN_SIZE); + + controlBsdSearchSchema.parse(args.where); + + const query = buildQuery(args); + + const sort = { id: "ASC" }; + const search_after = args?.after ? [args.after] : undefined; + + return buildResponse({ query, size, sort, search_after }); +}; + +export default controlBsdsResolver; diff --git a/back/src/bsds/resolvers/queries/helpers.ts b/back/src/bsds/resolvers/queries/helpers.ts new file mode 100644 index 0000000000..bd77ec8c8d --- /dev/null +++ b/back/src/bsds/resolvers/queries/helpers.ts @@ -0,0 +1,190 @@ +import { PrismaBsdMap } from "./types"; +import { FormForElastic } from "../../../forms/elastic"; +import { BsdasriForElastic } from "../../../bsdasris/elastic"; +import { BsvhuForElastic } from "../../../bsvhu/elastic"; +import { BsdaForElastic } from "../../../bsda/elastic"; +import { BsffForElastic } from "../../../bsffs/elastic"; +import { BspaohForElastic } from "../../../bspaoh/elastic"; +import { Bsd, BsdType } from "../../../generated/graphql/types"; +import { distinct } from "../../../common/arrays"; +import { prisma } from "@td/prisma"; +import { SearchResponse } from "./types"; +import { + client, + BsdElastic, + index, + groupByBsdType +} from "../../../common/elastic"; + +import { expandFormFromElastic } from "../../../forms/converter"; + +import { expandVhuFormFromDb } from "../../../bsvhu/converter"; +import { expandBsdaFromElastic } from "../../../bsda/converter"; +import { expandBsffFromElastic } from "../../../bsffs/converter"; +import { expandBspaohFromElastic } from "../../../bspaoh/converter"; + +import { ApiResponse } from "@elastic/elasticsearch"; +/** + * Convert a list of BsdElastic to a mapping of prisma-like Bsds by retrieving rawBsd elastic field + */ +export async function toRawBsds( + bsdsElastic: BsdElastic[] +): Promise { + const { BSDD, BSDA, BSDASRI, BSFF, BSVHU, BSPAOH } = + groupByBsdType(bsdsElastic); + + return { + bsdds: BSDD.map(bsdElastic => bsdElastic.rawBsd as FormForElastic), + bsdasris: BSDASRI.map( + bsdsElastic => bsdsElastic.rawBsd as BsdasriForElastic + ), + bsvhus: BSVHU.map(bsdElastic => bsdElastic.rawBsd as BsvhuForElastic), + bsdas: BSDA.map(bsdElastic => bsdElastic.rawBsd as BsdaForElastic), + bsffs: BSFF.map(bsdsElastic => bsdsElastic.rawBsd as BsffForElastic), + bspaohs: BSPAOH.map(bsdsElastic => bsdsElastic.rawBsd as BspaohForElastic) + }; +} +import { expandBsdasriFromElastic } from "../../../bsdasris/converter"; + +/** + * Returns the keyword field matching the given fieldName. + * + * e.g passing "readableId" returns "readableId.keyword", + * because "redableId" is a "text" with a sub field "readableId.keyword" which is a keyword. + * + * e.g passing "id" returns "id", because it's already a keyword. + * + * This is useful for context where we are given a property but need to use its keyword counterpart. + * For example when sorting, where it's not possible to sort on text fields. + */ +export function getKeywordFieldNameFromName( + fieldName: keyof BsdElastic +): string { + const property = index.mappings.properties[fieldName]; + + if (property.type === "keyword") { + // this property is of type "keyword" itself, it can be used as such + return fieldName; + } + + // look for a sub field with the type "keyword" + const [subFieldName] = + Object.entries(property.fields || {}).find( + ([_, property]) => property.type === "keyword" + ) ?? []; + + if (subFieldName == null) { + throw new Error( + `The field "${fieldName}" is not of type "keyword" and has no sub fields of that type.` + ); + } + + return `${fieldName}.${subFieldName}`; +} + +/** + * This function takes an array of dasris and, expand them and add `allowDirectTakeOver` boolean field by + * requesting emittercompany to know wether direct takeover is allowed + */ +export async function buildDasris(dasris: BsdasriForElastic[]) { + // build a list of emitter siret from dasris, non-INITIAL bsds are ignored + const emitterSirets = dasris + .filter(bsd => !!bsd.emitterCompanySiret && bsd.status === "INITIAL") + .map(bsd => bsd.emitterCompanySiret) + .filter(Boolean); + + const uniqueSirets = distinct(emitterSirets); + + // build an array of sirets allowing direct takeover + const allows = ( + await prisma.company.findMany({ + where: { + siret: { in: uniqueSirets }, + allowBsdasriTakeOverWithoutSignature: true + }, + select: { + siret: true + } + }) + ).map(comp => comp.siret); + + // expand dasris and insert `allowDirectTakeOver` + return dasris.map(bsd => ({ + ...expandBsdasriFromElastic(bsd), + allowDirectTakeOver: allows.includes(bsd.emitterCompanySiret) + })); +} + +export const buildResponse = async ({ query, size, sort, search_after }) => { + const { body }: ApiResponse> = await client.search( + { + index: index.alias, + body: { + size: + size + + // Take one more result to know if there's a next page + // it's removed from the actual results though + 1, + query, + + sort, + search_after + } + } + ); + + const hits = body.hits.hits.slice(0, size); + + const { + bsdds: concreteBsdds, + bsdasris: concreteBsdasris, + bsvhus: concreteBsvhus, + bsdas: concreteBsdas, + bsffs: concreteBsffs, + bspaohs: concreteBspaohs + } = await toRawBsds(hits.map(hit => hit._source)); + + const bsds: Record = { + BSDD: concreteBsdds.map(expandFormFromElastic), + BSDASRI: await buildDasris(concreteBsdasris), + BSVHU: concreteBsvhus.map(expandVhuFormFromDb), + BSDA: concreteBsdas.map(expandBsdaFromElastic), + BSFF: concreteBsffs.map(expandBsffFromElastic), + BSPAOH: concreteBspaohs.map(expandBspaohFromElastic) + }; + + const edges = hits + .reduce>((acc, { _source: { type, id } }) => { + const bsd = bsds[type].find(bsd => bsd.id === id); + + if (bsd) { + // filter out null values in case Elastic Search + // is desynchronized with the actual database + return acc.concat(bsd); + } + + return acc; + }, []) + .map(node => ({ + cursor: node.id, + node + })); + + const pageInfo = { + // startCursor and endCursor are null if the list is empty + // this is not 100% spec compliant but there are discussions to change that: + // https://github.com/facebook/relay/issues/1852 + // https://github.com/facebook/relay/pull/2655 + startCursor: edges[0]?.cursor || null, + endCursor: edges[edges.length - 1]?.cursor || null, + + hasNextPage: body.hits.hits.length > size, + hasPreviousPage: false + }; + + return { + edges, + pageInfo, + totalCount: body.hits.total.value + }; +}; diff --git a/back/src/bsds/resolvers/queries/types.ts b/back/src/bsds/resolvers/queries/types.ts new file mode 100644 index 0000000000..31625fb333 --- /dev/null +++ b/back/src/bsds/resolvers/queries/types.ts @@ -0,0 +1,31 @@ +import { FormForElastic } from "../../../forms/elastic"; +import { BsdasriForElastic } from "../../../bsdasris/elastic"; +import { BsvhuForElastic } from "../../../bsvhu/elastic"; +import { BsdaForElastic } from "../../../bsda/elastic"; +import { BsffForElastic } from "../../../bsffs/elastic"; +import { BspaohForElastic } from "../../../bspaoh/elastic"; +// complete Typescript example: +// https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/6.x/_a_complete_example.html +export interface SearchResponse { + hits: { + total: { + value: number; + }; + hits: Array<{ + _source: T; + }>; + }; +} + +export interface GetResponse { + _source: T; +} + +export type PrismaBsdMap = { + bsdds: FormForElastic[]; + bsdasris: BsdasriForElastic[]; + bsvhus: BsvhuForElastic[]; + bsdas: BsdaForElastic[]; + bsffs: BsffForElastic[]; + bspaohs: BspaohForElastic[]; +}; diff --git a/back/src/bsds/typeDefs/private/bsd.inputs.graphql b/back/src/bsds/typeDefs/private/bsd.inputs.graphql index 054660c203..b61ba7e968 100644 --- a/back/src/bsds/typeDefs/private/bsd.inputs.graphql +++ b/back/src/bsds/typeDefs/private/bsd.inputs.graphql @@ -30,6 +30,7 @@ input BsdWhere { isArchivedFor: [String!] isToCollectFor: [String!] isCollectedFor: [String!] + isReturnFor: [String!] # Révisions en cours isInRevisionFor: [String!] # Révisions passées @@ -164,3 +165,22 @@ input BsdSubTypeFilter { _eq: BsdSubType _in: [BsdSubType!] } + +""" +Payload de recherche d'un établissment pour la fiche établissement + +Recherche par: + +- readableId +- siret (transporteur) +- plates +- siret et plate +""" +input ControlBsdWhere { + # le siret d'un transporteur: 14 à 18 caractères + siret: String + # plaque d'immatriculation: 6 à 12 caractères + plate: String + # identifiant lisible du bsd: 20 à 25 caractères + readableId: String +} diff --git a/back/src/bsds/typeDefs/private/bsd.mutation.graphql b/back/src/bsds/typeDefs/private/bsd.mutation.graphql index fab3f16488..251d4fadaa 100644 --- a/back/src/bsds/typeDefs/private/bsd.mutation.graphql +++ b/back/src/bsds/typeDefs/private/bsd.mutation.graphql @@ -5,4 +5,13 @@ type ReindexBsds { type Mutation { reindexBsds(ids: String!): ReindexBsds! + + """ + FOR TEST ENVS ONLY + + This resolver will clone a BSD. Contrarily to a duplication, it will copy ALL its fields. + + Does not support complex BSDs like groupement, entreposage etc. for now. + """ + cloneBsd(id: String!): CloneId! } diff --git a/back/src/bsds/typeDefs/private/bsd.objects.graphql b/back/src/bsds/typeDefs/private/bsd.objects.graphql index d71f2c0f6f..bfc532dc21 100644 --- a/back/src/bsds/typeDefs/private/bsd.objects.graphql +++ b/back/src/bsds/typeDefs/private/bsd.objects.graphql @@ -10,3 +10,7 @@ type BsdEdge { } union Bsd = Form | Bsdasri | Bsvhu | Bsda | Bsff | Bspaoh + +type CloneId { + id: ID! +} diff --git a/back/src/bsds/typeDefs/private/bsd.queries.graphql b/back/src/bsds/typeDefs/private/bsd.queries.graphql index cea779d8cb..001652d66d 100644 --- a/back/src/bsds/typeDefs/private/bsd.queries.graphql +++ b/back/src/bsds/typeDefs/private/bsd.queries.graphql @@ -27,4 +27,10 @@ type Query { ): BsdConnection! bsd(id: String!): Bsd! + + """ + Query bsds simplifiée et adaptée pour la fiche établissement. + Accessible uniquement aux comptes gouvernementaux disposant de la permission BSDS_CAN_READ_ALL. + """ + controlBsds(where: ControlBsdWhere, after: String, first: Int): BsdConnection! } diff --git a/back/src/bsds/validation.ts b/back/src/bsds/validation.ts index 17c5e52986..ca33991668 100644 --- a/back/src/bsds/validation.ts +++ b/back/src/bsds/validation.ts @@ -1,6 +1,7 @@ import * as yup from "yup"; import { BsdWhere } from "../generated/graphql/types"; import { GET_BSDS_ACTOR_MAX_LENGTH } from "@td/constants"; +import { z } from "zod"; const maxLengthString = (maxLength: number) => yup @@ -19,6 +20,7 @@ export const bsdSearchSchema: yup.SchemaOf< | "isFollowFor" | "isForActionFor" | "isToCollectFor" + | "isReturnFor" > > = yup.object({ isArchivedFor: yup @@ -37,6 +39,33 @@ export const bsdSearchSchema: yup.SchemaOf< .array() .of(maxLengthString(GET_BSDS_ACTOR_MAX_LENGTH).required()) as any, isToCollectFor: yup + .array() + .of(maxLengthString(GET_BSDS_ACTOR_MAX_LENGTH).required()) as any, + isReturnFor: yup .array() .of(maxLengthString(GET_BSDS_ACTOR_MAX_LENGTH).required()) as any }); + +export const controlBsdSearchSchema = z + .object({ + siret: z.string().min(14).max(18).optional(), + plate: z.string().min(6).max(12).optional(), + readableId: z.string().min(20).max(25).optional() + }) + .superRefine((val, ctx) => { + if (val.readableId && (val.siret || val.plate)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["readableId"], + message: `Vous ne pouvez utiliser readableId avec d'autres paramètres de recherche` + }); + } + + if (![val.readableId, val.siret, val.plate].some(Boolean)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + + message: `Vous devez passer au moins un paramètre de recherche` + }); + } + }); diff --git a/back/src/bsffs/__tests__/elastic.integration.ts b/back/src/bsffs/__tests__/elastic.integration.ts index f9a5b716ed..6434cb821a 100644 --- a/back/src/bsffs/__tests__/elastic.integration.ts +++ b/back/src/bsffs/__tests__/elastic.integration.ts @@ -21,7 +21,12 @@ import { createFicheIntervention } from "./factories"; import { prisma } from "@td/prisma"; -import { BsffFicheIntervention } from "@prisma/client"; +import { + BsffFicheIntervention, + BsffStatus, + WasteAcceptationStatus +} from "@prisma/client"; +import { xDaysAgo } from "../../utils"; describe("getOrgIdsByTab", () => { let emitter: UserWithCompany; @@ -276,6 +281,96 @@ describe("getOrgIdsByTab", () => { expect(isFollowFor).toContain(destination.company.siret); expect(isFollowFor).toContain(detenteur.company.siret); }); + + describe("isReturnFor", () => { + it.each([ + WasteAcceptationStatus.REFUSED, + WasteAcceptationStatus.PARTIALLY_REFUSED + ])( + "waste acceptation status is %p > bsff should belong to tab", + async acceptationStatus => { + // Given + const bsff = await createBsffAfterOperation( + { emitter, transporter, destination }, + { + data: { + destinationReceptionDate: new Date() + }, + packagingData: { + acceptationStatus + } + } + ); + + // When + const { isReturnFor } = toBsdElastic(bsff); + + // Then + expect(isReturnFor).toContain(transporter.company.siret); + } + ); + + it("status is REFUSED > bsff should belong to tab", async () => { + // Given + const bsff = await createBsffAfterOperation( + { emitter, transporter, destination }, + { + data: { + status: BsffStatus.REFUSED, + destinationReceptionDate: new Date() + } + } + ); + + // When + const { isReturnFor } = toBsdElastic(bsff); + + // Then + expect(isReturnFor).toContain(transporter.company.siret); + }); + + it("waste acceptation status is ACCEPTED > bsff should not belong to tab", async () => { + // Given + const bsff = await createBsffAfterOperation( + { emitter, transporter, destination }, + { + data: { + destinationReceptionDate: new Date() + }, + packagingData: { + acceptationStatus: WasteAcceptationStatus.ACCEPTED + } + } + ); + + // When + const { isReturnFor } = toBsdElastic(bsff); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + + it("bsda has been received too long ago > should not belong to tab", async () => { + // Given + const bsff = await createBsffAfterOperation( + { emitter, transporter, destination }, + { + data: { + destinationReceptionDate: xDaysAgo(new Date(), 10) + }, + packagingData: { + acceptationStatus: WasteAcceptationStatus.REFUSED + } + } + ); + + // When + const { isReturnFor } = toBsdElastic(bsff); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + }); }); describe("toBsdElastic > companies Names & OrgIds", () => { diff --git a/back/src/bsffs/__tests__/factories.ts b/back/src/bsffs/__tests__/factories.ts index c73efd45cc..f02c1eb61b 100644 --- a/back/src/bsffs/__tests__/factories.ts +++ b/back/src/bsffs/__tests__/factories.ts @@ -322,6 +322,28 @@ export function createBsffAfterOperation( }); } +export const createBsffPackagingFinalOperation = async ({ + bsffPackagingId, + opts = {} +}: { + bsffPackagingId: string; + opts?: Omit< + Partial, + "finalBsffPackagingId" | "initialBsffPackagingId" + >; +}) => { + return prisma.bsffPackagingFinalOperation.create({ + data: { + finalBsffPackaging: { connect: { id: bsffPackagingId } }, + initialBsffPackaging: { connect: { id: bsffPackagingId } }, + operationCode: "", + noTraceability: false, + quantity: 1, + ...opts + } + }); +}; + export function createBsffPackaging( args: Partial, previousPackagings?: BsffPackaging[] @@ -376,7 +398,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/__tests__/recipify.integration.ts b/back/src/bsffs/__tests__/recipify.integration.ts deleted file mode 100644 index 2e5a52bf61..0000000000 --- a/back/src/bsffs/__tests__/recipify.integration.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { recipify as recipifyFn } from "../recipify"; -import { - companyFactory, - transporterReceiptFactory -} from "../../__tests__/factories"; - -describe("Bsff Recipify Module", () => { - it("recipifyGeneric should correctly process input and return completedInput with transporter receipt", async () => { - const company = await companyFactory(); - const mockInput = { - transporter: { - company, - recepisse: { - isExempted: false - } - } - }; - const receipt = await transporterReceiptFactory({ company }); - const completedInput = await recipifyFn(mockInput); - expect(completedInput).toEqual({ - transporter: { - company: mockInput.transporter.company, - recepisse: { - isExempted: false, - department: receipt.department, - number: receipt.receiptNumber, - validityLimit: receipt.validityLimit - } - } - }); - }); - - it("recipifyGeneric should correctly process BsffInput with null recepisse and return completedInput", async () => { - const mockInput = { - transporter: { - company: { - siret: "12345678912345", - recepisse: null - } - } - }; - const completedInput = await recipifyFn(mockInput); - expect(completedInput).toEqual(mockInput); - }); - - it("recipifyGeneric should correctly process input with null recepisse and return completedInput", async () => { - const mockInput = { - transporter: { - company: { - siret: "12345678912345", - recepisse: { - isExempted: true - } - } - } - }; - const completedInput = await recipifyFn(mockInput); - expect(completedInput).toEqual(mockInput); - }); -}); diff --git a/back/src/bsffs/database.ts b/back/src/bsffs/database.ts index d139f870a4..5dd082689e 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" } + } } }); @@ -380,6 +383,15 @@ export function getFirstTransporterSync(bsff: { return firstTransporter ?? null; } +export function getLastTransporterSync(bsff: { + transporters: BsffTransporter[] | null; +}): BsffTransporter | null { + const transporters = getTransportersSync(bsff); + const greatestNumber = Math.max(...transporters.map(t => t.number)); + const lastTransporter = transporters.find(t => t.number === greatestNumber); + return lastTransporter ?? null; +} + export function getNthTransporterSync( bsff: BsffWithTransporters, n: number diff --git a/back/src/bsffs/elastic.ts b/back/src/bsffs/elastic.ts index 1c316f86cd..36e73d5c8e 100644 --- a/back/src/bsffs/elastic.ts +++ b/back/src/bsffs/elastic.ts @@ -1,4 +1,10 @@ -import { Bsff, BsffStatus, BsdType, BsffTransporter } from "@prisma/client"; +import { + Bsff, + BsffStatus, + BsdType, + BsffTransporter, + WasteAcceptationStatus +} from "@prisma/client"; import { BsdElastic, indexBsd, transportPlateFilter } from "../common/elastic"; import { GraphQLContext } from "../types"; import { getRegistryFields } from "./registry"; @@ -15,10 +21,13 @@ import { import { prisma } from "@td/prisma"; import { getFirstTransporterSync, + getLastTransporterSync, getNextTransporterSync, getTransportersSync } from "./database"; import { getBsffSubType } from "../common/subTypes"; +import { isDefined } from "../common/helpers"; +import { xDaysAgo } from "../utils"; export type BsffForElastic = Bsff & BsffWithPackagings & @@ -297,6 +306,7 @@ export function toBsdElastic(bsff: BsffForElastic): BsdElastic { ...tabs, isInRevisionFor: [] as string[], isRevisedFor: [] as string[], + ...getBsffReturnOrgIds(bsff), sirets: Object.values(tabs).flat(), ...getRegistryFields(bsff), rawBsd: bsff, @@ -328,3 +338,39 @@ export function toBsdElastic(bsff: BsffForElastic): BsdElastic { export async function indexBsff(bsff: BsffForElastic, ctx?: GraphQLContext) { return indexBsd(toBsdElastic(bsff), ctx); } + +/** + * BSFF belongs to isReturnFor tab if: + * - waste has been received in the last 48 hours + * - at least one packaging hasn't been fully accepted + */ +export const belongsToIsReturnForTab = (bsff: BsffForElastic) => { + const hasBeenReceivedLately = + isDefined(bsff.destinationReceptionDate) && + bsff.destinationReceptionDate! > xDaysAgo(new Date(), 2); + + if (!hasBeenReceivedLately) return false; + + const hasNotBeenFullyAccepted = + bsff.status === BsffStatus.REFUSED || + bsff.packagings.some( + packaging => + packaging.acceptationStatus !== WasteAcceptationStatus.ACCEPTED + ); + + return hasNotBeenFullyAccepted; +}; + +function getBsffReturnOrgIds(bsff: BsffForElastic): { isReturnFor: string[] } { + // Return tab + if (belongsToIsReturnForTab(bsff)) { + const transporters = bsff?.transporters ?? []; + const lastTransporter = getLastTransporterSync({ transporters }); + + return { + isReturnFor: [lastTransporter?.transporterCompanySiret].filter(Boolean) + }; + } + + return { isReturnFor: [] }; +} 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/recipify.ts b/back/src/bsffs/recipify.ts deleted file mode 100644 index 45f9fab630..0000000000 --- a/back/src/bsffs/recipify.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - BsffInput, - BsffTransporterRecepisseInput -} from "../generated/graphql/types"; - -import { recipifyGeneric } from "../companies/recipify"; - -const bsffAccessors = (input: BsffInput) => [ - { - getter: () => - input?.transporter?.recepisse?.isExempted !== true - ? input?.transporter?.company - : null, - setter: ( - input: BsffInput, - recepisseInput: BsffTransporterRecepisseInput - ) => ({ - ...input, - transporter: { - ...input.transporter, - recepisse: { - ...input.transporter?.recepisse, - ...recepisseInput - } - } - }) - } -]; - -export const recipify = recipifyGeneric(bsffAccessors); 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(); + } + ); }); diff --git a/back/src/bsffs/validation/bsff/recipify.ts b/back/src/bsffs/validation/bsff/recipify.ts new file mode 100644 index 0000000000..a03574df52 --- /dev/null +++ b/back/src/bsffs/validation/bsff/recipify.ts @@ -0,0 +1,45 @@ +import { getTransporterCompanyOrgId } from "@td/constants"; +import { ParsedZodBsff } from "./schema"; +import { CompanyRole } from "../../../common/validation/zod/schema"; +import { + buildRecipify, + RecipifyInputAccessor +} from "../../../common/validation/recipify"; + +const recipifyBsffAccessors = ( + bsd: ParsedZodBsff +): RecipifyInputAccessor[] => [ + ...(bsd.transporters ?? []).map( + (_, idx) => + ({ + role: CompanyRole.Transporter, + skip: !!bsd.transporters![idx].transporterTransportSignatureDate, + orgIdGetter: () => { + const orgId = getTransporterCompanyOrgId({ + transporterCompanySiret: + bsd.transporters![idx].transporterCompanySiret ?? null, + transporterCompanyVatNumber: + bsd.transporters![idx].transporterCompanyVatNumber ?? null + }); + return orgId ?? null; + }, + setter: async (bsff: ParsedZodBsff, receipt) => { + const transporter = bsff.transporters![idx]; + if (transporter.transporterRecepisseIsExempted) { + transporter.transporterRecepisseNumber = null; + transporter.transporterRecepisseValidityLimit = null; + transporter.transporterRecepisseDepartment = null; + } else { + transporter.transporterRecepisseNumber = + receipt?.receiptNumber ?? null; + transporter.transporterRecepisseValidityLimit = + receipt?.validityLimit ?? null; + transporter.transporterRecepisseDepartment = + receipt?.department ?? null; + } + } + } as RecipifyInputAccessor) + ) +]; + +export const recipifyBsff = buildRecipify(recipifyBsffAccessors); 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/bsffs/validation/bsff/schema.ts b/back/src/bsffs/validation/bsff/schema.ts index c104b0716e..31243e4eb4 100644 --- a/back/src/bsffs/validation/bsff/schema.ts +++ b/back/src/bsffs/validation/bsff/schema.ts @@ -14,12 +14,12 @@ import { import { BsffValidationContext } from "./types"; import { weightSchema } from "../../../common/validation/weight"; import { WeightUnits } from "../../../common/validation"; -import { sirenifyBsff, sirenifyBsffTransporter } from "./sirenify"; +import { sirenifyBsffTransporter } from "./sirenify"; import { checkAndSetPreviousPackagings, + runTransformers, updateTransporterRecepisse } from "./transformers"; -import { updateTransportersRecepisse } from "../../../common/validation/zod/transformers"; import { CompanyRole, rawTransporterSchema, @@ -194,8 +194,7 @@ export const contextualBsffSchema = (context: BsffValidationContext) => { export const contextualBsffSchemaAsync = (context: BsffValidationContext) => { return refinedBsffSchema .superRefine(checkCompanies) - .transform(sirenifyBsff(context)) - .transform(updateTransportersRecepisse) + .transform((bsff: ParsedZodBsff) => runTransformers(bsff, context)) .superRefine( // run le check sur les champs requis après les transformations // au cas où des transformations auto-complète certains champs diff --git a/back/src/bsffs/validation/bsff/sirenify.ts b/back/src/bsffs/validation/bsff/sirenify.ts index e5fdf18ad3..85421ff80a 100644 --- a/back/src/bsffs/validation/bsff/sirenify.ts +++ b/back/src/bsffs/validation/bsff/sirenify.ts @@ -1,18 +1,16 @@ -import { nextBuildSirenify } from "../../../companies/sirenify"; +import { + nextBuildSirenify, + NextCompanyInputAccessor +} from "../../../companies/sirenify"; import { CompanyInput } from "../../../generated/graphql/types"; -import { getSealedFields } from "./rules"; import { ParsedZodBsff, ParsedZodBsffTransporter } from "./schema"; -import { - BsffValidationContext, - ZodBsffTransformer, - ZodBsffTransporterTransformer -} from "./types"; +import { ZodBsffTransporterTransformer } from "./types"; const sirenifyBsffAccessors = ( bsff: ParsedZodBsff, // Tranformations should not be run on sealed fields sealedFields: string[] -) => [ +): NextCompanyInputAccessor[] => [ { siret: bsff?.emitterCompanySiret, skip: sealedFields.includes("emitterCompanySiret"), @@ -41,17 +39,9 @@ const sirenifyBsffAccessors = ( })) ]; -export const sirenifyBsff: ( - context: BsffValidationContext -) => ZodBsffTransformer = context => { - return async bsff => { - const sealedFields = await getSealedFields(bsff, context); - return nextBuildSirenify(sirenifyBsffAccessors)( - bsff, - sealedFields - ); - }; -}; +export const sirenifyBsff = nextBuildSirenify( + sirenifyBsffAccessors +); const sirenifyBsffTransporterAccessors = ( bsffTransporter: ParsedZodBsffTransporter diff --git a/back/src/bsffs/validation/bsff/transformers.ts b/back/src/bsffs/validation/bsff/transformers.ts index a988a7cc7f..e2cc664c63 100644 --- a/back/src/bsffs/validation/bsff/transformers.ts +++ b/back/src/bsffs/validation/bsff/transformers.ts @@ -1,7 +1,28 @@ -import { ZodBsffTransformer, ZodBsffTransporterTransformer } from "./types"; +import { + BsffValidationContext, + ZodBsffTransformer, + ZodBsffTransporterTransformer +} from "./types"; import { BsffType } from "@prisma/client"; import { checkPreviousPackagings } from "./refinements"; import { recipifyTransporter } from "../../../common/validation/zod/transformers"; +import { ParsedZodBsff } from "./schema"; +import { sirenifyBsff } from "./sirenify"; +import { recipifyBsff } from "./recipify"; +import { getSealedFields } from "./rules"; + +export const runTransformers = async ( + bsff: ParsedZodBsff, + context: BsffValidationContext +): Promise => { + const transformers = [sirenifyBsff, recipifyBsff]; + const sealedFields = await getSealedFields(bsff, context); + + for (const transformer of transformers) { + bsff = await transformer(bsff, sealedFields); + } + return bsff; +}; /** * Applique les vérifications sur les contenants à réexpédier / grouper / reconditionner puis applique diff --git a/back/src/bspaoh/__tests__/elastic.integration.ts b/back/src/bspaoh/__tests__/elastic.integration.ts index 948747af17..76678e3837 100644 --- a/back/src/bspaoh/__tests__/elastic.integration.ts +++ b/back/src/bspaoh/__tests__/elastic.integration.ts @@ -3,7 +3,8 @@ import { companyFactory } from "../../__tests__/factories"; import { getBspaohForElastic, toBsdElastic } from "../elastic"; import { BsdElastic } from "../../common/elastic"; import { bspaohFactory } from "./factories"; -import { Company } from "@prisma/client"; +import { BspaohStatus, Company, WasteAcceptationStatus } from "@prisma/client"; +import { xDaysAgo } from "../../utils"; describe("toBsdElastic > companies Names & OrgIds", () => { afterEach(resetDatabase); @@ -58,4 +59,125 @@ describe("toBsdElastic > companies Names & OrgIds", () => { expect(elasticBspaoh.companyOrgIds).toContain(transporter.vatNumber); expect(elasticBspaoh.companyOrgIds).toContain(destination.siret); }); + + describe("isReturnFor", () => { + it.each([ + WasteAcceptationStatus.REFUSED, + WasteAcceptationStatus.PARTIALLY_REFUSED + ])( + "waste acceptation status is %p > bspaoh should belong to tab", + async destinationReceptionAcceptationStatus => { + // Given + const transporter = await companyFactory(); + const bspaoh = await bspaohFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus, + transporters: { + create: { + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + transporterCompanyVatNumber: transporter.vatNumber, + number: 1 + } + } + } + }); + + // When + const bspaohForElastic = await getBspaohForElastic(bspaoh); + const { isReturnFor } = toBsdElastic(bspaohForElastic); + + // Then + expect(isReturnFor).toContain(transporter.siret); + } + ); + + it("status is REFUSED > bspaoh should belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bspaoh = await bspaohFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + destinationReceptionDate: new Date(), + status: BspaohStatus.REFUSED, + transporters: { + create: { + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + transporterCompanyVatNumber: transporter.vatNumber, + number: 1 + } + } + } + }); + + // When + const bspaohForElastic = await getBspaohForElastic(bspaoh); + const { isReturnFor } = toBsdElastic(bspaohForElastic); + + // Then + expect(isReturnFor).toContain(transporter.siret); + }); + + it("waste acceptation status is ACCEPTED > bspaoh should not belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bspaoh = await bspaohFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus: + WasteAcceptationStatus.ACCEPTED, + transporters: { + create: { + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + transporterCompanyVatNumber: transporter.vatNumber, + number: 1 + } + } + } + }); + + // When + const bspaohForElastic = await getBspaohForElastic(bspaoh); + const { isReturnFor } = toBsdElastic(bspaohForElastic); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + + it("bsda has been received too long ago > should not belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bspaoh = await bspaohFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + destinationReceptionDate: xDaysAgo(new Date(), 10), + destinationReceptionAcceptationStatus: WasteAcceptationStatus.REFUSED, + transporters: { + create: { + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + transporterCompanyVatNumber: transporter.vatNumber, + number: 1 + } + } + } + }); + + // When + const bspaohForElastic = await getBspaohForElastic(bspaoh); + const { isReturnFor } = toBsdElastic(bspaohForElastic); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + }); }); diff --git a/back/src/bspaoh/converter.ts b/back/src/bspaoh/converter.ts index d3116619c5..28be678faf 100644 --- a/back/src/bspaoh/converter.ts +++ b/back/src/bspaoh/converter.ts @@ -55,6 +55,16 @@ export function getFirstTransporterSync(bspaoh: { return firstTransporter ?? null; } +export function getLastTransporterSync(bspaoh: { + transporters: PrismaBspaohTransporter[] | null; +}): PrismaBspaohTransporter | null { + const { transporters } = bspaoh; + if (!transporters) return null; + const greatestNumber = Math.max(...transporters.map(t => t.number)); + const lastTransporter = transporters.find(t => t.number === greatestNumber); + return lastTransporter ?? null; +} + const getWastepackagingAcceptationStatus = ( wastePackaging, packagingsAcceptation diff --git a/back/src/bspaoh/elastic.ts b/back/src/bspaoh/elastic.ts index e8fe518a8a..fff7532a5f 100644 --- a/back/src/bspaoh/elastic.ts +++ b/back/src/bspaoh/elastic.ts @@ -3,18 +3,21 @@ import { Bspaoh, BsdType, Prisma, - OperationMode + OperationMode, + WasteAcceptationStatus } from "@prisma/client"; import { BsdElastic, indexBsd, transportPlateFilter } from "../common/elastic"; import { GraphQLContext } from "../types"; import { getRegistryFields } from "./registry"; import { getTransporterCompanyOrgId } from "@td/constants"; import { buildAddress } from "../companies/sirene/utils"; -import { getFirstTransporterSync } from "./converter"; +import { getFirstTransporterSync, getLastTransporterSync } from "./converter"; import { prisma } from "@td/prisma"; import { distinct } from "../common/arrays"; import { BspaohIncludes } from "./types"; import { getBspaohSubType } from "../common/subTypes"; +import { isDefined } from "../common/helpers"; +import { xDaysAgo } from "../utils"; export const BspaohForElasticInclude = { ...BspaohIncludes @@ -215,6 +218,7 @@ export function toBsdElastic(bspaoh: BspaohForElastic): BsdElastic { ...where, isInRevisionFor: [], isRevisedFor: [], + ...getBspaohReturnOrgIds(bspaoh), sirets: distinct(Object.values(where).flat()), ...getRegistryFields(bspaoh), rawBsd: bspaoh, @@ -254,3 +258,39 @@ export async function getBspaohForElastic( include: { transporters: true } }); } + +/** + * BSPAOH belongs to isReturnFor tab if: + * - waste has been received in the last 48 hours + * - waste hasn't been fully accepted at reception + */ +export const belongsToIsReturnForTab = (bspaoh: Bspaoh) => { + const hasBeenReceivedLately = + isDefined(bspaoh.destinationReceptionDate) && + bspaoh.destinationReceptionDate! > xDaysAgo(new Date(), 2); + + if (!hasBeenReceivedLately) return false; + + const hasNotBeenFullyAccepted = + bspaoh.status === BspaohStatus.REFUSED || + bspaoh.destinationReceptionAcceptationStatus !== + WasteAcceptationStatus.ACCEPTED; + + return hasNotBeenFullyAccepted; +}; + +function getBspaohReturnOrgIds(bspaoh: BspaohForElastic): { + isReturnFor: string[]; +} { + // Return tab + if (belongsToIsReturnForTab(bspaoh)) { + const transporters = bspaoh?.transporters ?? []; + const lastTransporter = getLastTransporterSync({ transporters }); + + return { + isReturnFor: [lastTransporter?.transporterCompanySiret].filter(Boolean) + }; + } + + return { isReturnFor: [] }; +} 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/bspaoh/validation/recipify.ts b/back/src/bspaoh/validation/recipify.ts new file mode 100644 index 0000000000..78e9d7b6b8 --- /dev/null +++ b/back/src/bspaoh/validation/recipify.ts @@ -0,0 +1,41 @@ +import { getTransporterCompanyOrgId } from "@td/constants"; +import { CompanyRole } from "../../common/validation/zod/schema"; +import { + buildRecipify, + RecipifyInputAccessor +} from "../../common/validation/recipify"; +import { ZodFullBspaoh } from "./schema"; + +const recipifyBspaohAccessors = ( + bsd: ZodFullBspaoh, + // Tranformations should not be run on sealed fields + sealedFields: string[] +): RecipifyInputAccessor[] => [ + { + role: CompanyRole.Transporter, + skip: sealedFields.includes("transporterRecepisseNumber"), + orgIdGetter: () => { + const orgId = getTransporterCompanyOrgId({ + transporterCompanySiret: bsd.transporterCompanySiret ?? null, + transporterCompanyVatNumber: bsd.transporterCompanyVatNumber ?? null + }); + return orgId ?? null; + }, + setter: async (bspaoh: ZodFullBspaoh, receipt) => { + if (bspaoh.transporterRecepisseIsExempted) { + bspaoh.transporterRecepisseNumber = null; + bspaoh.transporterRecepisseValidityLimit = null; + bspaoh.transporterRecepisseDepartment = null; + } else { + bspaoh.transporterRecepisseNumber = receipt?.receiptNumber ?? null; + bspaoh.transporterRecepisseValidityLimit = + receipt?.validityLimit ?? null; + bspaoh.transporterRecepisseDepartment = receipt?.department ?? null; + } + } + } +]; + +export const recipifyBspaoh = buildRecipify( + recipifyBspaohAccessors +); diff --git a/back/src/bspaoh/validation/sirenify.ts b/back/src/bspaoh/validation/sirenify.ts index 080082dd96..e0142def78 100644 --- a/back/src/bspaoh/validation/sirenify.ts +++ b/back/src/bspaoh/validation/sirenify.ts @@ -1,4 +1,7 @@ -import { nextBuildSirenify } from "../../companies/sirenify"; +import { + nextBuildSirenify, + NextCompanyInputAccessor +} from "../../companies/sirenify"; import { CompanyInput } from "../../generated/graphql/types"; import { ZodFullBspaoh } from "./schema"; @@ -10,7 +13,7 @@ type SiretInfos = { const accessors = ( input: ZodFullBspaoh, sealedFields: string[] // Transformations should not be run on sealed fields -) => { +): NextCompanyInputAccessor[] => { return [ { siret: input?.emitterCompanySiret, diff --git a/back/src/bspaoh/validation/transformers.ts b/back/src/bspaoh/validation/transformers.ts index 531849b187..f33dbb70ea 100644 --- a/back/src/bspaoh/validation/transformers.ts +++ b/back/src/bspaoh/validation/transformers.ts @@ -1,7 +1,6 @@ -import { prisma } from "@td/prisma"; -import { ZodBspaohTransporter, ZodFullBspaoh } from "./schema"; +import { ZodFullBspaoh } from "./schema"; import { sirenify } from "./sirenify"; -import { getTransporterCompanyOrgId } from "@td/constants"; +import { recipifyBspaoh } from "./recipify"; /** * @@ -12,40 +11,9 @@ export const runTransformers = async ( val: ZodFullBspaoh, sealedFields: string[] // Transformations should not be run on sealed fields ): Promise => { - const transformers = [sirenify, recipisseTransporterTransformer]; + const transformers = [sirenify, recipifyBspaoh]; for (const transformer of transformers) { val = await transformer(val, sealedFields); } return val; }; - -async function recipisseTransporterTransformer( - val: ZodBspaohTransporter, - sealedFields: string[] -): Promise { - if (sealedFields.includes("transporterCompanySiret")) { - return val; - } - - const orgId = getTransporterCompanyOrgId({ - transporterCompanySiret: val.transporterCompanySiret ?? null, - transporterCompanyVatNumber: val.transporterCompanyVatNumber ?? null - }); - - if (!val.transporterRecepisseIsExempted && orgId) { - const transporterReceipt = await prisma.company - .findUnique({ - where: { - orgId - } - }) - .transporterReceipt(); - - val.transporterRecepisseNumber = transporterReceipt?.receiptNumber ?? null; - val.transporterRecepisseValidityLimit = - transporterReceipt?.validityLimit ?? null; - val.transporterRecepisseDepartment = transporterReceipt?.department ?? null; - } - - return val; -} diff --git a/back/src/bsvhu/__tests__/bsvhuEdition.test.ts b/back/src/bsvhu/__tests__/bsvhuEdition.test.ts index 482f6fd42f..2c31cc91fe 100644 --- a/back/src/bsvhu/__tests__/bsvhuEdition.test.ts +++ b/back/src/bsvhu/__tests__/bsvhuEdition.test.ts @@ -1,6 +1,8 @@ import { + BsvhuBrokerInput, BsvhuDestinationInput, BsvhuDestinationType, + BsvhuEcoOrganismeInput, BsvhuEmitterInput, BsvhuIdentificationInput, BsvhuInput, @@ -8,6 +10,7 @@ import { BsvhuOperationInput, BsvhuRecepisseInput, BsvhuReceptionInput, + BsvhuTraderInput, BsvhuTransporterInput, BsvhuTransportInput, BsvhuWeightInput, @@ -17,7 +20,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 +99,21 @@ describe("edition", () => { operation }; + const ecoOrganisme: Required = { + siret: "xxx", + name: "yyy" + }; + + const broker: Required = { + company, + recepisse + }; + + const trader: Required = { + company, + recepisse + }; + const input: Required = { emitter, wasteCode: "", @@ -104,7 +122,11 @@ describe("edition", () => { quantity: 1, weight, transporter, - destination + destination, + intermediaries: [company], + ecoOrganisme, + broker, + trader }; 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..2779c57332 100644 --- a/back/src/bsvhu/__tests__/elastic.integration.ts +++ b/back/src/bsvhu/__tests__/elastic.integration.ts @@ -1,9 +1,10 @@ -import { Company } from "@prisma/client"; +import { BsvhuStatus, Company, WasteAcceptationStatus } from "@prisma/client"; import { resetDatabase } from "../../../integration-tests/helper"; import { companyFactory } from "../../__tests__/factories"; import { BsdElastic } from "../../common/elastic"; import { getWhere, toBsdElastic } from "../elastic"; import { bsvhuFactory, toIntermediaryCompany } from "./factories.vhu"; +import { xDaysAgo } from "../../utils"; describe("getWhere", () => { test("if emitter publishes VHU > transporter should see it in 'follow' tab", async () => { @@ -30,6 +31,9 @@ describe("toBsdElastic > companies Names & OrgIds", () => { let elasticBsvhu: BsdElastic; let intermediary1: Company; let intermediary2: Company; + let ecoOrganisme: Company; + let broker: Company; + let trader: Company; beforeAll(async () => { // Given @@ -43,7 +47,9 @@ describe("toBsdElastic > companies Names & OrgIds", () => { }); intermediary1 = await companyFactory({ name: "Intermediaire 1" }); intermediary2 = await companyFactory({ name: "Intermediaire 2" }); - + ecoOrganisme = await companyFactory({ name: "Eco organisme" }); + broker = await companyFactory({ name: "Broker" }); + trader = await companyFactory({ name: "Trader" }); bsvhu = await bsvhuFactory({ opt: { emitterCompanyName: emitter.name, @@ -53,6 +59,12 @@ describe("toBsdElastic > companies Names & OrgIds", () => { transporterCompanyVatNumber: transporter.vatNumber, destinationCompanyName: destination.name, destinationCompanySiret: destination.siret, + ecoOrganismeName: ecoOrganisme.name, + ecoOrganismeSiret: ecoOrganisme.siret, + brokerCompanySiret: broker.siret, + brokerCompanyName: broker.name, + traderCompanySiret: trader.siret, + traderCompanyName: trader.name, intermediaries: { createMany: { data: [ @@ -75,6 +87,9 @@ 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); + expect(elasticBsvhu.companyNames).toContain(broker.name); + expect(elasticBsvhu.companyNames).toContain(trader.name); }); test("companyOrgIds > should contain the orgIds of ALL BSVHU companies", async () => { @@ -84,5 +99,100 @@ 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); + expect(elasticBsvhu.companyOrgIds).toContain(broker.siret); + expect(elasticBsvhu.companyOrgIds).toContain(trader.siret); + }); + + describe("isReturnFor", () => { + it.each([ + WasteAcceptationStatus.REFUSED, + WasteAcceptationStatus.PARTIALLY_REFUSED + ])( + "waste acceptation status is %p > bsvhu should belong to tab", + async destinationReceptionAcceptationStatus => { + // Given + const transporter = await companyFactory(); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus + } + }); + + // When + const { isReturnFor } = toBsdElastic(bsvhu); + + // Then + expect(isReturnFor).toContain(transporter.siret); + } + ); + + it("status is REFUSED > bsvhu should belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + destinationReceptionDate: new Date(), + status: BsvhuStatus.REFUSED + } + }); + + // When + const { isReturnFor } = toBsdElastic(bsvhu); + + // Then + expect(isReturnFor).toContain(transporter.siret); + }); + + it("waste acceptation status is ACCEPTED > bsvhu should not belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus: WasteAcceptationStatus.ACCEPTED + } + }); + + // When + const { isReturnFor } = toBsdElastic(bsvhu); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + + it("bsda has been received too long ago > should not belong to tab", async () => { + // Given + const transporter = await companyFactory(); + const bsvhu = await bsvhuFactory({ + opt: { + emitterCompanyName: emitter.name, + emitterCompanySiret: emitter.siret, + transporterCompanyName: transporter.name, + transporterCompanySiret: transporter.siret, + destinationReceptionDate: xDaysAgo(new Date(), 10), + destinationReceptionAcceptationStatus: WasteAcceptationStatus.REFUSED + } + }); + + // When + const { isReturnFor } = toBsdElastic(bsvhu); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); }); }); diff --git a/back/src/bsvhu/__tests__/factories.vhu.ts b/back/src/bsvhu/__tests__/factories.vhu.ts index f77a861a64..4cad05b309 100644 --- a/back/src/bsvhu/__tests__/factories.vhu.ts +++ b/back/src/bsvhu/__tests__/factories.vhu.ts @@ -6,25 +6,64 @@ 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, + userWithCompanyFactory +} from "../../__tests__/factories"; import { BsvhuForElastic, BsvhuForElasticInclude } from "../elastic"; +import { getCanAccessDraftOrgIds } from "../utils"; export const bsvhuFactory = async ({ + userId, opt = {} }: { + userId?: string; opt?: Partial; }): Promise => { const transporterCompany = await companyFactory({ companyTypes: ["TRANSPORTER"] }); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] + }); + const nextDestinationCompany = await companyFactory({ + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR"] + }); + const brokerCompany = await companyFactory({ + companyTypes: ["BROKER"] + }); + const traderCompany = await companyFactory({ + companyTypes: ["TRADER"] + }); + const ecoOrganisme = await ecoOrganismeFactory({ + handle: { handleBsvhu: true }, + createAssociatedCompany: true }); + const bsvhuObject = getVhuFormdata(); + const userAndCompany = userId + ? null + : await userWithCompanyFactory("ADMIN", { + siret: bsvhuObject.emitterCompanySiret, + name: bsvhuObject.emitterCompanyName ?? undefined, + address: bsvhuObject.emitterCompanyAddress ?? undefined, + contact: bsvhuObject.emitterCompanyContact ?? undefined, + contactPhone: bsvhuObject.emitterCompanyPhone ?? undefined, + contactEmail: bsvhuObject.emitterCompanyMail ?? undefined + }); const created = await prisma.bsvhu.create({ data: { - ...getVhuFormdata(), + ...bsvhuObject, transporterCompanySiret: transporterCompany.siret, destinationCompanySiret: destinationCompany.siret, + destinationOperationNextDestinationCompanySiret: + nextDestinationCompany.siret, + ecoOrganismeSiret: ecoOrganisme.siret, + brokerCompanySiret: brokerCompany.siret, + traderCompanySiret: traderCompany.siret, ...opt }, include: { @@ -36,9 +75,17 @@ export const bsvhuFactory = async ({ .flatMap(intermediary => [intermediary.siret, intermediary.vatNumber]) .filter(Boolean) : []; + + // For drafts, only the owner's sirets that appear on the bsd have access + const canAccessDraftOrgIds = await getCanAccessDraftOrgIds( + created, + (userId || userAndCompany?.user?.id) as string + ); + return prisma.bsvhu.update({ where: { id: created.id }, data: { + ...(canAccessDraftOrgIds.length ? { canAccessDraftOrgIds } : {}), ...(intermediariesOrgIds.length ? { intermediariesOrgIds } : {}) }, include: BsvhuForElasticInclude @@ -65,6 +112,14 @@ const getVhuFormdata = (): Prisma.BsvhuCreateInput => ({ destinationCompanyPhone: "05 05 05 05 05", destinationCompanyMail: "recipient@td.io", + destinationOperationNextDestinationCompanyName: "Car crusher", + destinationOperationNextDestinationCompanySiret: siretify(3), + destinationOperationNextDestinationCompanyAddress: + "12 rue des épavistes, Detroit", + destinationOperationNextDestinationCompanyContact: "Jean l'opérateur", + destinationOperationNextDestinationCompanyPhone: "06 07 08 09 10", + destinationOperationNextDestinationCompanyMail: "operator@carcrusher.com", + packaging: "UNITE" as BsvhuPackaging, identificationNumbers: ["1", "2", "3"], identificationType: "NUMERO_ORDRE_REGISTRE_POLICE" as BsvhuIdentificationType, @@ -73,7 +128,7 @@ const getVhuFormdata = (): Prisma.BsvhuCreateInput => ({ weightIsEstimate: true, transporterCompanyName: "Transport facile", - transporterCompanySiret: siretify(3), + transporterCompanySiret: siretify(4), transporterCompanyAddress: "12 route du Transporter, Transporter City", transporterCompanyContact: "Henri Transport", transporterCompanyPhone: "06 06 06 06 06", @@ -85,7 +140,29 @@ const getVhuFormdata = (): Prisma.BsvhuCreateInput => ({ destinationReceptionWeight: null, destinationReceptionAcceptationStatus: null, destinationReceptionRefusalReason: null, - destinationOperationCode: null + destinationOperationCode: null, + + ecoOrganismeSiret: siretify(5), + ecoOrganismeName: "Eco-Organisme", + + brokerCompanyName: "Courtier efficace", + brokerCompanySiret: siretify(6), + brokerCompanyAddress: "15 Rue des Lilas, 33000 Lille", + brokerCompanyContact: "Anton Spencer", + brokerCompanyPhone: "06 67 78 89 91", + brokerCompanyMail: "a.spencer@goodbroker.com", + brokerRecepisseNumber: "receipt number", + brokerRecepisseDepartment: "33", + brokerRecepisseValidityLimit: "2026-11-27T00:00:00.000Z", + traderCompanyName: "Le Négoce QVB", + traderCompanySiret: siretify(7), + traderCompanyAddress: "32 Avenue des Azalées, 33700 Mérignac", + traderCompanyContact: "Benjamin Turner", + traderCompanyPhone: "06 68 35 64 34", + traderCompanyMail: "b.turner@tradingalright.com", + traderRecepisseNumber: "receipt of the firm", + traderRecepisseDepartment: "33", + traderRecepisseValidityLimit: "2026-11-28T00:00:00.000Z" }); 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..bf893a3155 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", () => { @@ -62,6 +62,27 @@ describe("toGenericWaste", () => { expect(waste.emitterCompanyCity).toBe("Mallemort"); expect(waste.emitterCompanyCountry).toBe("FR"); }); + + it("should contain broker and trader emails", async () => { + // Given + const bsvhu = await bsvhuFactory({ + opt: { + brokerCompanyMail: "broker@mail.com", + traderCompanyMail: "trader@mail.com" + } + }); + + // When + const bsvhuForRegistry = await prisma.bsvhu.findUniqueOrThrow({ + where: { id: bsvhu.id }, + include: RegistryBsvhuInclude + }); + const waste = toGenericWaste(bsvhuForRegistry); + + // Then + expect(waste.brokerCompanyMail).toBe("broker@mail.com"); + expect(waste.traderCompanyMail).toBe("trader@mail.com"); + }); }); describe("toIncomingWaste", () => { @@ -142,6 +163,30 @@ describe("toIncomingWaste", () => { expect(waste.initialEmitterCompanyName).toBeNull(); expect(waste.initialEmitterCompanySiret).toBeNull(); }); + + it("should contain broker and trader information", async () => { + // Given + const bsvhu = await bsvhuFactory({}); + + // When + const bsvhuForRegistry = await prisma.bsvhu.findUniqueOrThrow({ + where: { id: bsvhu.id }, + include: RegistryBsvhuInclude + }); + const waste = toIncomingWaste(bsvhuForRegistry); + + // Then + expect(waste.brokerCompanySiret).toBe(bsvhuForRegistry.brokerCompanySiret); + expect(waste.brokerCompanyName).toBe(bsvhuForRegistry.brokerCompanyName); + expect(waste.brokerRecepisseNumber).toBe( + bsvhuForRegistry.brokerRecepisseNumber + ); + expect(waste.traderCompanySiret).toBe(bsvhuForRegistry.traderCompanySiret); + expect(waste.traderCompanyName).toBe(bsvhuForRegistry.traderCompanyName); + expect(waste.traderRecepisseNumber).toBe( + bsvhuForRegistry.traderRecepisseNumber + ); + }); }); describe("toOutgoingWaste", () => { @@ -190,6 +235,29 @@ describe("toOutgoingWaste", () => { expect(waste.initialEmitterCompanyName).toBeNull(); expect(waste.initialEmitterCompanySiret).toBeNull(); }); + it("should contain broker and trader information", async () => { + // Given + const bsvhu = await bsvhuFactory({}); + + // When + const bsvhuForRegistry = await prisma.bsvhu.findUniqueOrThrow({ + where: { id: bsvhu.id }, + include: RegistryBsvhuInclude + }); + const waste = toOutgoingWaste(bsvhuForRegistry); + + // Then + expect(waste.brokerCompanySiret).toBe(bsvhuForRegistry.brokerCompanySiret); + expect(waste.brokerCompanyName).toBe(bsvhuForRegistry.brokerCompanyName); + expect(waste.brokerRecepisseNumber).toBe( + bsvhuForRegistry.brokerRecepisseNumber + ); + expect(waste.traderCompanySiret).toBe(bsvhuForRegistry.traderCompanySiret); + expect(waste.traderCompanyName).toBe(bsvhuForRegistry.traderCompanyName); + expect(waste.traderRecepisseNumber).toBe( + bsvhuForRegistry.traderRecepisseNumber + ); + }); }); describe("toTransportedWaste", () => { @@ -253,6 +321,29 @@ describe("toTransportedWaste", () => { expect(waste.transporter5CompanySiret).toBeNull(); expect(waste.transporter5NumberPlates).toBeNull(); }); + it("should contain broker and trader information", async () => { + // Given + const bsvhu = await bsvhuFactory({}); + + // When + const bsvhuForRegistry = await prisma.bsvhu.findUniqueOrThrow({ + where: { id: bsvhu.id }, + include: RegistryBsvhuInclude + }); + const waste = toTransportedWaste(bsvhuForRegistry); + + // Then + expect(waste.brokerCompanySiret).toBe(bsvhuForRegistry.brokerCompanySiret); + expect(waste.brokerCompanyName).toBe(bsvhuForRegistry.brokerCompanyName); + expect(waste.brokerRecepisseNumber).toBe( + bsvhuForRegistry.brokerRecepisseNumber + ); + expect(waste.traderCompanySiret).toBe(bsvhuForRegistry.traderCompanySiret); + expect(waste.traderCompanyName).toBe(bsvhuForRegistry.traderCompanyName); + expect(waste.traderRecepisseNumber).toBe( + bsvhuForRegistry.traderRecepisseNumber + ); + }); }); describe("toManagedWaste", () => { @@ -313,6 +404,23 @@ describe("toManagedWaste", () => { expect(waste.transporter5CompanySiret).toBeNull(); expect(waste["transporter5NumberPlates"]).toBeUndefined(); }); + it("should contain broker and trader information", async () => { + // Given + const bsvhu = await bsvhuFactory({}); + + // When + const bsvhuForRegistry = await prisma.bsvhu.findUniqueOrThrow({ + where: { id: bsvhu.id }, + include: RegistryBsvhuInclude + }); + const waste = toManagedWaste(bsvhuForRegistry); + + // Then + expect(waste.brokerCompanySiret).toBe(bsvhuForRegistry.brokerCompanySiret); + expect(waste.brokerCompanyName).toBe(bsvhuForRegistry.brokerCompanyName); + expect(waste.traderCompanySiret).toBe(bsvhuForRegistry.traderCompanySiret); + expect(waste.traderCompanyName).toBe(bsvhuForRegistry.traderCompanyName); + }); }); describe("toAllWaste", () => { @@ -559,6 +667,55 @@ 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); + }); + it("should contain broker and trader information", async () => { + // Given + const bsvhu = await bsvhuFactory({}); + + // When + const bsvhuForRegistry = await prisma.bsvhu.findUniqueOrThrow({ + where: { id: bsvhu.id }, + include: RegistryBsvhuInclude + }); + const waste = toAllWaste(bsvhuForRegistry); + + // Then + expect(waste.brokerCompanySiret).toBe(bsvhuForRegistry.brokerCompanySiret); + expect(waste.brokerCompanyName).toBe(bsvhuForRegistry.brokerCompanyName); + expect(waste.brokerRecepisseNumber).toBe( + bsvhuForRegistry.brokerRecepisseNumber + ); + expect(waste.traderCompanySiret).toBe(bsvhuForRegistry.traderCompanySiret); + expect(waste.traderCompanyName).toBe(bsvhuForRegistry.traderCompanyName); + expect(waste.traderRecepisseNumber).toBe( + bsvhuForRegistry.traderRecepisseNumber + ); + }); }); describe("getTransportersData", () => { diff --git a/back/src/bsvhu/__tests__/where.test.ts b/back/src/bsvhu/__tests__/where.test.ts index ff2b4c60d8..d3a6149260 100644 --- a/back/src/bsvhu/__tests__/where.test.ts +++ b/back/src/bsvhu/__tests__/where.test.ts @@ -19,17 +19,21 @@ describe("Bsvhu where builder", () => { it("should convert string filters to db filters", () => { const where: BsvhuWhere = { - emitter: { company: { siret: { _eq: "1234" } } }, - destination: { company: { siret: { _eq: "1234" } } }, - transporter: { company: { siret: { _eq: "1234" } } } + emitter: { company: { siret: { _eq: "emitter" } } }, + destination: { company: { siret: { _eq: "destination" } } }, + transporter: { company: { siret: { _eq: "transporter" } } }, + broker: { company: { siret: { _eq: "broker" } } }, + trader: { company: { siret: { _eq: "trader" } } } }; const dbFilter = toPrismaWhereInput(where); expect(dbFilter).toEqual({ - emitterCompanySiret: { equals: "1234" }, - destinationCompanySiret: { equals: "1234" }, - transporterCompanySiret: { equals: "1234" } + emitterCompanySiret: { equals: "emitter" }, + destinationCompanySiret: { equals: "destination" }, + transporterCompanySiret: { equals: "transporter" }, + brokerCompanySiret: { equals: "broker" }, + traderCompanySiret: { equals: "trader" } }); }); diff --git a/back/src/bsvhu/__tests__/workflow.integration.ts b/back/src/bsvhu/__tests__/workflow.integration.ts index a210aa299a..aab7c67844 100644 --- a/back/src/bsvhu/__tests__/workflow.integration.ts +++ b/back/src/bsvhu/__tests__/workflow.integration.ts @@ -21,7 +21,10 @@ describe("Exemples de circuit du bordereau de suivi de véhicule hors d'usage", const { user: transporterUser, company: transporterCompany } = await userWithCompanyFactory("MEMBER"); const { user: broyeurUser, company: broyeurCompany } = - await userWithCompanyFactory("MEMBER"); + await userWithCompanyFactory("MEMBER", { + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] + }); const producteurToken = await apiKey(producteurUser); const transporteurToken = await apiKey(transporterUser); diff --git a/back/src/bsvhu/converter.ts b/back/src/bsvhu/converter.ts index a48992b79d..9cdf54c67a 100644 --- a/back/src/bsvhu/converter.ts +++ b/back/src/bsvhu/converter.ts @@ -22,7 +22,10 @@ import { BsvhuNextDestination, BsvhuTransport, BsvhuTransportInput, - CompanyInput + CompanyInput, + BsvhuEcoOrganisme, + BsvhuBroker, + BsvhuTrader } from "../generated/graphql/types"; import { Prisma, @@ -57,8 +60,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, @@ -160,6 +163,40 @@ export function expandVhuFormFromDb(form: PrismaVhuForm): GraphqlVhuForm { takenOverAt: processDate(form.transporterTransportTakenOverAt) }) }), + ecoOrganisme: nullIfNoValues({ + name: form.ecoOrganismeName, + siret: form.ecoOrganismeSiret + }), + broker: nullIfNoValues({ + company: nullIfNoValues({ + name: form.brokerCompanyName, + siret: form.brokerCompanySiret, + address: form.brokerCompanyAddress, + contact: form.brokerCompanyContact, + phone: form.brokerCompanyPhone, + mail: form.brokerCompanyMail + }), + recepisse: nullIfNoValues({ + department: form.brokerRecepisseDepartment, + number: form.brokerRecepisseNumber, + validityLimit: processDate(form.brokerRecepisseValidityLimit) + }) + }), + trader: nullIfNoValues({ + company: nullIfNoValues({ + name: form.traderCompanyName, + siret: form.traderCompanySiret, + address: form.traderCompanyAddress, + contact: form.traderCompanyContact, + phone: form.traderCompanyPhone, + mail: form.traderCompanyMail + }), + recepisse: nullIfNoValues({ + department: form.traderRecepisseDepartment, + number: form.traderRecepisseNumber, + validityLimit: processDate(form.traderRecepisseValidityLimit) + }) + }), metadata: null as any }; } @@ -169,6 +206,9 @@ export function flattenVhuInput(formInput: BsvhuInput) { ...flattenVhuEmitterInput(formInput), ...flattenVhuDestinationInput(formInput), ...flattenVhuTransporterInput(formInput), + ...flattenVhuEcoOrganismeInput(formInput), + ...flattenVhuBrokerInput(formInput), + ...flattenVhuTraderInput(formInput), packaging: chain(formInput, f => f.packaging), wasteCode: chain(formInput, f => f.wasteCode), quantity: chain(formInput, f => f.quantity), @@ -341,6 +381,55 @@ function flattenVhuTransporterInput({ }; } +function flattenVhuEcoOrganismeInput({ + ecoOrganisme +}: Pick) { + return { + ecoOrganismeName: chain(ecoOrganisme, e => e.name), + ecoOrganismeSiret: chain(ecoOrganisme, e => e.siret) + }; +} + +function flattenVhuBrokerInput({ broker }: Pick) { + return { + brokerCompanyName: chain(broker, b => chain(b.company, c => c.name)), + brokerCompanySiret: chain(broker, b => chain(b.company, c => c.siret)), + brokerCompanyAddress: chain(broker, b => chain(b.company, c => c.address)), + brokerCompanyContact: chain(broker, b => chain(b.company, c => c.contact)), + brokerCompanyPhone: chain(broker, b => chain(b.company, c => c.phone)), + brokerCompanyMail: chain(broker, b => chain(b.company, c => c.mail)), + brokerRecepisseNumber: chain(broker, b => + chain(b.recepisse, r => r.number) + ), + brokerRecepisseDepartment: chain(broker, b => + chain(b.recepisse, r => r.department) + ), + brokerRecepisseValidityLimit: chain(broker, b => + chain(b.recepisse, r => r.validityLimit) + ) + }; +} + +function flattenVhuTraderInput({ trader }: Pick) { + return { + traderCompanyName: chain(trader, b => chain(b.company, c => c.name)), + traderCompanySiret: chain(trader, b => chain(b.company, c => c.siret)), + traderCompanyAddress: chain(trader, b => chain(b.company, c => c.address)), + traderCompanyContact: chain(trader, b => chain(b.company, c => c.contact)), + traderCompanyPhone: chain(trader, b => chain(b.company, c => c.phone)), + traderCompanyMail: chain(trader, b => chain(b.company, c => c.mail)), + traderRecepisseNumber: chain(trader, b => + chain(b.recepisse, r => r.number) + ), + traderRecepisseDepartment: chain(trader, b => + chain(b.recepisse, r => r.department) + ), + traderRecepisseValidityLimit: chain(trader, b => + chain(b.recepisse, r => r.validityLimit) + ) + }; +} + function flattenTransporterTransportInput( input: | { diff --git a/back/src/bsvhu/elastic.ts b/back/src/bsvhu/elastic.ts index 60745af50a..5776ef6793 100644 --- a/back/src/bsvhu/elastic.ts +++ b/back/src/bsvhu/elastic.ts @@ -1,4 +1,9 @@ -import { BsvhuStatus, Bsvhu, BsdType } from "@prisma/client"; +import { + BsvhuStatus, + Bsvhu, + BsdType, + WasteAcceptationStatus +} from "@prisma/client"; import { prisma } from "@td/prisma"; import { getIntermediaryCompanyOrgId, @@ -13,6 +18,8 @@ import { BsvhuWithIntermediaries, BsvhuWithIntermediariesInclude } from "./types"; +import { isDefined } from "../common/helpers"; +import { xDaysAgo } from "../utils"; export const BsvhuForElasticInclude = { ...BsvhuWithIntermediariesInclude @@ -29,8 +36,11 @@ export async function getBsvhuForElastic( type ElasticSirets = { emitterCompanySiret: string | null | undefined; + ecoOrganismeSiret: string | null | undefined; destinationCompanySiret: string | null | undefined; transporterCompanySiret: string | null | undefined; + brokerCompanySiret: string | null | undefined; + traderCompanySiret: string | null | undefined; intermediarySiret1?: string | null | undefined; intermediarySiret2?: string | null | undefined; intermediarySiret3?: string | null | undefined; @@ -54,10 +64,21 @@ const getBsvhuSirets = (bsvhu: BsvhuForElastic): ElasticSirets => { const bsvhuSirets: ElasticSirets = { emitterCompanySiret: bsvhu.emitterCompanySiret, destinationCompanySiret: bsvhu.destinationCompanySiret, + ecoOrganismeSiret: bsvhu.ecoOrganismeSiret, + brokerCompanySiret: bsvhu.brokerCompanySiret, + traderCompanySiret: bsvhu.traderCompanySiret, transporterCompanySiret: getTransporterCompanyOrgId(bsvhu), ...intermediarySirets }; + // Drafts only appear in the dashboard for companies the bsvhu owner belongs to + if (bsvhu.isDraft) { + const draftFormSiretsEntries = Object.entries(bsvhuSirets).filter( + ([, siret]) => siret && bsvhu.canAccessDraftOrgIds.includes(siret) + ); + return Object.fromEntries(draftFormSiretsEntries) as ElasticSirets; + } + return bsvhuSirets; }; @@ -202,16 +223,16 @@ export function toBsdElastic(bsvhu: BsvhuForElastic): BsdElastic { destinationCustomInfo: "", destinationCap: "", - brokerCompanyName: "", - brokerCompanySiret: "", - brokerCompanyAddress: "", + brokerCompanyName: bsvhu.brokerCompanyName ?? "", + brokerCompanySiret: bsvhu.brokerCompanySiret ?? "", + brokerCompanyAddress: bsvhu.brokerCompanyAddress ?? "", - traderCompanyName: "", - traderCompanySiret: "", - traderCompanyAddress: "", + traderCompanyName: bsvhu.traderCompanyName ?? "", + traderCompanySiret: bsvhu.traderCompanySiret ?? "", + traderCompanyAddress: bsvhu.traderCompanyAddress ?? "", - ecoOrganismeName: "", - ecoOrganismeSiret: "", + ecoOrganismeName: bsvhu.ecoOrganismeName ?? "", + ecoOrganismeSiret: bsvhu.ecoOrganismeSiret ?? "", nextDestinationCompanyName: bsvhu.destinationOperationNextDestinationCompanyName ?? "", @@ -236,6 +257,7 @@ export function toBsdElastic(bsvhu: BsvhuForElastic): BsdElastic { ...where, isInRevisionFor: [], isRevisedFor: [], + ...getBsvhuReturnOrgIds(bsvhu), sirets: Object.values(where).flat(), ...getRegistryFields(bsvhu), rawBsd: bsvhu, @@ -247,6 +269,9 @@ export function toBsdElastic(bsvhu: BsvhuForElastic): BsdElastic { bsvhu.emitterCompanyName, bsvhu.transporterCompanyName, bsvhu.destinationCompanyName, + bsvhu.ecoOrganismeName, + bsvhu.brokerCompanyName, + bsvhu.traderCompanyName, ...bsvhu.intermediaries.map(intermediary => intermediary.name) ] .filter(Boolean) @@ -256,6 +281,9 @@ export function toBsdElastic(bsvhu: BsvhuForElastic): BsdElastic { bsvhu.transporterCompanySiret, bsvhu.transporterCompanyVatNumber, bsvhu.destinationCompanySiret, + bsvhu.ecoOrganismeSiret, + bsvhu.brokerCompanySiret, + bsvhu.traderCompanySiret, ...bsvhu.intermediaries.map(intermediary => intermediary.siret), ...bsvhu.intermediaries.map(intermediary => intermediary.vatNumber) ].filter(Boolean) @@ -265,3 +293,36 @@ export function toBsdElastic(bsvhu: BsvhuForElastic): BsdElastic { export function indexBsvhu(bsvhu: BsvhuForElastic, ctx?: GraphQLContext) { return indexBsd(toBsdElastic(bsvhu), ctx); } + +/** + * BSVHU belongs to isReturnFor tab if: + * - waste has been received in the last 48 hours + * - waste hasn't been fully accepted at reception + */ +export const belongsToIsReturnForTab = (bsvhu: BsvhuForElastic) => { + const hasBeenReceivedLately = + isDefined(bsvhu.destinationReceptionDate) && + bsvhu.destinationReceptionDate! > xDaysAgo(new Date(), 2); + + if (!hasBeenReceivedLately) return false; + + const hasNotBeenFullyAccepted = + bsvhu.status === BsvhuStatus.REFUSED || + bsvhu.destinationReceptionAcceptationStatus !== + WasteAcceptationStatus.ACCEPTED; + + return hasNotBeenFullyAccepted; +}; + +function getBsvhuReturnOrgIds(bsvhu: BsvhuForElastic): { + isReturnFor: string[]; +} { + // Return tab + if (belongsToIsReturnForTab(bsvhu)) { + return { + isReturnFor: [bsvhu.transporterCompanySiret].filter(Boolean) + }; + } + + return { isReturnFor: [] }; +} diff --git a/back/src/bsvhu/examples/workflows/vhuVersBroyeur.ts b/back/src/bsvhu/examples/workflows/vhuVersBroyeur.ts index 856f15394d..cb476ebefa 100644 --- a/back/src/bsvhu/examples/workflows/vhuVersBroyeur.ts +++ b/back/src/bsvhu/examples/workflows/vhuVersBroyeur.ts @@ -11,7 +11,11 @@ const workflow: Workflow = { companies: [ { name: "producteur", companyTypes: ["PRODUCER"] }, { name: "transporteur", companyTypes: ["TRANSPORTER"] }, - { name: "broyeur", companyTypes: ["WASTE_VEHICLES", "WASTEPROCESSOR"] } + { + name: "broyeur", + companyTypes: ["WASTE_VEHICLES", "WASTEPROCESSOR"], + opt: { wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] } + } ], steps: [ createBsvhu("producteur"), diff --git a/back/src/bsvhu/examples/workflows/vhuVersBroyeurTransporteurEtranger.ts b/back/src/bsvhu/examples/workflows/vhuVersBroyeurTransporteurEtranger.ts index 66e31f41cf..19618a58e6 100644 --- a/back/src/bsvhu/examples/workflows/vhuVersBroyeurTransporteurEtranger.ts +++ b/back/src/bsvhu/examples/workflows/vhuVersBroyeurTransporteurEtranger.ts @@ -18,7 +18,11 @@ const workflow: Workflow = { siret: null } }, - { name: "broyeur", companyTypes: ["WASTE_VEHICLES", "WASTEPROCESSOR"] } + { + name: "broyeur", + companyTypes: ["WASTE_VEHICLES", "WASTEPROCESSOR"], + opt: { wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] } + } ], steps: [ createBsvhu("producteur", fixtures as any), diff --git a/back/src/bsvhu/pdf/components/BsvhuPdf.tsx b/back/src/bsvhu/pdf/components/BsvhuPdf.tsx index 00f8027adb..c300141f07 100644 --- a/back/src/bsvhu/pdf/components/BsvhuPdf.tsx +++ b/back/src/bsvhu/pdf/components/BsvhuPdf.tsx @@ -1,9 +1,14 @@ import * as React from "react"; -import { Document, formatDate, SignatureStamp } from "../../../common/pdf"; +import { + Document, + formatDate, + FormCompanyFields, + SignatureStamp +} from "../../../common/pdf"; import { Bsvhu, OperationMode } from "../../../generated/graphql/types"; -import { Recepisse } from "./Recepisse"; import { getOperationModeLabel } from "../../../common/operationModes"; import { dateToXMonthAtHHMM } from "../../../common/helpers"; +import { Recepisse } from "../../../common/pdf/components/Recepisse"; const IDENTIFICATION_TYPES_LABELS = { NUMERO_ORDRE_REGISTRE_POLICE: @@ -81,7 +86,7 @@ export function BsvhuPdf({ bsvhu, qrCode, renderEmpty }: Props) { id="e_irregularSituation" />{" "}

) : null} @@ -107,6 +112,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 */} @@ -260,7 +275,7 @@ export function BsvhuPdf({ bsvhu, qrCode, renderEmpty }: Props) { {/* End Waste identification*/} - {/* Emitter signature*/}{" "} + {/* Emitter signature*/}

@@ -285,12 +300,80 @@ export function BsvhuPdf({ bsvhu, qrCode, renderEmpty }: Props) {

- {/* End Emitter signature*/} + {/* End Emitter signature */} + {/* Trader informations or broker information if no trader */} +
+
+

+ + 8.{bsvhu.trader && bsvhu.broker ? "1" : ""}{" "} + {" "} + Négociant{" "} + {" "} + Courtier + +

+
+
+ +
+
+ +
+
+
+
+ {/* End Broker/Trader informations */} + {/* Broker information */} + {bsvhu.broker ? ( +
+
+

+ + 8.{bsvhu.trader ? "2" : ""}{" "} + Négociant{" "} + Courtier + +

+
+
+ +
+
+ +
+
+
+
+ ) : null} + {/* End Broker informations */} {/* Transporter */}

- 8. Transporteur + 9. Transporteur

@@ -354,7 +437,7 @@ export function BsvhuPdf({ bsvhu, qrCode, renderEmpty }: Props) {

- 9. Installation de destination + 10. Installation de destination

- 10. Réalisation de l’opération + 11. Réalisation de l’opération
-

Récépissé n° : {recepisse?.number}

-
-

Département : {recepisse?.department}

-

- Date limite de validité : {formatDate(recepisse?.validityLimit)} -

-
- - ); -} diff --git a/back/src/bsvhu/permissions.ts b/back/src/bsvhu/permissions.ts index 852009b779..ab20d0e336 100644 --- a/back/src/bsvhu/permissions.ts +++ b/back/src/bsvhu/permissions.ts @@ -6,13 +6,18 @@ import { Permission, checkUserPermissions } from "../permissions"; * Retrieves organisations allowed to read a BSVHU */ function readers(bsvhu: Bsvhu): string[] { - return [ - bsvhu.emitterCompanySiret, - bsvhu.destinationCompanySiret, - bsvhu.transporterCompanySiret, - bsvhu.transporterCompanyVatNumber, - ...bsvhu.intermediariesOrgIds - ].filter(Boolean); + return bsvhu.isDraft + ? [...bsvhu.canAccessDraftOrgIds] + : [ + bsvhu.emitterCompanySiret, + bsvhu.destinationCompanySiret, + bsvhu.transporterCompanySiret, + bsvhu.transporterCompanyVatNumber, + bsvhu.ecoOrganismeSiret, + bsvhu.brokerCompanySiret, + bsvhu.traderCompanySiret, + ...bsvhu.intermediariesOrgIds + ].filter(Boolean); } /** @@ -22,11 +27,18 @@ function readers(bsvhu: Bsvhu): string[] { * a user is not removing his own company from the BSVHU */ function contributors(bsvhu: Bsvhu, input?: BsvhuInput): string[] { + if (bsvhu.isDraft) { + return [...bsvhu.canAccessDraftOrgIds]; + } const updateEmitterCompanySiret = input?.emitter?.company?.siret; const updateDestinationCompanySiret = input?.destination?.company?.siret; const updateTransporterCompanySiret = input?.transporter?.company?.siret; const updateTransporterCompanyVatNumber = input?.transporter?.company?.vatNumber; + const updateEcoOrganismeCompanySiret = input?.ecoOrganisme?.siret; + const updateBrokerCompanySiret = input?.broker?.company?.siret; + const updateTraderCompanySiret = input?.trader?.company?.siret; + const updateIntermediaries = (input?.intermediaries ?? []).flatMap(i => [ i.siret, i.vatNumber @@ -52,6 +64,21 @@ function contributors(bsvhu: Bsvhu, input?: BsvhuInput): string[] { ? updateTransporterCompanyVatNumber : bsvhu.transporterCompanyVatNumber; + const ecoOrganismeCompanySiret = + updateEcoOrganismeCompanySiret !== undefined + ? updateEcoOrganismeCompanySiret + : bsvhu.ecoOrganismeSiret; + + const brokerCompanySiret = + updateBrokerCompanySiret !== undefined + ? updateBrokerCompanySiret + : bsvhu.brokerCompanySiret; + + const traderCompanySiret = + updateTraderCompanySiret !== undefined + ? updateTraderCompanySiret + : bsvhu.traderCompanySiret; + const intermediariesOrgIds = input?.intermediaries !== undefined ? updateIntermediaries @@ -62,6 +89,9 @@ function contributors(bsvhu: Bsvhu, input?: BsvhuInput): string[] { destinationCompanySiret, transporterCompanySiret, transporterCompanyVatNumber, + ecoOrganismeCompanySiret, + brokerCompanySiret, + traderCompanySiret, ...intermediariesOrgIds ].filter(Boolean); } @@ -72,9 +102,12 @@ 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 + input.destination?.company?.siret, + input.broker?.company?.siret, + input.trader?.company?.siret ].filter(Boolean); } diff --git a/back/src/bsvhu/registry.ts b/back/src/bsvhu/registry.ts index fc6b3cc5e4..861a775c78 100644 --- a/back/src/bsvhu/registry.ts +++ b/back/src/bsvhu/registry.ts @@ -119,6 +119,18 @@ 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.brokerCompanySiret) { + registryFields.isManagedWasteFor.push(bsvhu.brokerCompanySiret); + registryFields.isAllWasteFor.push(bsvhu.brokerCompanySiret); + } + if (bsvhu.traderCompanySiret) { + registryFields.isManagedWasteFor.push(bsvhu.traderCompanySiret); + registryFields.isAllWasteFor.push(bsvhu.traderCompanySiret); + } if (bsvhu.intermediaries?.length) { for (const intermediary of bsvhu.intermediaries) { const intermediaryOrgId = getIntermediaryCompanyOrgId(intermediary); @@ -172,8 +184,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, @@ -215,6 +227,8 @@ export function toGenericWaste(bsvhu: RegistryBsvhu): GenericWaste { emitterCompanyName: bsvhu.emitterCompanyName, emitterCompanySiret: bsvhu.emitterCompanySiret, emitterCompanyIrregularSituation: !!bsvhu.emitterIrregularSituation, + brokerCompanyMail: bsvhu.brokerCompanyMail, + traderCompanyMail: bsvhu.traderCompanyMail, weight: bsvhu.weightValue ? new Decimal(bsvhu.weightValue) .dividedBy(1000) @@ -233,12 +247,12 @@ export function toIncomingWaste(bsvhu: RegistryBsvhu): Required { ...emptyIncomingWaste, ...genericWaste, destinationReceptionDate: bsvhu.destinationReceptionDate, - traderCompanyName: null, - traderCompanySiret: null, - traderRecepisseNumber: null, - brokerCompanyName: null, - brokerCompanySiret: null, - brokerRecepisseNumber: null, + traderCompanyName: bsvhu.traderCompanyName, + traderCompanySiret: bsvhu.traderCompanySiret, + traderRecepisseNumber: bsvhu.traderRecepisseNumber, + brokerCompanyName: bsvhu.brokerCompanyName, + brokerCompanySiret: bsvhu.brokerCompanySiret, + brokerRecepisseNumber: bsvhu.brokerRecepisseNumber, emitterCompanyMail: bsvhu.emitterCompanyMail, ...getOperationData(bsvhu), ...getTransporterData(bsvhu), @@ -253,13 +267,13 @@ export function toOutgoingWaste(bsvhu: RegistryBsvhu): Required { // Make sure all possible keys are in the exported sheet so that no column is missing ...emptyOutgoingWaste, ...genericWaste, - brokerCompanyName: null, - brokerCompanySiret: null, - brokerRecepisseNumber: null, destinationPlannedOperationMode: null, - traderCompanyName: null, - traderCompanySiret: null, - traderRecepisseNumber: null, + brokerCompanyName: bsvhu.brokerCompanyName, + brokerCompanySiret: bsvhu.brokerCompanySiret, + brokerRecepisseNumber: bsvhu.brokerRecepisseNumber, + traderCompanyName: bsvhu.traderCompanyName, + traderCompanySiret: bsvhu.traderCompanySiret, + traderRecepisseNumber: bsvhu.traderRecepisseNumber, ...getOperationData(bsvhu), weight: bsvhu.weightValue ? bsvhu.weightValue / 1000 : bsvhu.weightValue, ...getOperationData(bsvhu), @@ -279,12 +293,12 @@ export function toTransportedWaste( ...genericWaste, destinationReceptionDate: bsvhu.destinationReceptionDate, weight: bsvhu.weightValue ? bsvhu.weightValue / 1000 : bsvhu.weightValue, - traderCompanyName: null, - traderCompanySiret: null, - traderRecepisseNumber: null, - brokerCompanyName: null, - brokerCompanySiret: null, - brokerRecepisseNumber: null, + traderCompanyName: bsvhu.traderCompanyName, + traderCompanySiret: bsvhu.traderCompanySiret, + traderRecepisseNumber: bsvhu.traderRecepisseNumber, + brokerCompanyName: bsvhu.brokerCompanyName, + brokerCompanySiret: bsvhu.brokerCompanySiret, + brokerRecepisseNumber: bsvhu.brokerRecepisseNumber, emitterCompanyMail: bsvhu.emitterCompanyMail, ...getTransporterData(bsvhu, true) }; @@ -301,10 +315,10 @@ export function toManagedWaste(bsvhu: RegistryBsvhu): Required { // Make sure all possible keys are in the exported sheet so that no column is missing ...emptyManagedWaste, ...genericWaste, - traderCompanyName: null, - traderCompanySiret: null, - brokerCompanyName: null, - brokerCompanySiret: null, + traderCompanyName: bsvhu.traderCompanyName, + traderCompanySiret: bsvhu.traderCompanySiret, + brokerCompanyName: bsvhu.brokerCompanyName, + brokerCompanySiret: bsvhu.brokerCompanySiret, destinationPlannedOperationMode: null, emitterCompanyMail: bsvhu.emitterCompanyMail, ...getTransporterData(bsvhu) @@ -320,14 +334,14 @@ export function toAllWaste(bsvhu: RegistryBsvhu): Required { ...genericWaste, createdAt: bsvhu.createdAt, destinationReceptionDate: bsvhu.destinationReceptionDate, - brokerCompanyName: null, - brokerCompanySiret: null, - brokerRecepisseNumber: null, + brokerCompanyName: bsvhu.brokerCompanyName, + brokerCompanySiret: bsvhu.brokerCompanySiret, + brokerRecepisseNumber: bsvhu.brokerRecepisseNumber, + traderCompanyName: bsvhu.traderCompanyName, + traderCompanySiret: bsvhu.traderCompanySiret, + traderRecepisseNumber: bsvhu.traderRecepisseNumber, destinationPlannedOperationMode: null, weight: bsvhu.weightValue ? bsvhu.weightValue / 1000 : bsvhu.weightValue, - traderCompanyName: null, - traderCompanySiret: null, - traderRecepisseNumber: null, emitterCompanyMail: bsvhu.emitterCompanyMail, ...getOperationData(bsvhu), ...getTransporterData(bsvhu, true), diff --git a/back/src/bsvhu/repository/bsvhu/create.ts b/back/src/bsvhu/repository/bsvhu/create.ts index 96eb815a17..3719cd835c 100644 --- a/back/src/bsvhu/repository/bsvhu/create.ts +++ b/back/src/bsvhu/repository/bsvhu/create.ts @@ -5,6 +5,7 @@ import { } from "../../../common/repository/types"; import { enqueueCreatedBsdToIndex } from "../../../queue/producers/elastic"; import { bsvhuEventTypes } from "./eventTypes"; +import { getCanAccessDraftOrgIds } from "../../utils"; export type CreateBsvhuFn = ( data: Prisma.BsvhuCreateInput, logMetadata?: LogMetadata @@ -14,7 +15,12 @@ export function buildCreateBsvhu(deps: RepositoryFnDeps): CreateBsvhuFn { return async (data, logMetadata?) => { const { prisma, user } = deps; - const bsvhu = await prisma.bsvhu.create({ data }); + const bsvhu = await prisma.bsvhu.create({ + data, + include: { + intermediaries: true + } + }); await prisma.event.create({ data: { @@ -26,8 +32,23 @@ export function buildCreateBsvhu(deps: RepositoryFnDeps): CreateBsvhuFn { } }); - prisma.addAfterCommitCallback(() => enqueueCreatedBsdToIndex(bsvhu.id)); + // For drafts, only the owner's sirets that appear on the bsd have access + const canAccessDraftOrgIds = await getCanAccessDraftOrgIds(bsvhu, user.id); + + const updatedBsvhu = await prisma.bsvhu.update({ + where: { id: bsvhu.id }, + data: { + ...(canAccessDraftOrgIds.length ? { canAccessDraftOrgIds } : {}) + }, + include: { + intermediaries: true + } + }); + + prisma.addAfterCommitCallback(() => + enqueueCreatedBsdToIndex(updatedBsvhu.id) + ); - return bsvhu; + return updatedBsvhu; }; } diff --git a/back/src/bsvhu/resolvers/mutations/__tests__/createBsvhu.integration.ts b/back/src/bsvhu/resolvers/mutations/__tests__/createBsvhu.integration.ts index 3314fe114f..8826480b86 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 } @@ -110,7 +115,8 @@ describe("Mutation.Vhu.create", () => { it("should allow creating a valid form for the producer signature", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); const input = { @@ -171,7 +177,8 @@ describe("Mutation.Vhu.create", () => { it("should create a bsvhu and autocomplete transporter recepisse", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); const transporter = await companyFactory({ @@ -239,7 +246,8 @@ describe("Mutation.Vhu.create", () => { it("should create a bsvhu and ignore recepisse input", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); const transporter = await companyFactory({ @@ -311,10 +319,77 @@ 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"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] + }); + + 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({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); const intermediary = await companyFactory({ @@ -382,7 +457,8 @@ describe("Mutation.Vhu.create", () => { it("should fail if creating a bsvhu with the same intermediary several times", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); const intermediary = await companyFactory({ @@ -457,7 +533,8 @@ describe("Mutation.Vhu.create", () => { it("should fail if creating a bsvhu with more than 3 intermediaries", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); const intermediary1 = await companyFactory({ @@ -553,7 +630,8 @@ describe("Mutation.Vhu.create", () => { it("should fail if a required field like the recipient agrement is missing", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); const input = { @@ -619,7 +697,8 @@ describe("Mutation.Vhu.create", () => { } ); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); await transporterReceiptFactory({ @@ -687,7 +766,8 @@ describe("Mutation.Vhu.create", () => { } ); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); await transporterReceiptFactory({ @@ -752,7 +832,8 @@ describe("Mutation.Vhu.create", () => { } ); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] }); await transporterReceiptFactory({ diff --git a/back/src/bsvhu/resolvers/mutations/__tests__/duplicateBsvhu.integration.ts b/back/src/bsvhu/resolvers/mutations/__tests__/duplicateBsvhu.integration.ts index 832656319c..2ece1440b7 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"; @@ -78,11 +79,47 @@ describe("mutaion.duplicateBsvhu", () => { await prisma.transporterReceipt.findUniqueOrThrow({ where: { id: transporter.transporterReceiptId! } }); - const destination = await companyFactory(); + const destination = await companyFactory({ + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] + }); const intermediary = await companyFactory(); + const ecoOrganisme = await ecoOrganismeFactory({ + handle: { handleBsvhu: true }, + createAssociatedCompany: true + }); + + const broker = await companyFactory({ + companyTypes: ["BROKER"], + brokerReceipt: { + create: { + receiptNumber: "BROKER-RECEIPT-NUMBER", + validityLimit: TODAY.toISOString() as any, + department: "BROKER-RECEIPT-DEPARTMENT" + } + } + }); + const brokerReceipt = await prisma.brokerReceipt.findUniqueOrThrow({ + where: { id: broker.brokerReceiptId! } + }); + const trader = await companyFactory({ + companyTypes: ["TRADER"], + traderReceipt: { + create: { + receiptNumber: "TRADER-RECEIPT-NUMBER", + validityLimit: TODAY.toISOString() as any, + department: "TRADER-RECEIPT-DEPARTMENT" + } + } + }); + const traderReceipt = await prisma.traderReceipt.findUniqueOrThrow({ + where: { id: trader.traderReceiptId! } + }); + const bsvhu = await bsvhuFactory({ + userId: emitter.user.id, opt: { emitterIrregularSituation: false, emitterNoSiret: false, @@ -125,7 +162,27 @@ describe("mutaion.duplicateBsvhu", () => { } ] } - } + }, + ecoOrganismeSiret: ecoOrganisme.siret, + ecoOrganismeName: ecoOrganisme.name, + brokerCompanyName: broker.name, + brokerCompanySiret: broker.siret, + brokerCompanyAddress: broker.address, + brokerCompanyContact: broker.contact, + brokerCompanyPhone: broker.contactPhone, + brokerCompanyMail: broker.contactEmail, + brokerRecepisseNumber: brokerReceipt.receiptNumber, + brokerRecepisseDepartment: brokerReceipt.department, + brokerRecepisseValidityLimit: brokerReceipt.validityLimit, + traderCompanyName: trader.name, + traderCompanySiret: trader.siret, + traderCompanyAddress: trader.address, + traderCompanyContact: trader.contact, + traderCompanyPhone: trader.contactPhone, + traderCompanyMail: trader.contactEmail, + traderRecepisseNumber: traderReceipt.receiptNumber, + traderRecepisseDepartment: traderReceipt.department, + traderRecepisseValidityLimit: traderReceipt.validityLimit } }); const { mutate } = makeClient(emitter.user); @@ -195,6 +252,26 @@ describe("mutaion.duplicateBsvhu", () => { transporterCustomInfo, transporterTransportPlates, transporterRecepisseIsExempted, + ecoOrganismeSiret, + ecoOrganismeName, + brokerCompanyName, + brokerCompanySiret, + brokerCompanyAddress, + brokerCompanyContact, + brokerCompanyPhone, + brokerCompanyMail, + brokerRecepisseNumber, + brokerRecepisseDepartment, + brokerRecepisseValidityLimit, + traderCompanyName, + traderCompanySiret, + traderCompanyAddress, + traderCompanyContact, + traderCompanyPhone, + traderCompanyMail, + traderRecepisseNumber, + traderRecepisseDepartment, + traderRecepisseValidityLimit, ...rest } = bsvhu; @@ -225,7 +302,8 @@ describe("mutaion.duplicateBsvhu", () => { "destinationOperationSignatureDate", "intermediaries", - "intermediariesOrgIds" + "intermediariesOrgIds", + "canAccessDraftOrgIds" ]; expect(duplicatedBsvhu.status).toEqual("INITIAL"); @@ -283,7 +361,27 @@ describe("mutaion.duplicateBsvhu", () => { transporterTransportTakenOverAt, transporterCustomInfo, transporterTransportPlates, - transporterRecepisseIsExempted + transporterRecepisseIsExempted, + ecoOrganismeSiret, + ecoOrganismeName, + brokerCompanyName, + brokerCompanySiret, + brokerCompanyAddress, + brokerCompanyContact, + brokerCompanyPhone, + brokerCompanyMail, + brokerRecepisseNumber, + brokerRecepisseDepartment, + brokerRecepisseValidityLimit, + traderCompanyName, + traderCompanySiret, + traderCompanyAddress, + traderCompanyContact, + traderCompanyPhone, + traderCompanyMail, + traderRecepisseNumber, + traderRecepisseDepartment, + traderRecepisseValidityLimit }); // make sure this test breaks when a new field is added to the Bsvhu model @@ -374,6 +472,8 @@ describe("mutaion.duplicateBsvhu", () => { where: { id: transporterCompany.transporterReceiptId! } }); const destinationCompany = await companyFactory({ + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"], vhuAgrementDemolisseur: { create: { agrementNumber: "UPDATED-AGREEMENT-NUMBER", @@ -558,6 +658,8 @@ describe("mutaion.duplicateBsvhu", () => { where: { id: transporterCompany.transporterReceiptId! } }); const destinationCompany = await companyFactory({ + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"], vhuAgrementDemolisseur: { create: { agrementNumber: "UPDATED-AGREEMENT-NUMBER", diff --git a/back/src/bsvhu/resolvers/mutations/__tests__/publishBsvhu.integration.ts b/back/src/bsvhu/resolvers/mutations/__tests__/publishBsvhu.integration.ts index 5c6de34445..4f4f773be0 100644 --- a/back/src/bsvhu/resolvers/mutations/__tests__/publishBsvhu.integration.ts +++ b/back/src/bsvhu/resolvers/mutations/__tests__/publishBsvhu.integration.ts @@ -63,6 +63,7 @@ describe("Mutation.Vhu.publish", () => { it("should pass the form as non draft", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const form = await bsvhuFactory({ + userId: user.id, opt: { emitterCompanySiret: company.siret, isDraft: true @@ -79,4 +80,40 @@ describe("Mutation.Vhu.publish", () => { expect(data.publishBsvhu.isDraft).toBe(false); }); + + it("should fail if the user is not part a the creator's companies", async () => { + const { user, company } = await userWithCompanyFactory("MEMBER"); + const { company: company2, user: user2 } = await userWithCompanyFactory( + "ADMIN", + { + companyTypes: ["TRANSPORTER"] + } + ); + const form = await bsvhuFactory({ + userId: user.id, + opt: { + emitterCompanySiret: company.siret, + transporterCompanySiret: company2.siret, + isDraft: true + } + }); + + const { mutate } = makeClient(user2); + const { errors } = await mutate>( + PUBLISH_VHU_FORM, + { + variables: { id: form.id } + } + ); + + expect(errors).toEqual([ + expect.objectContaining({ + message: + "Vous ne pouvez pas modifier un bordereau sur lequel votre entreprise n'apparait pas", + extensions: expect.objectContaining({ + code: ErrorCode.FORBIDDEN + }) + }) + ]); + }); }); diff --git a/back/src/bsvhu/resolvers/mutations/__tests__/signBsvhu.integration.ts b/back/src/bsvhu/resolvers/mutations/__tests__/signBsvhu.integration.ts index b411ef4620..7d7667269e 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,229 @@ 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", { + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] + }); + 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"); diff --git a/back/src/bsvhu/resolvers/mutations/__tests__/updateBsvhu.integration.ts b/back/src/bsvhu/resolvers/mutations/__tests__/updateBsvhu.integration.ts index 489ca915f0..e5ad991543 100644 --- a/back/src/bsvhu/resolvers/mutations/__tests__/updateBsvhu.integration.ts +++ b/back/src/bsvhu/resolvers/mutations/__tests__/updateBsvhu.integration.ts @@ -10,6 +10,7 @@ import { import makeClient from "../../../../__tests__/testClient"; import { Mutation } from "../../../../generated/graphql/types"; import { UserRole } from "@prisma/client"; +import { prisma } from "@td/prisma"; import gql from "graphql-tag"; const UPDATE_VHU_FORM = gql` @@ -112,6 +113,73 @@ describe("Mutation.Vhu.update", () => { ]); }); + it("should allow user to update a draft BSVHU", async () => { + const { company, user } = await userWithCompanyFactory(UserRole.ADMIN); + const bsvhu = await bsvhuFactory({ + userId: user.id, + opt: { + isDraft: true, + status: "INITIAL", + emitterCompanySiret: company.siret + } + }); + const { mutate } = makeClient(user); + const input = { + quantity: 4 + }; + const { errors } = await mutate>( + UPDATE_VHU_FORM, + { + variables: { id: bsvhu.id, input } + } + ); + expect(errors).toBeUndefined(); + const updatedBsvhu = await prisma.bsvhu.findFirstOrThrow({ + where: { id: bsvhu.id } + }); + expect(updatedBsvhu.quantity).toEqual(4); + }); + + it("should disallow user who isn't part of the creator's companies to update a draft BSVHU", async () => { + const { company, user } = await userWithCompanyFactory(UserRole.ADMIN); + const { company: company2, user: user2 } = await userWithCompanyFactory( + UserRole.ADMIN, + { + companyTypes: ["TRANSPORTER"] + } + ); + const bsvhu = await bsvhuFactory({ + userId: user.id, + opt: { + isDraft: true, + status: "INITIAL", + emitterCompanySiret: company.siret, + transporterCompanySiret: company2.siret + } + }); + const { mutate } = makeClient(user2); + const input = { + weight: { + value: 4 + } + }; + const { errors } = await mutate>( + UPDATE_VHU_FORM, + { + variables: { id: bsvhu.id, input } + } + ); + expect(errors).toEqual([ + expect.objectContaining({ + message: + "Vous ne pouvez pas modifier un bordereau sur lequel votre entreprise n'apparait pas", + extensions: expect.objectContaining({ + code: ErrorCode.FORBIDDEN + }) + }) + ]); + }); + it("should be possible to update a non signed form", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); const form = await bsvhuFactory({ @@ -132,7 +200,6 @@ describe("Mutation.Vhu.update", () => { variables: { id: form.id, input } } ); - expect(data.updateBsvhu.weight!.value).toBe(4); }); diff --git a/back/src/bsvhu/resolvers/mutations/duplicate.ts b/back/src/bsvhu/resolvers/mutations/duplicate.ts index e64fdc6de3..c5906b99f3 100644 --- a/back/src/bsvhu/resolvers/mutations/duplicate.ts +++ b/back/src/bsvhu/resolvers/mutations/duplicate.ts @@ -65,7 +65,8 @@ async function getDuplicateData( user }); - const { emitter, transporter, destination } = await getBsvhuCompanies(bsvhu); + const { emitter, transporter, destination, broker, trader } = + await getBsvhuCompanies(bsvhu); let data: Prisma.BsvhuCreateInput = { ...parsedBsvhu, @@ -90,7 +91,31 @@ async function getDuplicateData( transporter?.contactPhone ?? parsedBsvhu.transporterCompanyPhone, transporterCompanyMail: transporter?.contactEmail ?? parsedBsvhu.transporterCompanyMail, - transporterCompanyVatNumber: parsedBsvhu.transporterCompanyVatNumber + transporterCompanyVatNumber: parsedBsvhu.transporterCompanyVatNumber, + // Broker company info + brokerCompanyMail: broker?.contactEmail ?? bsvhu.brokerCompanyMail, + brokerCompanyPhone: broker?.contactPhone ?? bsvhu.brokerCompanyPhone, + brokerCompanyContact: broker?.contact ?? bsvhu.brokerCompanyContact, + // Broker recepisse + brokerRecepisseNumber: + broker?.brokerReceipt?.receiptNumber ?? bsvhu.brokerRecepisseNumber, + brokerRecepisseValidityLimit: + broker?.brokerReceipt?.validityLimit ?? + bsvhu.brokerRecepisseValidityLimit, + brokerRecepisseDepartment: + broker?.brokerReceipt?.department ?? bsvhu.brokerRecepisseDepartment, + // Trader company info + traderCompanyMail: trader?.contactEmail ?? bsvhu.traderCompanyMail, + traderCompanyPhone: trader?.contactPhone ?? bsvhu.traderCompanyPhone, + traderCompanyContact: trader?.contact ?? bsvhu.traderCompanyContact, + // Trader recepisse + traderRecepisseNumber: + trader?.traderReceipt?.receiptNumber ?? bsvhu.traderRecepisseNumber, + traderRecepisseValidityLimit: + trader?.traderReceipt?.validityLimit ?? + bsvhu.traderRecepisseValidityLimit, + traderRecepisseDepartment: + trader?.traderReceipt?.department ?? bsvhu.traderRecepisseDepartment }; if (intermediaries) { data = { @@ -119,14 +144,18 @@ async function getBsvhuCompanies(bsvhu: PrismaBsvhuForParsing) { bsvhu.emitterCompanySiret, bsvhu.transporterCompanySiret, bsvhu.transporterCompanyVatNumber, - bsvhu.destinationCompanySiret + bsvhu.destinationCompanySiret, + bsvhu.brokerCompanySiret, + bsvhu.traderCompanySiret ].filter(Boolean); // Batch fetch all companies involved in the BSVHU const companies = await prisma.company.findMany({ where: { orgId: { in: companiesOrgIds } }, include: { - transporterReceipt: true + transporterReceipt: true, + brokerReceipt: true, + traderReceipt: true } }); @@ -144,5 +173,13 @@ async function getBsvhuCompanies(bsvhu: PrismaBsvhuForParsing) { company.orgId === bsvhu.transporterCompanyVatNumber ); - return { emitter, destination, transporter }; + const broker = companies.find( + company => company.orgId === bsvhu.brokerCompanySiret + ); + + const trader = companies.find( + company => company.orgId === bsvhu.traderCompanySiret + ); + + return { emitter, destination, transporter, broker, trader }; } diff --git a/back/src/bsvhu/resolvers/queries/__tests__/bsvhu.integration.ts b/back/src/bsvhu/resolvers/queries/__tests__/bsvhu.integration.ts index 9a4c4fe2d3..b212f84e46 100644 --- a/back/src/bsvhu/resolvers/queries/__tests__/bsvhu.integration.ts +++ b/back/src/bsvhu/resolvers/queries/__tests__/bsvhu.integration.ts @@ -40,6 +40,26 @@ query GetBsvhu($id: ID!) { number } } + ecoOrganisme { + name + siret + } + broker { + company { + siret + } + recepisse { + number + } + } + trader { + company { + siret + } + recepisse { + number + } + } 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..af75937bd2 100644 --- a/back/src/bsvhu/resolvers/queries/__tests__/bsvhumetadata.integration.ts +++ b/back/src/bsvhu/resolvers/queries/__tests__/bsvhumetadata.integration.ts @@ -115,7 +115,7 @@ describe("Query.Bsvhu", () => { variables: { id: bsd.id } }); - expect(data.bsvhu.metadata?.fields?.sealed?.length).toBe(21); + expect(data.bsvhu.metadata?.fields?.sealed?.length).toBe(24); }); it("should return TRANSPORTER signed bsvhu sealed fields", async () => { @@ -139,7 +139,7 @@ describe("Query.Bsvhu", () => { variables: { id: bsd.id } }); - expect(data.bsvhu.metadata?.fields?.sealed?.length).toBe(34); + expect(data.bsvhu.metadata?.fields?.sealed?.length).toBe(37); }); it("should return OPERATION signed bsvhu sealed fields", async () => { @@ -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(78); }); }); diff --git a/back/src/bsvhu/resolvers/queries/__tests__/bsvhus.integration.ts b/back/src/bsvhu/resolvers/queries/__tests__/bsvhus.integration.ts index 765022524b..5734ffd9a9 100644 --- a/back/src/bsvhu/resolvers/queries/__tests__/bsvhus.integration.ts +++ b/back/src/bsvhu/resolvers/queries/__tests__/bsvhus.integration.ts @@ -50,6 +50,22 @@ const GET_BSVHUS = ` number } } + broker { + company { + siret + } + recepisse { + number + } + } + trader { + company { + siret + } + recepisse { + number + } + } weight { value } diff --git a/back/src/bsvhu/resolvers/queries/bsvhus.ts b/back/src/bsvhu/resolvers/queries/bsvhus.ts index debc266ec8..a0c0911db1 100644 --- a/back/src/bsvhu/resolvers/queries/bsvhus.ts +++ b/back/src/bsvhu/resolvers/queries/bsvhus.ts @@ -8,6 +8,7 @@ import { getReadonlyBsvhuRepository } from "../../repository"; import { toPrismaWhereInput } from "../../where"; import { Permission, can, getUserRoles } from "../../../permissions"; +import { Prisma } from "@prisma/client"; export default async function bsvhus( _, @@ -27,16 +28,32 @@ export default async function bsvhus( { transporterCompanySiret: { in: orgIdsWithListPermission } }, { transporterCompanyVatNumber: { in: orgIdsWithListPermission } }, { destinationCompanySiret: { in: orgIdsWithListPermission } }, + { brokerCompanySiret: { in: orgIdsWithListPermission } }, + { traderCompanySiret: { in: orgIdsWithListPermission } }, { intermediariesOrgIds: { hasSome: orgIdsWithListPermission } } ] }; + const draftMask: Prisma.BsvhuWhereInput = { + OR: [ + { + isDraft: false, + ...mask + }, + { + isDraft: true, + canAccessDraftOrgIds: { hasSome: orgIdsWithListPermission }, + ...mask + } + ] + }; + const prismaWhere = { ...(whereArgs ? toPrismaWhereInput(whereArgs) : {}), isDeleted: false }; - const where = applyMask(prismaWhere, mask); + const where = applyMask(prismaWhere, draftMask); const bsvhuRepository = getReadonlyBsvhuRepository(); const totalCount = await bsvhuRepository.count(where); diff --git a/back/src/bsvhu/typeDefs/bsvhu.inputs.graphql b/back/src/bsvhu/typeDefs/bsvhu.inputs.graphql index 172c0b01ff..1457a9f1d6 100644 --- a/back/src/bsvhu/typeDefs/bsvhu.inputs.graphql +++ b/back/src/bsvhu/typeDefs/bsvhu.inputs.graphql @@ -16,6 +16,10 @@ input BsvhuWhere { transporter: BsvhuTransporterWhere "Filtre sur le champ destination." destination: BsvhuDestinationWhere + "Filtre sur le champ broker." + broker: BsvhuBrokerWhere + "Filtre sur le champ négociant." + trader: BsvhuTraderWhere "ET logique" _and: [BsvhuWhere!] "OU logique" @@ -59,6 +63,16 @@ input BsvhuDestinationWhere { operation: BsvhuOperationWhere } +"Champs possible pour le filtre sur le courtier." +input BsvhuBrokerWhere { + company: CompanyWhere +} + +"Champs possible pour le filtre sur le négociant." +input BsvhuTraderWhere { + company: CompanyWhere +} + "Champs possibles pour le filtre sur la réception" input BsvhuReceptionWhere { date: DateFilter @@ -89,6 +103,13 @@ input BsvhuInput { destination: BsvhuDestinationInput "Détails sur le transporteur" transporter: BsvhuTransporterInput + + "Courtier" + broker: BsvhuBrokerInput + + "Négociant" + trader: BsvhuTraderInput + """ Liste d'entreprises intermédiaires. Un intermédiaire est une entreprise qui prend part à la gestion du déchet, mais pas à la responsabilité de la traçabilité. Il pourra lire ce bordereau, sans étape de signature. @@ -96,6 +117,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 +133,11 @@ input BsvhuEmitterInput { company: BsvhuCompanyInput } +input BsvhuEcoOrganismeInput { + name: String! + siret: String! +} + "Extension de CompanyInput ajoutant des champs d'adresse séparés" input BsvhuCompanyInput { """ @@ -232,6 +261,20 @@ input BsvhuTransporterInput { transport: BsvhuTransportInput } +input BsvhuBrokerInput { + "Coordonnées de l'entreprise courtier" + company: CompanyInput + "Récépissé courtier" + recepisse: BsvhuRecepisseInput +} + +input BsvhuTraderInput { + "Coordonnées de l'entreprise de négoce" + company: CompanyInput + "Récépissé courtier" + recepisse: BsvhuRecepisseInput +} + input BsvhuTransportInput { "Date de prise en charge" takenOverAt: DateTime diff --git a/back/src/bsvhu/typeDefs/bsvhu.objects.graphql b/back/src/bsvhu/typeDefs/bsvhu.objects.graphql index aef6bc68d6..ad5379216c 100644 --- a/back/src/bsvhu/typeDefs/bsvhu.objects.graphql +++ b/back/src/bsvhu/typeDefs/bsvhu.objects.graphql @@ -62,6 +62,15 @@ type Bsvhu { """ intermediaries: [FormCompany!] + "Eco-organisme" + ecoOrganisme: BsvhuEcoOrganisme + + "Courtier" + broker: BsvhuBroker + + "Négociant" + trader: BsvhuTrader + metadata: BsvhuMetadata! } @@ -82,6 +91,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 @@ -100,11 +115,28 @@ type BsvhuTransport { type BsvhuRecepisse { "Exemption de récépissé" isExempted: Boolean + "Numéro de récépissé" number: String + "Département" department: String + "Date limite de validité" validityLimit: DateTime } +type BsvhuBroker { + "Coordonnées de l'entreprise courtier" + company: FormCompany + "Récépissé courtier" + recepisse: BsvhuRecepisse +} + +type BsvhuTrader { + "Coordonnées de l'entreprise de négoce" + company: FormCompany + "Récépissé négociant" + recepisse: BsvhuRecepisse +} + type BsvhuDestination { "Type de receveur: broyeur ou second centre VHU" type: BsvhuDestinationType diff --git a/back/src/bsvhu/utils.ts b/back/src/bsvhu/utils.ts index f5c23c4e59..b0d23729e1 100644 --- a/back/src/bsvhu/utils.ts +++ b/back/src/bsvhu/utils.ts @@ -1,3 +1,6 @@ +import { getUserCompanies } from "../users/database"; +import { BsvhuWithIntermediaries } from "./types"; + export function getWasteDescription(wasteCode: string | null) { return wasteCode === "16 01 06" ? "Véhicules hors d'usage ne contenant ni liquides ni autres composants dangereux" @@ -5,3 +8,37 @@ export function getWasteDescription(wasteCode: string | null) { ? "Véhicules hors d’usage non dépollués par un centre agréé" : ""; } + +export const getCanAccessDraftOrgIds = async ( + bsvhu: BsvhuWithIntermediaries, + userId: string +): Promise => { + const intermediariesOrgIds: string[] = bsvhu.intermediaries + ? bsvhu.intermediaries + .flatMap(intermediary => [intermediary.siret, intermediary.vatNumber]) + .filter(Boolean) + : []; + + const canAccessDraftOrgIds: string[] = []; + if (bsvhu.isDraft) { + const userCompanies = await getUserCompanies(userId); + const userOrgIds = userCompanies.map(company => company.orgId); + const bsvhuOrgIds = [ + ...intermediariesOrgIds, + bsvhu.emitterCompanySiret, + ...[ + bsvhu.transporterCompanySiret, + bsvhu.transporterCompanyVatNumber + ].filter(Boolean), + bsvhu.ecoOrganismeSiret, + bsvhu.destinationCompanySiret, + bsvhu.traderCompanySiret, + bsvhu.brokerCompanySiret + ].filter(Boolean); + const userOrgIdsInForm = userOrgIds.filter(orgId => + bsvhuOrgIds.includes(orgId) + ); + canAccessDraftOrgIds.push(...userOrgIdsInForm); + } + return canAccessDraftOrgIds; +}; diff --git a/back/src/bsvhu/validation/__tests__/validation.integration.ts b/back/src/bsvhu/validation/__tests__/validation.integration.ts index e8a7ca65b8..60388ff670 100644 --- a/back/src/bsvhu/validation/__tests__/validation.integration.ts +++ b/back/src/bsvhu/validation/__tests__/validation.integration.ts @@ -1,7 +1,9 @@ -import { Company, OperationMode } from "@prisma/client"; +import { Company, EcoOrganisme, OperationMode } from "@prisma/client"; import { resetDatabase } from "../../../../integration-tests/helper"; import { companyFactory, + ecoOrganismeFactory, + intermediaryReceiptFactory, transporterReceiptFactory } from "../../../__tests__/factories"; import { CompanySearchResult } from "../../../companies/types"; @@ -16,6 +18,7 @@ import { BsvhuValidationContext } from "../types"; import { getCurrentSignatureType, prismaToZodBsvhu } from "../helpers"; import { parseBsvhu, parseBsvhuAsync } from ".."; import { ZodError } from "zod"; +import { CompanyRole } from "../../../common/validation/zod/schema"; const searchResult = (companyName: string) => { return { @@ -35,13 +38,21 @@ describe("BSVHU validation", () => { let foreignTransporter: Company; let transporterCompany: Company; let intermediaryCompany: Company; + let ecoOrganisme: EcoOrganisme; + let brokerCompany: Company; + let traderCompany: Company; beforeAll(async () => { const emitterCompany = await companyFactory({ companyTypes: ["PRODUCER"] }); transporterCompany = await companyFactory({ companyTypes: ["TRANSPORTER"] }); const destinationCompany = await companyFactory({ - companyTypes: ["WASTE_VEHICLES"] + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR", "DEMOLISSEUR"] + }); + const nextDestinationCompany = await companyFactory({ + companyTypes: ["WASTE_VEHICLES"], + wasteVehiclesTypes: ["BROYEUR"] }); foreignTransporter = await companyFactory({ companyTypes: ["TRANSPORTER"], @@ -51,12 +62,26 @@ describe("BSVHU validation", () => { intermediaryCompany = await companyFactory({ companyTypes: ["INTERMEDIARY"] }); - + ecoOrganisme = await ecoOrganismeFactory({ + handle: { handleBsvhu: true }, + createAssociatedCompany: true + }); + brokerCompany = await companyFactory({ + companyTypes: ["BROKER"] + }); + traderCompany = await companyFactory({ + companyTypes: ["TRADER"] + }); const prismaBsvhu = await bsvhuFactory({ opt: { emitterCompanySiret: emitterCompany.siret, transporterCompanySiret: transporterCompany.siret, destinationCompanySiret: destinationCompany.siret, + destinationOperationNextDestinationCompanySiret: + nextDestinationCompany.siret, + ecoOrganismeSiret: ecoOrganisme.siret, + brokerCompanySiret: brokerCompany.siret, + traderCompanySiret: traderCompany.siret, intermediaries: { create: [toIntermediaryCompany(intermediaryCompany)] } @@ -347,9 +372,30 @@ describe("BSVHU validation", () => { expect((err as ZodError).issues).toEqual([ expect.objectContaining({ message: - `L'installation de destination avec le SIRET \"${company.siret}\" n'est pas inscrite` + - " sur Trackdéchets en tant qu'installation de traitement de VHU. Cette installation ne peut donc pas" + - " être visée sur le bordereau. Veuillez vous rapprocher de l'administrateur de cette installation pour qu'il modifie le profil de l'établissement depuis l'interface Trackdéchets dans Mes établissements" + "Cet établissement n'a pas le profil Installation de traitement de VHU." + }) + ]); + } + }); + + test("when next destination is registered with wrong profile", async () => { + const company = await companyFactory({ companyTypes: ["PRODUCER"] }); + const data: ZodBsvhu = { + ...bsvhu, + destinationOperationNextDestinationCompanySiret: company.siret + }; + expect.assertions(1); + + try { + await parseBsvhuAsync(data, { + ...context, + currentSignatureType: "TRANSPORT" + }); + } catch (err) { + expect((err as ZodError).issues).toEqual([ + expect.objectContaining({ + message: + "Cet établissement n'a pas le profil Installation de traitement de VHU." }) ]); } @@ -408,6 +454,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 +647,10 @@ 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"), + [brokerCompany.siret!]: searchResult("broker"), + [traderCompany.siret!]: searchResult("trader") }; (searchCompany as jest.Mock).mockImplementation((clue: string) => { return Promise.resolve(searchResults[clue]); @@ -611,6 +684,15 @@ describe("BSVHU validation", () => { expect(sirenified.intermediaries![0].address).toEqual( searchResults[intermediaryCompany.siret!].address ); + expect(sirenified.ecoOrganismeName).toEqual( + searchResults[ecoOrganisme.siret!].name + ); + expect(sirenified.brokerCompanyName).toEqual( + searchResults[brokerCompany.siret!].name + ); + expect(sirenified.traderCompanyName).toEqual( + searchResults[traderCompany.siret!].name + ); }); it("should not overwrite `name` and `address` based on SIRENE data for sealed fields", async () => { const searchResults = { @@ -645,10 +727,10 @@ describe("BSVHU validation", () => { bsvhu.transporterCompanyAddress ); expect(sirenified.destinationCompanyName).toEqual( - searchResults[bsvhu.destinationCompanySiret!].name + bsvhu.destinationCompanyName ); expect(sirenified.destinationCompanyAddress).toEqual( - searchResults[bsvhu.destinationCompanySiret!].address + bsvhu.destinationCompanyAddress ); expect(sirenified.intermediaries![0].name).toEqual( bsvhu.intermediaries![0].name @@ -660,7 +742,7 @@ describe("BSVHU validation", () => { }); describe("BSVHU Recipify Module", () => { - it("recipify should correctly process input and return completedInput with transporter receipt", async () => { + it("recipify should correctly process transporter and return completedInput with transporter receipt", async () => { const receipt = await transporterReceiptFactory({ company: transporterCompany }); @@ -694,6 +776,41 @@ describe("BSVHU validation", () => { }) ); }); + + it("recipify should correctly process broker and trader", async () => { + const brokerReceipt = await intermediaryReceiptFactory({ + role: CompanyRole.Broker, + company: brokerCompany + }); + const traderReceipt = await intermediaryReceiptFactory({ + role: CompanyRole.Trader, + company: traderCompany + }); + const recipified = await parseBsvhuAsync( + { + ...bsvhu, + brokerRecepisseNumber: null, + brokerRecepisseDepartment: null, + brokerRecepisseValidityLimit: null, + traderRecepisseNumber: null, + traderRecepisseDepartment: null, + traderRecepisseValidityLimit: null + }, + { + ...context + } + ); + expect(recipified).toEqual( + expect.objectContaining({ + brokerRecepisseNumber: brokerReceipt.receiptNumber, + brokerRecepisseDepartment: brokerReceipt.department, + brokerRecepisseValidityLimit: brokerReceipt.validityLimit, + traderRecepisseNumber: traderReceipt.receiptNumber, + traderRecepisseDepartment: traderReceipt.department, + traderRecepisseValidityLimit: traderReceipt.validityLimit + }) + ); + }); }); describe("getCurrentSignatureType", () => { diff --git a/back/src/bsvhu/validation/helpers.ts b/back/src/bsvhu/validation/helpers.ts index c641503d78..761a3357ce 100644 --- a/back/src/bsvhu/validation/helpers.ts +++ b/back/src/bsvhu/validation/helpers.ts @@ -113,7 +113,16 @@ 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), + isBroker: + bsvhu.brokerCompanySiret != null && + orgIds.includes(bsvhu.brokerCompanySiret), + isTrader: + bsvhu.traderCompanySiret != null && + orgIds.includes(bsvhu.traderCompanySiret) }; } diff --git a/back/src/bsvhu/validation/recipify.ts b/back/src/bsvhu/validation/recipify.ts new file mode 100644 index 0000000000..e411c7c106 --- /dev/null +++ b/back/src/bsvhu/validation/recipify.ts @@ -0,0 +1,79 @@ +import { getTransporterCompanyOrgId } from "@td/constants"; +import { ParsedZodBsvhu } from "./schema"; +import { CompanyRole } from "../../common/validation/zod/schema"; +import { + buildRecipify, + RecipifyInputAccessor +} from "../../common/validation/recipify"; + +const recipifyBsvhuAccessors = ( + bsd: ParsedZodBsvhu, + // Tranformations should not be run on sealed fields + sealedFields: string[] +): RecipifyInputAccessor[] => [ + { + role: CompanyRole.Transporter, + skip: sealedFields.includes("transporterRecepisseNumber"), + orgIdGetter: () => { + const orgId = getTransporterCompanyOrgId({ + transporterCompanySiret: bsd.transporterCompanySiret ?? null, + transporterCompanyVatNumber: bsd.transporterCompanyVatNumber ?? null + }); + return orgId ?? null; + }, + setter: async (bsvhu: ParsedZodBsvhu, receipt) => { + if (bsvhu.transporterRecepisseIsExempted) { + bsvhu.transporterRecepisseNumber = null; + bsvhu.transporterRecepisseValidityLimit = null; + bsvhu.transporterRecepisseDepartment = null; + } else { + bsvhu.transporterRecepisseNumber = receipt?.receiptNumber ?? null; + bsvhu.transporterRecepisseValidityLimit = + receipt?.validityLimit ?? null; + bsvhu.transporterRecepisseDepartment = receipt?.department ?? null; + } + } + }, + { + role: CompanyRole.Broker, + skip: sealedFields.includes("brokerRecepisseNumber"), + orgIdGetter: () => { + return bsd.brokerCompanySiret ?? null; + }, + setter: async (bsvhu: ParsedZodBsvhu, receipt) => { + // don't overwrite user input because there are still those inputs in BSVHU forms + if (!bsvhu.brokerRecepisseNumber && receipt?.receiptNumber) { + bsvhu.brokerRecepisseNumber = receipt.receiptNumber; + } + if (!bsvhu.brokerRecepisseValidityLimit && receipt?.validityLimit) { + bsvhu.brokerRecepisseValidityLimit = receipt.validityLimit; + } + if (!bsvhu.brokerRecepisseDepartment && receipt?.department) { + bsvhu.brokerRecepisseDepartment = receipt.department; + } + } + }, + { + role: CompanyRole.Trader, + skip: sealedFields.includes("traderRecepisseNumber"), + orgIdGetter: () => { + return bsd.traderCompanySiret ?? null; + }, + setter: async (bsvhu: ParsedZodBsvhu, receipt) => { + // don't overwrite user input because there are still those inputs in BSVHU forms + if (!bsvhu.traderRecepisseNumber && receipt?.receiptNumber) { + bsvhu.traderRecepisseNumber = receipt.receiptNumber; + } + if (!bsvhu.traderRecepisseValidityLimit && receipt?.validityLimit) { + bsvhu.traderRecepisseValidityLimit = receipt.validityLimit; + } + if (!bsvhu.traderRecepisseDepartment && receipt?.department) { + bsvhu.traderRecepisseDepartment = receipt.department; + } + } + } +]; + +export const recipifyBsvhu = buildRecipify( + recipifyBsvhuAccessors +); diff --git a/back/src/bsvhu/validation/refinements.ts b/back/src/bsvhu/validation/refinements.ts index 25fa4d2cd8..53b21ce7e4 100644 --- a/back/src/bsvhu/validation/refinements.ts +++ b/back/src/bsvhu/validation/refinements.ts @@ -10,15 +10,19 @@ 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, + isBrokerRefinement, isDestinationRefinement, - isNotDormantRefinement, + isEcoOrganismeRefinement, + isEmitterNotDormantRefinement, isRegisteredVatNumberRefinement, + isTraderRefinement, isTransporterRefinement } from "../../common/validation/zod/refinement"; import { EditionRule } from "./rules"; +import { CompanyRole } from "../../common/validation/zod/schema"; // Date de la MAJ 2024.07.2 introduisant un changement // des règles de validations sur les poids et volume qui doivent @@ -33,11 +37,17 @@ export const checkCompanies: Refinement = async ( bsvhu, zodContext ) => { - await isNotDormantRefinement(bsvhu.emitterCompanySiret, zodContext); + await isEmitterNotDormantRefinement(bsvhu.emitterCompanySiret, zodContext); await isDestinationRefinement( bsvhu.destinationCompanySiret, zodContext, - "WASTE_VEHICLES" + bsvhu.destinationType ?? "WASTE_VEHICLES" + ); + await isDestinationRefinement( + bsvhu.destinationOperationNextDestinationCompanySiret, + zodContext, + "BROYEUR", + CompanyRole.DestinationOperationNextDestination ); await isTransporterRefinement( { @@ -51,6 +61,13 @@ export const checkCompanies: Refinement = async ( bsvhu.transporterCompanyVatNumber, zodContext ); + await isEcoOrganismeRefinement( + bsvhu.ecoOrganismeSiret, + BsdType.BSVHU, + zodContext + ); + await isBrokerRefinement(bsvhu.brokerCompanySiret, zodContext); + await isTraderRefinement(bsvhu.traderCompanySiret, zodContext); }; export const checkWeights: Refinement = ( @@ -69,6 +86,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 +115,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 +129,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 +145,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/bsvhu/validation/rules.ts b/back/src/bsvhu/validation/rules.ts index 1b6e5bafde..ec1f4742c6 100644 --- a/back/src/bsvhu/validation/rules.ts +++ b/back/src/bsvhu/validation/rules.ts @@ -197,19 +197,19 @@ export const bsvhuEditionRules: BsvhuEditionRules = { path: ["destination", "agrementNumber"] }, destinationCompanyName: { - sealed: { from: "OPERATION" }, + sealed: { from: sealedFromEmissionExceptForEmitter }, required: { from: "EMISSION" }, readableFieldName: "La raison sociale du destinataire", path: ["destination", "company", "name"] }, destinationCompanySiret: { - sealed: { from: "OPERATION" }, + sealed: { from: sealedFromEmissionExceptForEmitter }, required: { from: "EMISSION" }, readableFieldName: "Le N° SIRET du destinataire", path: ["destination", "company", "siret"] }, destinationCompanyAddress: { - sealed: { from: "OPERATION" }, + sealed: { from: sealedFromEmissionExceptForEmitter }, required: { from: "EMISSION" }, readableFieldName: "L'adresse du destinataire", path: ["destination", "company", "address"] @@ -381,6 +381,7 @@ export const bsvhuEditionRules: BsvhuEditionRules = { }, identificationNumbers: { sealed: { from: sealedFromEmissionExceptForEmitter }, + required: { from: "EMISSION" }, readableFieldName: "Les numéros d'identification", path: ["identification", "numbers"] }, @@ -511,6 +512,164 @@ 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"] + }, + brokerCompanyName: { + readableFieldName: "le nom du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "company", "name"] + }, + brokerCompanySiret: { + readableFieldName: "le SIRET du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "company", "siret"] + }, + brokerCompanyAddress: { + readableFieldName: "l'adresse du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "company", "address"] + }, + brokerCompanyContact: { + readableFieldName: "le nom de contact du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "company", "contact"] + }, + brokerCompanyPhone: { + readableFieldName: "le téléphone du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "company", "phone"] + }, + brokerCompanyMail: { + readableFieldName: "le mail du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "company", "mail"] + }, + brokerRecepisseNumber: { + readableFieldName: "le numéro de récépissé du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "recepisse", "number"] + }, + brokerRecepisseDepartment: { + readableFieldName: "le département du récépissé du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "recepisse", "department"] + }, + brokerRecepisseValidityLimit: { + readableFieldName: "la date de validité du récépissé du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.brokerCompanySiret + }, + path: ["broker", "recepisse", "validityLimit"] + }, + traderCompanyName: { + readableFieldName: "le nom du négociant", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "company", "name"] + }, + traderCompanySiret: { + readableFieldName: "le SIRET du négociant", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "company", "siret"] + }, + traderCompanyAddress: { + readableFieldName: "l'adresse du négociant", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "company", "address"] + }, + traderCompanyContact: { + readableFieldName: "le nom de contact du négociant", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "company", "contact"] + }, + traderCompanyPhone: { + readableFieldName: "le téléphone du négociant", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "company", "phone"] + }, + traderCompanyMail: { + readableFieldName: "le mail du négociant", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "company", "mail"] + }, + traderRecepisseNumber: { + readableFieldName: "le numéro de récépissé du négociant", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "recepisse", "number"] + }, + traderRecepisseDepartment: { + readableFieldName: "le département du récépissé du négociant", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "recepisse", "department"] + }, + traderRecepisseValidityLimit: { + readableFieldName: "la date de validité du récépissé du courtier", + sealed: { + from: "OPERATION", + when: bsvhu => !!bsvhu.traderCompanySiret + }, + path: ["trader", "recepisse", "validityLimit"] + }, 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..e0aaafae72 100644 --- a/back/src/bsvhu/validation/schema.ts +++ b/back/src/bsvhu/validation/schema.ts @@ -12,8 +12,6 @@ import { import { BsvhuValidationContext } from "./types"; import { weightSchema } from "../../common/validation/weight"; import { WeightUnits } from "../../common/validation"; -import { sirenifyBsvhu } from "./sirenify"; -import { recipifyBsdTransporter } from "../../common/validation/zod/transformers"; import { CompanyRole, foreignVatNumberSchema, @@ -31,7 +29,7 @@ import { OperationMode, WasteAcceptationStatus } from "@prisma/client"; -import { fillIntermediariesOrgIds } from "./transformers"; +import { fillIntermediariesOrgIds, runTransformers } from "./transformers"; export const ZodWasteCodeEnum = z .enum(BSVHU_WASTE_CODES, { @@ -182,6 +180,26 @@ const rawBsvhuSchema = z.object({ .boolean() .nullish() .transform(v => Boolean(v)), + ecoOrganismeName: z.string().nullish(), + ecoOrganismeSiret: siretSchema(CompanyRole.EcoOrganisme).nullish(), + brokerCompanyName: z.string().nullish(), + brokerCompanySiret: siretSchema(CompanyRole.Broker).nullish(), + brokerCompanyAddress: z.string().nullish(), + brokerCompanyContact: z.string().nullish(), + brokerCompanyPhone: z.string().nullish(), + brokerCompanyMail: z.string().nullish(), + brokerRecepisseNumber: z.string().nullish(), + brokerRecepisseDepartment: z.string().nullish(), + brokerRecepisseValidityLimit: z.coerce.date().nullish(), + traderCompanyName: z.string().nullish(), + traderCompanySiret: siretSchema(CompanyRole.Trader).nullish(), + traderCompanyAddress: z.string().nullish(), + traderCompanyContact: z.string().nullish(), + traderCompanyPhone: z.string().nullish(), + traderCompanyMail: z.string().nullish(), + traderRecepisseNumber: z.string().nullish(), + traderRecepisseDepartment: z.string().nullish(), + traderRecepisseValidityLimit: z.coerce.date().nullish(), intermediaries: z .array(intermediarySchema) .nullish() @@ -230,8 +248,7 @@ export const contextualBsvhuSchema = (context: BsvhuValidationContext) => { export const contextualBsvhuSchemaAsync = (context: BsvhuValidationContext) => { return transformedBsvhuSyncSchema .superRefine(checkCompanies) - .transform(sirenifyBsvhu(context)) - .transform(recipifyBsdTransporter) + .transform((bsvhu: ParsedZodBsvhu) => runTransformers(bsvhu, context)) .superRefine( // run le check sur les champs requis après les transformations // au cas où des transformations auto-complète certains champs diff --git a/back/src/bsvhu/validation/sirenify.ts b/back/src/bsvhu/validation/sirenify.ts index ddd01cfb7a..32a800981f 100644 --- a/back/src/bsvhu/validation/sirenify.ts +++ b/back/src/bsvhu/validation/sirenify.ts @@ -2,9 +2,7 @@ import { nextBuildSirenify, NextCompanyInputAccessor } from "../../companies/sirenify"; -import { getSealedFields } from "./rules"; import { ParsedZodBsvhu } from "./schema"; -import { BsvhuValidationContext, ZodBsvhuTransformer } from "./types"; const sirenifyBsvhuAccessors = ( bsvhu: ParsedZodBsvhu, @@ -49,6 +47,31 @@ const sirenifyBsvhuAccessors = ( input.transporterCompanyAddress = companyInput.address; } }, + { + siret: bsvhu?.ecoOrganismeSiret, + skip: sealedFields.includes("ecoOrganismeSiret"), + setter: (input, companyInput) => { + if (companyInput.name) { + input.ecoOrganismeName = companyInput.name; + } + } + }, + { + siret: bsvhu?.brokerCompanySiret, + skip: sealedFields.includes("brokerCompanySiret"), + setter: (input, companyInput) => { + input.brokerCompanyName = companyInput.name; + input.brokerCompanyAddress = companyInput.address; + } + }, + { + siret: bsvhu?.traderCompanySiret, + skip: sealedFields.includes("traderCompanySiret"), + setter: (input, companyInput) => { + input.traderCompanyName = companyInput.name; + input.traderCompanyAddress = companyInput.address; + } + }, ...(bsvhu.intermediaries ?? []).map( (_, idx) => ({ @@ -67,14 +90,6 @@ const sirenifyBsvhuAccessors = ( ) ]; -export const sirenifyBsvhu: ( - context: BsvhuValidationContext -) => ZodBsvhuTransformer = context => { - return async bsvhu => { - const sealedFields = await getSealedFields(bsvhu, context); - return nextBuildSirenify(sirenifyBsvhuAccessors)( - bsvhu, - sealedFields - ); - }; -}; +export const sirenifyBsvhu = nextBuildSirenify( + sirenifyBsvhuAccessors +); diff --git a/back/src/bsvhu/validation/transformers.ts b/back/src/bsvhu/validation/transformers.ts index b957382660..43496b44de 100644 --- a/back/src/bsvhu/validation/transformers.ts +++ b/back/src/bsvhu/validation/transformers.ts @@ -1,4 +1,21 @@ -import { ZodBsvhuTransformer } from "./types"; +import { recipifyBsvhu } from "./recipify"; +import { getSealedFields } from "./rules"; +import { ParsedZodBsvhu } from "./schema"; +import { sirenifyBsvhu } from "./sirenify"; +import { BsvhuValidationContext, ZodBsvhuTransformer } from "./types"; + +export const runTransformers = async ( + bsvhu: ParsedZodBsvhu, + context: BsvhuValidationContext +): Promise => { + const transformers = [sirenifyBsvhu, recipifyBsvhu]; + const sealedFields = await getSealedFields(bsvhu, context); + + for (const transformer of transformers) { + bsvhu = await transformer(bsvhu, sealedFields); + } + return bsvhu; +}; export const fillIntermediariesOrgIds: ZodBsvhuTransformer = bsvhu => { bsvhu.intermediariesOrgIds = bsvhu.intermediaries diff --git a/back/src/bsvhu/validation/types.ts b/back/src/bsvhu/validation/types.ts index a2dfb7ecf6..9217951190 100644 --- a/back/src/bsvhu/validation/types.ts +++ b/back/src/bsvhu/validation/types.ts @@ -7,6 +7,9 @@ export type BsvhuUserFunctions = { isEmitter: boolean; isDestination: boolean; isTransporter: boolean; + isEcoOrganisme: boolean; + isBroker: boolean; + isTrader: boolean; }; export type BsvhuValidationContext = { diff --git a/back/src/bsvhu/where.ts b/back/src/bsvhu/where.ts index d0dd64f943..810e633c61 100644 --- a/back/src/bsvhu/where.ts +++ b/back/src/bsvhu/where.ts @@ -34,7 +34,9 @@ function toPrismaBsvhuWhereInput(where: BsvhuWhere): Prisma.BsvhuWhereInput { ), destinationOperationSignatureDate: toPrismaDateFilter( where.destination?.operation?.signature?.date - ) + ), + brokerCompanySiret: toPrismaStringFilter(where.broker?.company?.siret), + traderCompanySiret: toPrismaStringFilter(where.trader?.company?.siret) }); } diff --git a/back/src/common/__tests__/elasticHelpers.integration.ts b/back/src/common/__tests__/elasticHelpers.integration.ts new file mode 100644 index 0000000000..ab5eeead6d --- /dev/null +++ b/back/src/common/__tests__/elasticHelpers.integration.ts @@ -0,0 +1,375 @@ +import { + BsdasriStatus, + BsdaStatus, + BsffStatus, + BspaohStatus, + BsvhuStatus, + Status, + WasteAcceptationStatus +} from "@prisma/client"; +import { resetDatabase } from "../../../integration-tests/helper"; +import { + companyFactory, + formFactory, + userFactory +} from "../../__tests__/factories"; +import { + BsdIndexationConfig, + client, + index as globalIndex +} from "../../common/elastic"; +import { reindexAllBsdsInBulk } from "../../bsds/indexation"; +import { cleanUpIsReturnForTab } from "../elasticHelpers"; +import { xDaysAgo } from "../../utils"; +import { bsdaFactory } from "../../bsda/__tests__/factories"; +import { createBsff } from "../../bsffs/__tests__/factories"; +import { bsdasriFactory } from "../../bsdasris/__tests__/factories"; +import { bsvhuFactory } from "../../bsvhu/__tests__/factories.vhu"; +import { bspaohFactory } from "../../bspaoh/__tests__/factories"; + +describe("elasticHelpers", () => { + describe("cleanUpIsReturnForTab", () => { + // do not use INDEX_ALIAS_NAME_SEPARATOR + const testAlias = "testbsds"; + + const testIndex: BsdIndexationConfig = { + ...globalIndex, + alias: testAlias + }; + + async function deleteTestIndexes() { + await client.indices.delete( + { index: `${testAlias}*` }, + { ignore: [404] } + ); + } + + afterEach(async () => { + await resetDatabase(); + await deleteTestIndexes(); + }); + + const countIsReturnForBSDs = async () => { + const countResponse1 = await client.count({ + index: testIndex.alias, + body: { + query: { + bool: { + must: { + exists: { + field: "isReturnFor" + } + } + } + } + } + }); + + return countResponse1.body.count; + }; + + const changeBSDDestinationReceptionDateInES = async ( + bsdId: string, + date: Date + ) => { + await client.updateByQuery({ + index: testIndex.alias, + refresh: true, + body: { + query: { + match: { + id: bsdId + } + }, + script: { + source: + `ctx._source.destinationReceptionDate = ` + + `new SimpleDateFormat('yyyy-MM-dd').parse('${ + date.toISOString().split("T")[0] + }')` + } + } + }); + }; + + const reindex = async () => { + await reindexAllBsdsInBulk({ index: testIndex }); + await client.indices.refresh({ + index: testIndex.alias + }); + }; + + interface CreateOpt { + wasteAcceptationStatus: WasteAcceptationStatus; + } + + const seedBsdd = async (opt?: CreateOpt) => { + const user = await userFactory(); + const bsdd = await formFactory({ + ownerId: user.id, + opt: { + status: Status.RECEIVED, + receivedAt: new Date(), + wasteAcceptationStatus: + opt?.wasteAcceptationStatus ?? + WasteAcceptationStatus.PARTIALLY_REFUSED + } + }); + + return bsdd; + }; + + const seedBsda = async (opt?: CreateOpt) => { + const user = await userFactory(); + const bsda = await bsdaFactory({ + userId: user.id, + opt: { + status: BsdaStatus.PROCESSED, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus: + opt?.wasteAcceptationStatus ?? + WasteAcceptationStatus.PARTIALLY_REFUSED + } + }); + + return bsda; + }; + + const seedBsff = async (opt?: CreateOpt) => { + const transporter = await companyFactory(); + const bsff = await createBsff( + {}, + { + data: { + status: BsffStatus.RECEIVED, + destinationReceptionDate: new Date() + }, + packagingData: { + acceptationStatus: + opt?.wasteAcceptationStatus ?? + WasteAcceptationStatus.PARTIALLY_REFUSED + }, + transporterData: { + transporterCompanySiret: transporter.siret + } + } + ); + + return bsff; + }; + + const seedBsdasri = async (opt?: CreateOpt) => { + const transporter = await companyFactory(); + const bsdasri = await bsdasriFactory({ + opt: { + status: BsdasriStatus.RECEIVED, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus: + opt?.wasteAcceptationStatus ?? + WasteAcceptationStatus.PARTIALLY_REFUSED, + transporterCompanySiret: transporter.siret + } + }); + + return bsdasri; + }; + + const seedBsvhu = async (opt?: CreateOpt) => { + const transporter = await companyFactory(); + const bsvhu = await bsvhuFactory({ + opt: { + status: BsvhuStatus.PROCESSED, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus: + opt?.wasteAcceptationStatus ?? + WasteAcceptationStatus.PARTIALLY_REFUSED, + transporterCompanySiret: transporter.siret + } + }); + + return bsvhu; + }; + + const seedBspaoh = async (opt?: CreateOpt) => { + const bspaoh = await bspaohFactory({ + opt: { + status: BspaohStatus.RECEIVED, + destinationReceptionDate: new Date(), + destinationReceptionAcceptationStatus: + opt?.wasteAcceptationStatus ?? + WasteAcceptationStatus.PARTIALLY_REFUSED + } + }); + + return bspaoh; + }; + + describe.each([ + ["BSDD", seedBsdd], + ["BSDA", seedBsda], + ["BSFF", seedBsff], + ["BSDASRI", seedBsdasri], + ["BSVHU", seedBsvhu], + ["BSPAOH", seedBspaoh] + ])("%p", (_, bsdCreate) => { + it("should not remove fresh BSDs", async () => { + // Given + await bsdCreate(); + await reindex(); + + // There should be 1 BSD in the isReturnFor tab + const count1 = await countIsReturnForBSDs(); + expect(count1).toEqual(1); + + // Given: now clean up isReturnFor tab + await cleanUpIsReturnForTab(testIndex.alias); + + const count2 = await countIsReturnForBSDs(); + expect(count2).toEqual(1); + }); + + it("should remove outdated BSDs", async () => { + // Given + const bsdd = await bsdCreate(); + await reindex(); + + // There should be 1 BSD in the isReturnFor tab + const count1 = await countIsReturnForBSDs(); + expect(count1).toEqual(1); + + // Given + // Manually outdate BSD + await changeBSDDestinationReceptionDateInES( + bsdd.id, + xDaysAgo(new Date(), 3) + ); + + // Clean up tab + await cleanUpIsReturnForTab(testIndex.alias); + + // BSD should have disappeared + const count2 = await countIsReturnForBSDs(); + expect(count2).toEqual(0); + }); + + it("should not touch unconcerned BSDs", async () => { + // Given + const bsdd = await bsdCreate(); + // Shall NOT go to isReturnFor tab + await bsdCreate({ + wasteAcceptationStatus: WasteAcceptationStatus.ACCEPTED + }); + await reindex(); + + // There should be 1 BSD in the isReturnFor tab + const count1 = await countIsReturnForBSDs(); + expect(count1).toEqual(1); + + // Manually outdate BSD + await changeBSDDestinationReceptionDateInES( + bsdd.id, + xDaysAgo(new Date(), 3) + ); + + // Given + const body = await cleanUpIsReturnForTab(testIndex.alias); + + // BSD should have disappeared + expect(body.updated).toEqual(1); + }); + }); + + describe("all BSDs mixed together", () => { + it("should not remove fresh BSDs", async () => { + // Given + await seedBsda(); + await seedBsdasri(); + await seedBsdd(); + await seedBsff(); + await seedBspaoh(); + await seedBsvhu(); + await reindex(); + + // There should be 6 BSDs in the isReturnFor tab + const count1 = await countIsReturnForBSDs(); + expect(count1).toEqual(6); + + // Given: now clean up isReturnFor tab + await cleanUpIsReturnForTab(testIndex.alias); + + const count2 = await countIsReturnForBSDs(); + expect(count2).toEqual(6); + }); + + it("should remove outdated BSDs", async () => { + // Given + const bsda = await seedBsda(); + const bsdasri = await seedBsdasri(); + const bsdd = await seedBsdd(); + const bsff = await seedBsff(); + const bspaoh = await seedBspaoh(); + const bsvhu = await seedBsvhu(); + await reindex(); + + // There should be 6 BSDs in the isReturnFor tab + const count1 = await countIsReturnForBSDs(); + expect(count1).toEqual(6); + + // Given + // Manually outdate BSD + const THREE_DAYS_AGO = xDaysAgo(new Date(), 3); + await changeBSDDestinationReceptionDateInES(bsda.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bsdasri.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bsdd.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bsff.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bspaoh.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bsvhu.id, THREE_DAYS_AGO); + + // Clean up tab + await cleanUpIsReturnForTab(testIndex.alias); + + // BSDs should have disappeared + const count2 = await countIsReturnForBSDs(); + expect(count2).toEqual(0); + }); + + it("should not touch unconcerned BSDs", async () => { + // Given + const bsda = await seedBsda(); + const bsdasri = await seedBsdasri(); + const bsdd = await seedBsdd(); + const bsff = await seedBsff(); + const bspaoh = await seedBspaoh(); + const bsvhu = await seedBsvhu(); + // Shall NOT go to isReturnFor tab + const opt = { wasteAcceptationStatus: WasteAcceptationStatus.ACCEPTED }; + await seedBsda(opt); + await seedBsdasri(opt); + await seedBsdd(opt); + await seedBsff(opt); + await seedBspaoh(opt); + await seedBsvhu(opt); + await reindex(); + + // There should be 1 BSD in the isReturnFor tab + const count1 = await countIsReturnForBSDs(); + expect(count1).toEqual(6); + + // Manually outdate BSD + const THREE_DAYS_AGO = xDaysAgo(new Date(), 3); + await changeBSDDestinationReceptionDateInES(bsda.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bsdasri.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bsdd.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bsff.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bspaoh.id, THREE_DAYS_AGO); + await changeBSDDestinationReceptionDateInES(bsvhu.id, THREE_DAYS_AGO); + + // Given + const body = await cleanUpIsReturnForTab(testIndex.alias); + + // BSDs should have disappeared + expect(body.updated).toEqual(6); + }); + }); + }); +}); diff --git a/back/src/common/elastic.ts b/back/src/common/elastic.ts index bbd93fc10e..444189d681 100644 --- a/back/src/common/elastic.ts +++ b/back/src/common/elastic.ts @@ -97,6 +97,7 @@ export interface BsdElastic { isArchivedFor: string[]; isToCollectFor: string[]; isCollectedFor: string[]; + isReturnFor: string[]; sirets: string[]; isIncomingWasteFor: string[]; @@ -286,6 +287,7 @@ const properties: Record> = { isArchivedFor: stringField, isToCollectFor: stringField, isCollectedFor: stringField, + isReturnFor: stringField, sirets: stringField, isIncomingWasteFor: stringField, diff --git a/back/src/common/elasticHelpers.ts b/back/src/common/elasticHelpers.ts index 0c8395c857..eeb2de54a7 100644 --- a/back/src/common/elasticHelpers.ts +++ b/back/src/common/elasticHelpers.ts @@ -1,5 +1,9 @@ -import { BsdElastic } from "./elastic"; +import { ApiResponse } from "@elastic/elasticsearch"; +import { UpdateByQueryResponse } from "@elastic/elasticsearch/api/types"; +import { xDaysAgo } from "../utils"; +import { BsdElastic, client, index } from "./elastic"; import { RevisionRequestStatus } from "@prisma/client"; +import { logger } from "@td/logger"; type RevisionRequest = { status: RevisionRequestStatus; @@ -61,3 +65,45 @@ export function getRevisionOrgIds( isRevisedFor: isRevisedFor.filter(orgId => !isInRevisionFor.includes(orgId)) }; } +/** + * The "isReturnFor" tab contains BSDs with characteristics that make them valuable for + * transporters, but only for so long. That's why we need to regularly clean this tab up + */ +export const cleanUpIsReturnForTab = async (alias = index.alias) => { + const twoDaysAgo = xDaysAgo(new Date(), 2); + + const { body }: ApiResponse = + await client.updateByQuery({ + index: alias, + refresh: true, + body: { + query: { + bool: { + filter: [ + { + exists: { + field: "isReturnFor" + } + }, + { + range: { + destinationReceptionDate: { + lt: twoDaysAgo.toISOString() + } + } + } + ] + } + }, + script: { + source: "ctx._source.isReturnFor = []" + } + } + }); + + logger.info( + `[cleanUpIsReturnForTab] Update ended! ${body.updated} bsds updated in ${body.took}ms!` + ); + + return body; +}; diff --git a/back/src/common/helpers.ts b/back/src/common/helpers.ts index 8823aff37f..10981449f6 100644 --- a/back/src/common/helpers.ts +++ b/back/src/common/helpers.ts @@ -19,3 +19,20 @@ export const dateToXMonthAtHHMM = (date: Date = new Date()): string => { export const isDefined = (obj: any) => { return obj !== null && obj !== undefined; }; + +/** + * This one does not consider empty strings "" as 'defined' + */ +export const isDefinedStrict = (val: any): boolean => { + if (val === "") return false; + + return isDefined(val); +}; + +/* + * 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/common/pdf/components/Recepisse.tsx b/back/src/common/pdf/components/Recepisse.tsx index 161ad271e5..14f9f1f5fa 100644 --- a/back/src/common/pdf/components/Recepisse.tsx +++ b/back/src/common/pdf/components/Recepisse.tsx @@ -1,20 +1,23 @@ import * as React from "react"; import { formatDate } from "../../../common/pdf"; -import { - BsdaRecepisse, - BsffTransporterRecepisse -} from "../../../generated/graphql/types"; type Props = { - recepisse: BsdaRecepisse | BsffTransporterRecepisse | null | undefined; + recepisse: + | { + number?: string | number | null; + department?: string | null; + validityLimit?: Date | null; + } + | null + | undefined; }; export function Recepisse({ recepisse }: Readonly) { return (

- Récépissé n° : {recepisse?.number} + Récépissé n° : {recepisse?.number ?? "-"}
- Département : {recepisse?.department} + Département : {recepisse?.department ?? "-"}
Limite de validité : {formatDate(recepisse?.validityLimit)}

diff --git a/back/src/common/validation/recipify.ts b/back/src/common/validation/recipify.ts index d3d94cf45f..4543412dbd 100644 --- a/back/src/common/validation/recipify.ts +++ b/back/src/common/validation/recipify.ts @@ -1,53 +1,77 @@ -import { - BsdaInput, - BsdasriInput, - BsdasriRecepisseInput, - BsvhuInput, - BsvhuRecepisseInput -} from "../../generated/graphql/types"; +import { prisma } from "@td/prisma"; +import { CompanyRole } from "./zod/schema"; -import { recipifyGeneric } from "../../companies/recipify"; +type Receipt = { + receiptNumber: string; + validityLimit: Date; + department: string; +}; -/** - * Generic setter for Bsvhu and Bsdasri - * @param input auto-completed Input - * @param recepisseInput Original input - * @returns - */ -export const autocompletedRecepisse = ( - input: BsvhuInput | BsdasriInput, - recepisseInput: BsvhuRecepisseInput | BsdasriRecepisseInput -) => ({ - ...input.transporter?.recepisse, - ...recepisseInput -}); +export type RecipifyInputAccessor = { + role: CompanyRole.Transporter | CompanyRole.Broker | CompanyRole.Trader; + skip: boolean; + orgIdGetter: () => string | null; + setter: (input: T, receipt: Receipt | null) => Promise; +}; -/** - * Generic getter for Bsda, Bsvhu and Bsdasri - * Null when exempted or if transporter company has not changed - * @param input auto-completed Input - * @returns - */ -export const genericGetter = (input: BsvhuInput | BsdasriInput) => () => - input?.transporter?.recepisse?.isExempted !== true - ? input?.transporter?.company - : null; - -const accessors = (input: BsdaInput) => [ - { - getter: genericGetter(input), - setter: ( - input: BsvhuInput | BsdasriInput, - recepisseInput: BsvhuRecepisseInput | BsdasriRecepisseInput - ) => ({ - ...input, - transporter: { - company: input.transporter?.company, - transport: input.transporter?.transport, - recepisse: autocompletedRecepisse(input, recepisseInput) +export const buildRecipify = ( + companyInputAccessors: ( + input: T, + sealedFields: string[] + ) => RecipifyInputAccessor[] +): ((bsd: T, sealedFields: string[]) => Promise) => { + return async (bsd, sealedFields) => { + const accessors = companyInputAccessors(bsd, sealedFields); + const recipifiedBsd = { ...bsd }; + for (const { role, skip, setter, orgIdGetter } of accessors) { + if (skip) { + continue; + } + let receipt: Receipt | null = null; + const orgId = orgIdGetter(); + if (!orgId) { + continue; + } + if (role === CompanyRole.Transporter) { + try { + receipt = await prisma.company + .findUnique({ + where: { + orgId + } + }) + .transporterReceipt(); + } catch (error) { + // do nothing + } + } else if (role === CompanyRole.Broker) { + try { + receipt = await prisma.company + .findUnique({ + where: { + orgId + } + }) + .brokerReceipt(); + } catch (error) { + // do nothing + } + } else if (role === CompanyRole.Trader) { + try { + receipt = await prisma.company + .findUnique({ + where: { + orgId + } + }) + .traderReceipt(); + } catch (error) { + // do nothing + } } - }) - } -]; + setter(recipifiedBsd, receipt); + } -export const recipify = recipifyGeneric(accessors); + return recipifiedBsd; + }; +}; diff --git a/back/src/common/validation/zod/refinement.ts b/back/src/common/validation/zod/refinement.ts index 0fc8e9ca14..5623a31d0d 100644 --- a/back/src/common/validation/zod/refinement.ts +++ b/back/src/common/validation/zod/refinement.ts @@ -1,15 +1,19 @@ import { RefinementCtx, z } from "zod"; import { + isBroker, + isBroyeur, isCollector, + isDemolisseur, + isTrader, isTransporter, isWasteCenter, isWasteProcessor, 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 } from "./schema"; +import { CompanyRole, pathFromCompanyRole } from "./schema"; const { VERIFY_COMPANY } = process.env; @@ -34,6 +38,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 +60,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,12 +70,34 @@ 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` }); } 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 @@ -81,12 +109,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.` + @@ -98,60 +128,79 @@ export const isRegisteredVatNumberRefinement = async ( export async function isDestinationRefinement( siret: string | null | undefined, ctx: RefinementCtx, - role: "DESTINATION" | "WASTE_VEHICLES" = "DESTINATION", + role: + | "DESTINATION" + | "WASTE_VEHICLES" + | "BROYEUR" + | "DEMOLISSEUR" = "DESTINATION", + bsdCompanyRole: CompanyRole = CompanyRole.Destination, isExemptedFromVerification?: (destination: Company | null) => boolean ) { - const company = await refineSiretAndGetCompany( - siret, - ctx, - CompanyRole.Destination - ); + const company = await refineSiretAndGetCompany(siret, ctx, bsdCompanyRole); + if (company) { + if ( + role === "WASTE_VEHICLES" || + role === "BROYEUR" || + role === "DEMOLISSEUR" + ) { + if (!isWasteVehicles(company)) { + return ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(bsdCompanyRole), + message: `Cet établissement n'a pas le profil Installation de traitement de VHU.` + }); + } + if (role === "BROYEUR" && !isBroyeur(company)) { + return ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(bsdCompanyRole), + message: `Cet établissement n'a pas le sous-profil Broyeur.` + }); + } + if (role === "DEMOLISSEUR" && !isDemolisseur(company)) { + return ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(bsdCompanyRole), + message: `Cet établissement n'a pas le sous-profil Casse automobile / démolisseur.` + }); + } + } else if ( + !isCollector(company) && + !isWasteProcessor(company) && + !isWasteCenter(company) && + !isWasteVehicles(company) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(bsdCompanyRole), + 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` + + ` donc pas être visée sur le bordereau. Veuillez vous rapprocher de l'administrateur de cette installation pour qu'il` + + ` modifie le profil de l'établissement depuis l'interface Trackdéchets dans Mes établissements` + }); + } - if (company && role === "WASTE_VEHICLES" && !isWasteVehicles(company)) { - return ctx.addIssue({ - code: z.ZodIssueCode.custom, - 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` + - ` donc pas être visée sur le bordereau. Veuillez vous rapprocher de l'administrateur de cette installation pour qu'il` + - ` modifie le profil de l'établissement depuis l'interface Trackdéchets dans Mes établissements` - }); - } else if ( - company && - !isCollector(company) && - !isWasteProcessor(company) && - !isWasteCenter(company) && - !isWasteVehicles(company) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - 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` + - ` donc pas être visée sur le bordereau. Veuillez vous rapprocher de l'administrateur de cette installation pour qu'il` + - ` modifie le profil de l'établissement depuis l'interface Trackdéchets dans Mes établissements` - }); - } + if ( + VERIFY_COMPANY === "true" && + company.verificationStatus !== CompanyVerificationStatus.VERIFIED + ) { + if (isExemptedFromVerification && isExemptedFromVerification(company)) { + return true; + } - if ( - company && - VERIFY_COMPANY === "true" && - company.verificationStatus !== CompanyVerificationStatus.VERIFIED - ) { - if (isExemptedFromVerification && isExemptedFromVerification(company)) { - return true; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(bsdCompanyRole), + 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.` + }); } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - 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.` - }); } } -export async function isNotDormantRefinement( +export async function isEmitterNotDormantRefinement( siret: string | null | undefined, ctx: RefinementCtx ) { @@ -163,6 +212,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` }); } @@ -179,6 +229,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,9 +240,72 @@ 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" }); } } } + +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` + }); + } + } +} + +export async function isBrokerRefinement( + siret: string | null | undefined, + ctx: RefinementCtx +) { + const company = await refineSiretAndGetCompany( + siret, + ctx, + CompanyRole.Broker + ); + + if (company && !isBroker(company)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Broker), + message: `Cet établissement n'a pas le profil Courtier.` + }); + } +} + +export async function isTraderRefinement( + siret: string | null | undefined, + ctx: RefinementCtx +) { + const company = await refineSiretAndGetCompany( + siret, + ctx, + CompanyRole.Trader + ); + + if (company && !isTrader(company)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: pathFromCompanyRole(CompanyRole.Trader), + message: `Cet établissement n'a pas le profil Négociant.` + }); + } +} diff --git a/back/src/common/validation/zod/schema.ts b/back/src/common/validation/zod/schema.ts index 20d9413e70..2663289fa1 100644 --- a/back/src/common/validation/zod/schema.ts +++ b/back/src/common/validation/zod/schema.ts @@ -8,11 +8,46 @@ export enum CompanyRole { Destination = "Destination", EcoOrganisme = "Éco-organisme", Broker = "Courtier", + Trader = "Trader", Worker = "Entreprise de travaux", Intermediary = "Intermédiaire", - NextDestination = "Éxutoire" + NextDestination = "Éxutoire", + DestinationOperationNextDestination = "Éxutoire final" } +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", "siret"]; + case CompanyRole.Broker: + return ["broker", "company", "siret"]; + case CompanyRole.Trader: + return ["trader", "company", "siret"]; + case CompanyRole.Worker: + return ["worker", "company", "siret"]; + case CompanyRole.Intermediary: + return ["intermediaries", "company", "siret"]; + case CompanyRole.NextDestination: + return ["nextDestination", "company", "siret"]; + case CompanyRole.DestinationOperationNextDestination: + return [ + "destination", + "operation", + "nextDestination", + "company", + "siret" + ]; + default: + return []; + } +}; + export const siretSchema = (expectedCompanyRole?: CompanyRole) => z .string({ @@ -28,6 +63,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 +79,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(), diff --git a/back/src/companies/converters.ts b/back/src/companies/converters.ts new file mode 100644 index 0000000000..881c7e18d1 --- /dev/null +++ b/back/src/companies/converters.ts @@ -0,0 +1,18 @@ +import { Company } from "@prisma/client"; +import { CompanyPrivate } from "../generated/graphql/types"; +import { libelleFromCodeNaf } from "./sirene/utils"; + +export function toGqlCompanyPrivate(company: Company): CompanyPrivate { + return { + ...company, + ecoOrganismeAgreements: + company.ecoOrganismeAgreements?.map(a => new URL(a)) ?? [], + naf: company.codeNaf, + libelleNaf: company.codeNaf ? libelleFromCodeNaf(company.codeNaf) : "", + // les champs ci-dessous sont calculés dans le resolver CompanyPrivate + signatureAutomations: [], + receivedSignatureAutomations: [], + userPermissions: [], + userNotifications: [] + }; +} diff --git a/back/src/companies/database.ts b/back/src/companies/database.ts index b466bde04c..7d12b93e46 100644 --- a/back/src/companies/database.ts +++ b/back/src/companies/database.ts @@ -8,7 +8,9 @@ import { Prisma, Company, CompanyAssociation, - UserAccountHash + UserAccountHash, + UserNotification, + UserRole } from "@prisma/client"; import { CompanyNotFound, @@ -31,6 +33,7 @@ import { searchVatFrOnlyOrNotFoundFailFast } from "./search"; import { SireneSearchResult } from "./sirene/types"; +import { ALL_NOTIFICATIONS } from "../users/notifications"; /** * Retrieves a company by any unique identifier or throw a CompanyNotFound error @@ -185,22 +188,23 @@ export const userNameDisplay = ( /** * map a user's company association to a CompanyMember - * @param companyAssociations + * @param companyAssociation * @param company * @param isTDAdmin */ export const userAssociationToCompanyMember = ( - companyAssociations: CompanyAssociation & { user: User }, + companyAssociation: CompanyAssociation & { user: User }, orgId: string, requestingUserId?: string, isTDAdmin = false -) => { +): CompanyMember => { return { - ...companyAssociations.user, + ...companyAssociation.user, orgId: orgId, - name: userNameDisplay(companyAssociations, requestingUserId, isTDAdmin), - role: companyAssociations.role, - isPendingInvitation: false + name: userNameDisplay(companyAssociation, requestingUserId, isTDAdmin), + role: companyAssociation.role, + isPendingInvitation: false, + notifications: companyAssociation.notifications }; }; @@ -213,7 +217,7 @@ export const userAssociationToCompanyMember = ( export const userAccountHashToCompanyMember = ( userAccountHash: UserAccountHash, orgId: string -) => { +): CompanyMember => { return { id: userAccountHash.id, orgId: orgId, @@ -221,7 +225,9 @@ export const userAccountHashToCompanyMember = ( email: userAccountHash.email, role: userAccountHash.role, isActive: false, - isPendingInvitation: true + isPendingInvitation: true, + notifications: + userAccountHash.role === UserRole.ADMIN ? ALL_NOTIFICATIONS : [] }; }; @@ -308,31 +314,37 @@ export async function getActiveAdminsByCompanyIds( } /** - * Get all the companies and admins from companies, by companyOrgIds + * Get all the companies and subscribers to notification from companies, by companyOrgIds * Will return an object like: * { - * [ordId]: { ...company, admins: user[] } + * [ordId]: { ...company, subscribers: user[] } * } */ -export const getCompaniesAndActiveAdminsByCompanyOrgIds = async ( - orgIds: string[] -): Promise> => { +export const getCompaniesAndSubscribersByCompanyOrgIds = async ( + orgIds: string[], + notification: UserNotification +): Promise> => { const companies = await prisma.company.findMany({ where: { orgId: { in: orgIds } }, include: { companyAssociations: { - where: { role: "ADMIN", user: { isActive: true } }, + where: { + notifications: { has: notification }, + user: { + isActive: true + } + }, include: { user: true, company: true } } } }); - return companies.reduce>( + return companies.reduce>( (companiesAndAdminsByOrgId, { companyAssociations, ...company }) => ({ ...companiesAndAdminsByOrgId, [company.orgId]: { ...company, - admins: companyAssociations.map(({ user }) => user) + subscribers: companyAssociations.map(({ user }) => user) } }), {} @@ -391,24 +403,6 @@ export async function getWorkerCertificationOrNotFound({ return agrement; } -export function convertUrls>( - company: T -): T & { - ecoOrganismeAgreements: URL[]; - signatureAutomations: []; - receivedSignatureAutomations: []; - userPermissions: []; -} { - return { - ...company, - ecoOrganismeAgreements: - company.ecoOrganismeAgreements?.map(a => new URL(a)) ?? [], - signatureAutomations: [], - receivedSignatureAutomations: [], - userPermissions: [] - }; -} - export async function updateFavorites(orgIds: string[]) { for (const favoriteType of allFavoriteTypes) { await favoritesCompanyQueue.addBulk( diff --git a/back/src/companies/dataloaders.ts b/back/src/companies/dataloaders.ts index f67a238862..cd9045b191 100644 --- a/back/src/companies/dataloaders.ts +++ b/back/src/companies/dataloaders.ts @@ -5,7 +5,7 @@ import { UserRole } from "@prisma/client"; export function createCompanyDataLoaders() { return { installations: new DataLoader((sirets: string[]) => - genInstallations(sirets) + getInstallations(sirets) ), companiesAdmin: new DataLoader((companyIds: string[]) => getCompaniesAdmin(companyIds) @@ -13,7 +13,7 @@ export function createCompanyDataLoaders() { }; } -async function genInstallations(sirets: string[]) { +async function getInstallations(sirets: string[]) { const installations = await prisma.installation.findMany({ where: { OR: [ diff --git a/back/src/companies/resolvers/CompanyPrivate.ts b/back/src/companies/resolvers/CompanyPrivate.ts index 3799a394c9..7afd378e21 100644 --- a/back/src/companies/resolvers/CompanyPrivate.ts +++ b/back/src/companies/resolvers/CompanyPrivate.ts @@ -27,6 +27,20 @@ const companyPrivateResolvers: CompanyPrivateResolvers = { parent.userRole ?? (await getUserRole(context.user!.id, parent.orgId)); return role ? grants[role].map(toGraphQLPermission) : []; }, + userNotifications: async (parent, _, context) => { + if (!context.user) { + return []; + } + const companyAssociations = await prisma.company + .findUnique({ where: { id: parent.id } }) + .companyAssociations({ where: { userId: context.user.id } }); + + if (companyAssociations?.length) { + return companyAssociations[0].notifications; + } + + return []; + }, transporterReceipt: parent => { return prisma.company .findUnique({ where: { id: parent.id } }) diff --git a/back/src/companies/resolvers/mutations/__tests__/createCompany.integration.ts b/back/src/companies/resolvers/mutations/__tests__/createCompany.integration.ts index 999cdfa5ef..dab07eeb33 100644 --- a/back/src/companies/resolvers/mutations/__tests__/createCompany.integration.ts +++ b/back/src/companies/resolvers/mutations/__tests__/createCompany.integration.ts @@ -31,6 +31,7 @@ import { import { searchCompany } from "../../../search"; import { sendVerificationCodeLetter } from "../../../../common/post"; import gql from "graphql-tag"; +import { ALL_NOTIFICATIONS } from "../../../../users/notifications"; // Mock external search services jest.mock("../../../search"); @@ -165,14 +166,15 @@ describe("Mutation.createCompany", () => { })) != null; expect(newCompanyExists).toBe(true); - const newCompanyAssociationExists = - (await prisma.companyAssociation.findFirst({ - where: { - company: { siret: companyInput.siret }, - user: { id: user.id } - } - })) != null; - expect(newCompanyAssociationExists).toBe(true); + const newCompanyAssociation = await prisma.companyAssociation.findFirst({ + where: { + company: { siret: companyInput.siret }, + user: { id: user.id } + } + }); + + expect(newCompanyAssociation).not.toBeNull(); + expect(newCompanyAssociation?.notifications).toEqual(ALL_NOTIFICATIONS); const refreshedUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id } diff --git a/back/src/companies/resolvers/mutations/__tests__/renewSecurityCode.test.ts b/back/src/companies/resolvers/mutations/__tests__/renewSecurityCode.test.ts index 9f3369e87d..b3be2a952d 100644 --- a/back/src/companies/resolvers/mutations/__tests__/renewSecurityCode.test.ts +++ b/back/src/companies/resolvers/mutations/__tests__/renewSecurityCode.test.ts @@ -23,11 +23,10 @@ jest.mock("../../../../mailer/mailing", () => ({ sendMail: jest.fn((...args) => sendMailMock(...args)) })); -const getCompanyActiveUsersMock = jest.fn(); +const getNotificationSubscribersMock = jest.fn(); -jest.mock("../../../database", () => ({ - getCompanyActiveUsers: jest.fn(() => getCompanyActiveUsersMock()), - convertUrls: v => v +jest.mock("../../../../users/notifications", () => ({ + getNotificationSubscribers: jest.fn(() => getNotificationSubscribersMock()) })); describe("renewSecurityCode", () => { @@ -36,7 +35,7 @@ describe("renewSecurityCode", () => { updateCompanyMock.mockReset(); (utils.randomNumber as jest.Mock).mockReset(); sendMailMock.mockReset(); - getCompanyActiveUsersMock.mockReset(); + getNotificationSubscribersMock.mockReset(); }); it("should throw BAD_USER_INPUT exception if siret is not 14 character long", async () => { @@ -67,12 +66,12 @@ describe("renewSecurityCode", () => { .mockReturnValueOnce(1234) .mockReturnValueOnce(2345); - getCompanyActiveUsersMock.mockReturnValueOnce([]); + getNotificationSubscribersMock.mockReturnValueOnce([]); await renewSecurityCode("85001946400013"); expect(utils.randomNumber as jest.Mock).toHaveBeenCalledTimes(2); }); - it("should send a notification email to all users and return updated company", async () => { + it("should send a notification email to subscribers and return updated company", async () => { const siret = siretify(2); companyMock.mockResolvedValueOnce({ securityCode: 1234, @@ -101,13 +100,13 @@ describe("renewSecurityCode", () => { } } }); - getCompanyActiveUsersMock.mockReturnValueOnce(users); + getNotificationSubscribersMock.mockReturnValueOnce(users); const updatedCompany = await renewSecurityCode(siret); expect(sendMailMock).toHaveBeenCalledWith(mail); - expect(updatedCompany).toEqual({ + expect(updatedCompany).toMatchObject({ orgId: siret, name: "Code en stock", securityCode: 4567 diff --git a/back/src/companies/resolvers/mutations/__tests__/updateCompany.integration.ts b/back/src/companies/resolvers/mutations/__tests__/updateCompany.integration.ts index b761837a95..165876dcbe 100644 --- a/back/src/companies/resolvers/mutations/__tests__/updateCompany.integration.ts +++ b/back/src/companies/resolvers/mutations/__tests__/updateCompany.integration.ts @@ -178,21 +178,21 @@ describe("mutation updateCompany", () => { }; mockGetUpdatedCompanyNameAndAddress.mockResolvedValueOnce({ name: "nom de sirene", - address: "l'adresse de sirene" + address: "l'adresse de sirene", + codeNaf: "0710Z" }); - const { data } = await mutate>( + const { data, errors } = await mutate>( UPDATE_COMPANY, { variables } ); + expect(errors).toBeUndefined(); expect(data.updateCompany.id).toEqual(company.id); expect(data.updateCompany.name).toEqual("nom de sirene"); expect(data.updateCompany.address).toEqual("l'adresse de sirene"); - expect(data.updateCompany.naf).toEqual(company.codeNaf); - expect(data.updateCompany.libelleNaf).toEqual( - libelleFromCodeNaf(company.codeNaf!) - ); + expect(data.updateCompany.naf).toEqual("0710Z"); + expect(data.updateCompany.libelleNaf).toEqual(libelleFromCodeNaf("0710Z")); const updatedCompany = await prisma.company.findUnique({ where: { id: company.id } @@ -209,8 +209,7 @@ describe("mutation updateCompany", () => { vatNumber: "RO17579668", name: "Acme in EU", address: "Transporter street", - companyTypes: ["TRANSPORTER"], - codeNaf: "0112Z" + companyTypes: ["TRANSPORTER"] }); const { mutate } = makeClient({ ...user, auth: AuthType.Session }); @@ -231,10 +230,6 @@ describe("mutation updateCompany", () => { expect(data.updateCompany.id).toEqual(company.id); expect(data.updateCompany.name).toEqual("nom de vies"); expect(data.updateCompany.address).toEqual("l'adresse de vies"); - expect(data.updateCompany.naf).toEqual(company.codeNaf); - expect(data.updateCompany.libelleNaf).toEqual( - libelleFromCodeNaf(company.codeNaf!) - ); const updatedCompany = await prisma.company.findUnique({ where: { id: company.id } diff --git a/back/src/companies/resolvers/mutations/bulkUpdateCompaniesProfiles.ts b/back/src/companies/resolvers/mutations/bulkUpdateCompaniesProfiles.ts index f21a483787..1f8da5c090 100644 --- a/back/src/companies/resolvers/mutations/bulkUpdateCompaniesProfiles.ts +++ b/back/src/companies/resolvers/mutations/bulkUpdateCompaniesProfiles.ts @@ -9,7 +9,7 @@ import { GraphQLContext } from "../../../types"; import { UserRole } from "@prisma/client"; import { bulkUpdateCompanySchema } from "../../validation/schema"; import { UserInputError } from "../../../common/errors"; -import { convertUrls } from "../../../companies/database"; +import { toGqlCompanyPrivate } from "../../converters"; export async function bulkUpdateCompaniesProfiles( _, { input }: MutationBulkUpdateCompaniesProfilesArgs, @@ -78,7 +78,7 @@ export async function bulkUpdateCompaniesProfiles( }, where: { orgId: row.orgId } }); - updatedCompanies.push(convertUrls(updatedCompany)); + updatedCompanies.push(toGqlCompanyPrivate(updatedCompany)); } return updatedCompanies; diff --git a/back/src/companies/resolvers/mutations/createCompany.ts b/back/src/companies/resolvers/mutations/createCompany.ts index 0f849907f7..50ee87840a 100644 --- a/back/src/companies/resolvers/mutations/createCompany.ts +++ b/back/src/companies/resolvers/mutations/createCompany.ts @@ -7,7 +7,6 @@ import { WasteProcessorType, WasteVehiclesType } from "@prisma/client"; -import { convertUrls } from "../../database"; import { prisma } from "@td/prisma"; import { applyAuthStrategies, AuthType } from "../../../auth"; import { sendMail } from "../../../mailer/mailing"; @@ -33,7 +32,8 @@ import { sendVerificationCodeLetter } from "../../../common/post"; import { isGenericEmail } from "@td/constants"; import { parseCompanyAsync } from "../../validation/index"; import { companyInputToZodCompany } from "../../validation/helpers"; - +import { toGqlCompanyPrivate } from "../../converters"; +import { ALL_NOTIFICATIONS } from "../../../users/notifications"; /** * Create a new company and associate it to a user * who becomes the first admin of the company @@ -185,7 +185,8 @@ const createCompanyResolver: MutationResolvers["createCompany"] = async ( company: { create: companyCreateInput }, - role: "ADMIN" + role: "ADMIN", + notifications: ALL_NOTIFICATIONS }, include: { company: true } }); @@ -252,7 +253,7 @@ const createCompanyResolver: MutationResolvers["createCompany"] = async ( ) { await sendFirstOnboardingEmail(companyInput, user); } - return convertUrls(company); + return toGqlCompanyPrivate(company); }; export default createCompanyResolver; diff --git a/back/src/companies/resolvers/mutations/deleteCompany.ts b/back/src/companies/resolvers/mutations/deleteCompany.ts index 9a445779dc..4ebb2d7c4a 100644 --- a/back/src/companies/resolvers/mutations/deleteCompany.ts +++ b/back/src/companies/resolvers/mutations/deleteCompany.ts @@ -1,11 +1,12 @@ import { UserRole } from "@prisma/client"; import { prisma } from "@td/prisma"; import { applyAuthStrategies, AuthType } from "../../../auth"; -import { convertUrls, getCompanyActiveUsers } from "../../database"; +import { getCompanyActiveUsers } from "../../database"; import { checkIsAuthenticated } from "../../../common/permissions"; import { MutationResolvers } from "../../../generated/graphql/types"; import { deleteCachedUserRoles } from "../../../common/redis/users"; import { UserInputError } from "../../../common/errors"; +import { toGqlCompanyPrivate } from "../../converters"; const deleteCompanyResolver: MutationResolvers["deleteCompany"] = async ( _, @@ -39,7 +40,7 @@ const deleteCompanyResolver: MutationResolvers["deleteCompany"] = async ( await prisma.membershipRequest.deleteMany({ where: { companyId: id } }); await prisma.company.delete({ where: { id } }); - return convertUrls(companyAssociation.company); + return toGqlCompanyPrivate(companyAssociation.company); }; export default deleteCompanyResolver; diff --git a/back/src/companies/resolvers/mutations/renewSecurityCodeService.ts b/back/src/companies/resolvers/mutations/renewSecurityCodeService.ts index 41ea546b9d..d3bb3dbaf8 100644 --- a/back/src/companies/resolvers/mutations/renewSecurityCodeService.ts +++ b/back/src/companies/resolvers/mutations/renewSecurityCodeService.ts @@ -5,10 +5,12 @@ import { sendMail } from "../../../mailer/mailing"; import { CompanyPrivate } from "../../../generated/graphql/types"; import { randomNumber } from "../../../utils"; -import { convertUrls, getCompanyActiveUsers } from "../../database"; import { renderMail, securityCodeRenewal } from "@td/mail"; import { isSiret, isVat } from "@td/constants"; import { UserInputError } from "../../../common/errors"; +import { getNotificationSubscribers } from "../../../users/notifications"; +import { UserNotification } from "@prisma/client"; +import { toGqlCompanyPrivate } from "../../converters"; /** * This function is used to renew the security code @@ -50,19 +52,20 @@ export async function renewSecurityCodeFn( } }); - const users = await getCompanyActiveUsers(orgId); - const recipients = users.map(({ email, name }) => ({ - email, - name: name ?? "" - })); + const subscribers = await getNotificationSubscribers( + UserNotification.SIGNATURE_CODE_RENEWAL, + [orgId] + ); - const mail = renderMail(securityCodeRenewal, { - to: recipients, - variables: { - company: { orgId: company.orgId, name: company.name } - } - }); - sendMail(mail); + if (subscribers.length) { + const mail = renderMail(securityCodeRenewal, { + to: subscribers, + variables: { + company: { orgId: company.orgId, name: company.name } + } + }); + await sendMail(mail); + } - return convertUrls(updatedCompany); + return toGqlCompanyPrivate(updatedCompany); } diff --git a/back/src/companies/resolvers/mutations/updateCompany.ts b/back/src/companies/resolvers/mutations/updateCompany.ts index d6565eff40..095cc1c31e 100644 --- a/back/src/companies/resolvers/mutations/updateCompany.ts +++ b/back/src/companies/resolvers/mutations/updateCompany.ts @@ -2,7 +2,6 @@ import { MutationResolvers } from "../../../generated/graphql/types"; import { applyAuthStrategies, AuthType } from "../../../auth"; import { checkIsAuthenticated } from "../../../common/permissions"; import { - convertUrls, getCompanyOrCompanyNotFound, getUpdatedCompanyNameAndAddress, updateFavorites @@ -10,14 +9,14 @@ import { import { checkUserPermissions, Permission } from "../../../permissions"; import { NotCompanyAdminErrorMsg } from "../../../common/errors"; -import { libelleFromCodeNaf } from "../../sirene/utils"; -import { Company, Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { ZodCompany } from "../../validation/schema"; import { safeInput } from "../../../common/converter"; import { parseCompanyAsync } from "../../validation/index"; import { SiretNotFoundError } from "../../sirene/errors"; import { logger } from "@td/logger"; import { prisma } from "@td/prisma"; +import { toGqlCompanyPrivate } from "../../converters"; const updateCompanyResolver: MutationResolvers["updateCompany"] = async ( parent, @@ -153,16 +152,10 @@ const updateCompanyResolver: MutationResolvers["updateCompany"] = async ( where: { id: existingCompany.id }, data }); + await updateFavorites([existingCompany.orgId]); - // conversion to CompanyPrivate type - const naf = (updatedCompany as Company).codeNaf ?? existingCompany.codeNaf; - const libelleNaf = libelleFromCodeNaf(naf!); - return { - ...convertUrls(updatedCompany), - naf, - libelleNaf - }; + return toGqlCompanyPrivate(updatedCompany); }; export default updateCompanyResolver; diff --git a/back/src/companies/resolvers/mutations/verifyCompany.ts b/back/src/companies/resolvers/mutations/verifyCompany.ts index 31d36c03a1..6f2d89c13e 100644 --- a/back/src/companies/resolvers/mutations/verifyCompany.ts +++ b/back/src/companies/resolvers/mutations/verifyCompany.ts @@ -15,13 +15,14 @@ import { verifiedForeignTransporterCompany } from "@td/mail"; import { prisma } from "@td/prisma"; -import { convertUrls, getCompanyOrCompanyNotFound } from "../../database"; +import { getCompanyOrCompanyNotFound } from "../../database"; import { isForeignTransporter } from "../../validation"; import { Permission, checkUserPermissions } from "../../../permissions"; import { NotCompanyAdminErrorMsg, UserInputError } from "../../../common/errors"; +import { toGqlCompanyPrivate } from "../../converters"; export const sendFirstOnboardingEmail = async ( { @@ -94,7 +95,7 @@ const verifyCompanyResolver: MutationResolvers["verifyCompany"] = async ( // Potential onboarding email await sendFirstOnboardingEmail(verifiedCompany, user); - return convertUrls(verifiedCompany); + return toGqlCompanyPrivate(verifiedCompany); }; export default verifyCompanyResolver; diff --git a/back/src/companies/resolvers/queries/__tests__/companyInfos.test.ts b/back/src/companies/resolvers/queries/__tests__/companyInfos.test.ts index ec309abd0b..3ab63656e4 100644 --- a/back/src/companies/resolvers/queries/__tests__/companyInfos.test.ts +++ b/back/src/companies/resolvers/queries/__tests__/companyInfos.test.ts @@ -23,8 +23,7 @@ jest.mock("@td/prisma", () => ({ const installationMock = jest.fn(); jest.mock("../../../database", () => ({ - getInstallation: jest.fn((...args) => installationMock(...args)), - convertUrls: v => v + getInstallation: jest.fn((...args) => installationMock(...args)) })); describe("companyInfos with SIRET", () => { diff --git a/back/src/companies/resolvers/queries/__tests__/favorites.integration.ts b/back/src/companies/resolvers/queries/__tests__/favorites.integration.ts index ab9c3314d3..355739df50 100644 --- a/back/src/companies/resolvers/queries/__tests__/favorites.integration.ts +++ b/back/src/companies/resolvers/queries/__tests__/favorites.integration.ts @@ -13,10 +13,10 @@ import { indexFavorites, favoritesConstrutor } from "../../../../queue/jobs/indexFavorites"; -import { convertUrls } from "../../../database"; import { searchCompany } from "../../../search"; import { index, client as elasticSearch } from "../../../../common/elastic"; import { getFormForElastic, indexForm } from "../../../../forms/elastic"; +import { toGqlCompanyPrivate } from "../../../converters"; const request = supertest(app); @@ -82,7 +82,7 @@ describe("query favorites", () => { await refreshIndices(); (searchCompany as jest.Mock).mockResolvedValueOnce({ - ...convertUrls(company) + ...toGqlCompanyPrivate(company) }); await indexFavorites( await favoritesConstrutor({ orgId: company.orgId, type: "RECIPIENT" }), @@ -137,7 +137,7 @@ describe("query favorites", () => { await refreshIndices(); (searchCompany as jest.Mock).mockResolvedValueOnce({ - ...convertUrls(company) + ...toGqlCompanyPrivate(company) }); await indexFavorites( await favoritesConstrutor({ orgId: company.orgId, type: "EMITTER" }), 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/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..86195b6846 100644 --- a/back/src/companies/typeDefs/company.objects.graphql +++ b/back/src/companies/typeDefs/company.objects.graphql @@ -45,6 +45,9 @@ type CompanyPrivate { "Liste des permissions de l'utilisateur authentifié au sein de cet établissement" userPermissions: [UserPermission!]! + "Liste des notifications auquels l'utilisateur est abonné pour cet établissement" + userNotifications: [UserNotification!]! + """ Nom d'usage de l'entreprise qui permet de différencier différents établissements ayant le même nom @@ -182,8 +185,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" @@ -496,6 +501,7 @@ type EcoOrganisme { handleBsdasri: Boolean handleBsda: Boolean + handleBsvhu: Boolean } """ @@ -638,6 +644,9 @@ type CompanyMember { "Si oui ou non cet utilisateur correspond à l'utilisateur authentifié" isMe: Boolean + + "Liste des notifications auxquelles l'utilisateur est abonné pour cet établissement" + notifications: [UserNotification!]! } """ diff --git a/back/src/companies/validation.ts b/back/src/companies/validation.ts index 8a7a08c571..8ee1b14551 100644 --- a/back/src/companies/validation.ts +++ b/back/src/companies/validation.ts @@ -1,5 +1,10 @@ import * as yup from "yup"; -import { Company, CompanyType, WasteProcessorType } from "@prisma/client"; +import { + Company, + CompanyType, + WasteProcessorType, + WasteVehiclesType +} from "@prisma/client"; import { cleanClue, isForeignVat, @@ -31,6 +36,14 @@ export function isWasteVehicles(company: Company) { return company.companyTypes.includes(CompanyType.WASTE_VEHICLES); } +export function isBroyeur(company: Company) { + return company.wasteVehiclesTypes.includes(WasteVehiclesType.BROYEUR); +} + +export function isDemolisseur(company: Company) { + return company.wasteVehiclesTypes.includes(WasteVehiclesType.DEMOLISSEUR); +} + export function isTransporter({ companyTypes }: { @@ -53,6 +66,14 @@ export function isWorker(company: Company) { return company.companyTypes.includes(CompanyType.WORKER); } +export function isBroker(company: Company) { + return company.companyTypes.includes(CompanyType.BROKER); +} + +export function isTrader(company: Company) { + return company.companyTypes.includes(CompanyType.TRADER); +} + export function hasCremationProfile(company: Company) { return company.wasteProcessorTypes.includes(WasteProcessorType.CREMATION); } diff --git a/back/src/companydigest/developper_doc.md b/back/src/companydigest/developper_doc.md index 5a3e89ec27..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é. -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 fonctionnalité 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/back/src/forms/__tests__/elastic.integration.ts b/back/src/forms/__tests__/elastic.integration.ts index 59236d261f..5dcbdd4e63 100644 --- a/back/src/forms/__tests__/elastic.integration.ts +++ b/back/src/forms/__tests__/elastic.integration.ts @@ -1,4 +1,9 @@ -import { Company, Status } from "@prisma/client"; +import { + Company, + EmptyReturnADR, + Status, + WasteAcceptationStatus +} from "@prisma/client"; import { resetDatabase } from "../../../integration-tests/helper"; import { prisma } from "@td/prisma"; import { @@ -9,10 +14,15 @@ import { userFactory, userWithCompanyFactory } from "../../__tests__/factories"; -import { getFirstTransporterSync, getFullForm } from "../database"; +import { + getFirstTransporterSync, + getFullForm, + getLastTransporterSync +} from "../database"; import { getSiretsByTab } from "../elasticHelpers"; import { getFormForElastic, toBsdElastic } from "../elastic"; import { BsdElastic } from "../../common/elastic"; +import { xDaysAgo } from "../../utils"; describe("getSiretsByTab", () => { afterEach(resetDatabase); @@ -368,6 +378,160 @@ describe("getSiretsByTab", () => { forwardedInTransporter!.transporterCompanySiret ); }); + + describe("isReturnFor", () => { + it.each([ + WasteAcceptationStatus.REFUSED, + WasteAcceptationStatus.PARTIALLY_REFUSED + ])( + "waste acceptation status is %p > bsdd should belong to tab", + async wasteAcceptationStatus => { + // Given + const user = await userFactory(); + const form = await formFactory({ + ownerId: user.id, + opt: { + status: Status.RECEIVED, + receivedAt: new Date(), + wasteAcceptationStatus + } + }); + const formForElastic = await getFormForElastic(form); + const transporter = getLastTransporterSync(formForElastic); + + // When + const { isReturnFor } = toBsdElastic(formForElastic); + + // Then + expect(isReturnFor).toContain(transporter?.transporterCompanySiret); + } + ); + + it("status is REFUSED > bsdd should belong to tab", async () => { + // Given + const user = await userFactory(); + const form = await formFactory({ + ownerId: user.id, + opt: { + status: Status.REFUSED, + receivedAt: new Date() + } + }); + const fullForm = await getFormForElastic(form); + const transporter = getLastTransporterSync(fullForm); + + // When + const { isReturnFor } = toBsdElastic(fullForm); + + // Then + expect(isReturnFor).toContain(transporter?.transporterCompanySiret); + }); + + it("waste acceptation status is ACCEPTED > bsdd should not belong to tab", async () => { + // Given + const user = await userFactory(); + const form = await formFactory({ + ownerId: user.id, + opt: { + status: Status.RECEIVED, + receivedAt: new Date(), + wasteAcceptationStatus: WasteAcceptationStatus.ACCEPTED + } + }); + const fullForm = await getFormForElastic(form); + + // When + const { isReturnFor } = toBsdElastic(fullForm); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + + it("form has been received too long ago > should not belong to tab", async () => { + // Given + const user = await userFactory(); + const form = await formFactory({ + ownerId: user.id, + opt: { + status: Status.RECEIVED, + receivedAt: xDaysAgo(new Date(), 10), + wasteAcceptationStatus: WasteAcceptationStatus.REFUSED + } + }); + const fullForm = await getFormForElastic(form); + + // When + const { isReturnFor } = toBsdElastic(fullForm); + + // Then + expect(isReturnFor).toStrictEqual([]); + }); + + it("empty ADR stuff has been precised > bsdd should belong to tab", async () => { + // Given + const user = await userFactory(); + const form = await formFactory({ + ownerId: user.id, + opt: { + status: Status.RECEIVED, + receivedAt: new Date(), + emptyReturnADR: EmptyReturnADR.EMPTY_CITERNE + } + }); + const fullForm = await getFormForElastic(form); + const transporter = getLastTransporterSync(fullForm); + + // When + const { isReturnFor } = toBsdElastic(fullForm); + + // Then + expect(isReturnFor).toContain(transporter?.transporterCompanySiret); + }); + + it("citerne stuff has been precised > bsdd should belong to tab", async () => { + // Given + const user = await userFactory(); + const form = await formFactory({ + ownerId: user.id, + opt: { + status: Status.RECEIVED, + receivedAt: new Date(), + hasCiterneBeenWashedOut: false + } + }); + const fullForm = await getFormForElastic(form); + const transporter = getLastTransporterSync(fullForm); + + // When + const { isReturnFor } = toBsdElastic(fullForm); + + // Then + expect(isReturnFor).toContain(transporter?.transporterCompanySiret); + }); + + it("combination of all > bsdd should belong to tab", async () => { + // Given + const user = await userFactory(); + const form = await formFactory({ + ownerId: user.id, + opt: { + status: Status.RECEIVED, + receivedAt: new Date(), + wasteAcceptationStatus: WasteAcceptationStatus.REFUSED, + hasCiterneBeenWashedOut: false, + emptyReturnADR: EmptyReturnADR.EMPTY_RETURN_NOT_WASHED + } + }); + const fullForm = await getFormForElastic(form); + const transporter = getLastTransporterSync(fullForm); + + // When + const { isReturnFor } = toBsdElastic(fullForm); + + // Then + expect(isReturnFor).toContain(transporter?.transporterCompanySiret); + }); + }); }); describe("toBsdElastic > companies Names & OrgIds", () => { diff --git a/back/src/forms/database.ts b/back/src/forms/database.ts index 526cee9b92..d4c2a2ba7a 100644 --- a/back/src/forms/database.ts +++ b/back/src/forms/database.ts @@ -217,6 +217,15 @@ export function getFirstTransporterSync(form: { return firstTransporter ?? null; } +export function getLastTransporterSync(form: { + transporters: BsddTransporter[] | null; +}): BsddTransporter | null { + const transporters = getTransportersSync(form); + const greatestNumber = Math.max(...transporters.map(t => t.number)); + const lastTransporter = transporters.find(t => t.number === greatestNumber); + return lastTransporter ?? null; +} + // Renvoie le premier transporteur qui n'a pas encor signé export function getNextTransporterSync(form: { transporters: BsddTransporter[] | null; diff --git a/back/src/forms/elastic.ts b/back/src/forms/elastic.ts index 088ba5cddd..8d199dc75d 100644 --- a/back/src/forms/elastic.ts +++ b/back/src/forms/elastic.ts @@ -17,7 +17,8 @@ import { getRegistryFields } from "./registry"; import { getSiretsByTab, getRecipient, - getFormRevisionOrgIds + getFormRevisionOrgIds, + getFormReturnOrgIds } from "./elasticHelpers"; import { buildAddress } from "../companies/sirene/utils"; @@ -135,6 +136,7 @@ export function toBsdElastic(form: FormForElastic): BsdElastic { ? form.quantityReceived.toNumber() : null, destinationOperationDate: form.processedAt?.getTime(), + ...getFormReturnOrgIds(form), ...(form.forwarding ? { // do not display BSD suite in dashboard diff --git a/back/src/forms/elasticHelpers.ts b/back/src/forms/elasticHelpers.ts index 3f074f8328..a5d329a92b 100644 --- a/back/src/forms/elasticHelpers.ts +++ b/back/src/forms/elasticHelpers.ts @@ -1,15 +1,23 @@ -import { BsddTransporter, EmitterType, Status } from "@prisma/client"; +import { + BsddTransporter, + EmitterType, + Status, + WasteAcceptationStatus +} from "@prisma/client"; import { BsdElastic } from "../common/elastic"; import { FullForm } from "./types"; import { getTransporterCompanyOrgId } from "@td/constants"; import { getFirstTransporterSync, + getLastTransporterSync, getNextTransporterSync, getTransportersSync } from "./database"; import { FormForElastic } from "./elastic"; import { getRevisionOrgIds } from "../common/elasticHelpers"; +import { isDefined } from "../common/helpers"; +import { xDaysAgo } from "../utils"; /** * Computes which SIRET or VAT number should appear on which tab in the frontend @@ -223,6 +231,40 @@ export function getSiretsByTab(form: FullForm): Pick { return siretsByTab; } +/** + * BSDD belongs to isReturnFor tab if: + * - waste has been received in the last 48 hours + * - waste has citerne business (emptyReturnADR or citerne washed) OR waste hasn't been fully accepted + */ +export const belongsToIsReturnForTab = (form: FullForm) => { + const hasBeenReceivedLately = + isDefined(form.receivedAt) && form.receivedAt! > xDaysAgo(new Date(), 2); + + if (!hasBeenReceivedLately) return false; + + const hasCiterneBusiness = + isDefined(form.emptyReturnADR) || isDefined(form.hasCiterneBeenWashedOut); + + const hasNotBeenFullyAccepted = + form.status === Status.REFUSED || + form.wasteAcceptationStatus !== WasteAcceptationStatus.ACCEPTED; + + return hasCiterneBusiness || hasNotBeenFullyAccepted; +}; + +export function getFormReturnOrgIds(form: FullForm) { + // Return tab + if (belongsToIsReturnForTab(form)) { + const lastTransporter = getLastTransporterSync(form); + + return { + isReturnFor: [lastTransporter?.transporterCompanySiret].filter(Boolean) + }; + } + + return { isReturnFor: [] }; +} + function getFormSirets(form: FullForm) { const transporter = getFirstTransporterSync(form); diff --git a/back/src/forms/examples/fixtures.ts b/back/src/forms/examples/fixtures.ts index 2af213d5e9..1fbfc13952 100644 --- a/back/src/forms/examples/fixtures.ts +++ b/back/src/forms/examples/fixtures.ts @@ -2,8 +2,6 @@ * Fixtures used as building blocks of mutations inputs */ -import { TransportMode } from "@prisma/client"; - function emitterCompanyInput(siret: string) { return { siret, @@ -131,7 +129,7 @@ function signTransportFormInput() { takenOverAt: "2020-04-03T14:48:00", takenOverBy: "Isabelle Guichard", transporterNumberPlate: "AA-123456-BB", - transporterTransportMode: TransportMode.ROAD + transporterTransportMode: "ROAD" }; } diff --git a/back/src/forms/mail/__tests__/renderFormRefusedEmail.integration.ts b/back/src/forms/mail/__tests__/renderFormRefusedEmail.integration.ts index 0b70e1fdca..68e2ea47a4 100644 --- a/back/src/forms/mail/__tests__/renderFormRefusedEmail.integration.ts +++ b/back/src/forms/mail/__tests__/renderFormRefusedEmail.integration.ts @@ -345,8 +345,8 @@ describe("renderFormRefusedEmail", () => { { email: emitter.user.email, name: emitter.user.name } ]); expect(email!.cc).toEqual([ - { email: destination.user.email, name: destination.user.name }, - { email: ttr.user.email, name: ttr.user.name } + { email: ttr.user.email, name: ttr.user.name }, + { email: destination.user.email, name: destination.user.name } ]); expect(email!.body).toContain(`

Nous vous informons que la société ${destination.company.name} diff --git a/back/src/forms/mail/renderFormRefusedEmail.ts b/back/src/forms/mail/renderFormRefusedEmail.ts index faf7b85549..dec3af0f98 100644 --- a/back/src/forms/mail/renderFormRefusedEmail.ts +++ b/back/src/forms/mail/renderFormRefusedEmail.ts @@ -1,6 +1,5 @@ -import { BsddTransporter, Form } from "@prisma/client"; +import { BsddTransporter, Form, UserNotification } from "@prisma/client"; import { prisma } from "@td/prisma"; -import { getCompanyAdminUsers } from "../../companies/database"; import { generateBsddPdfToBase64 } from "../pdf"; import { Mail, @@ -11,6 +10,7 @@ import { import { getTransporterCompanyOrgId, Dreals } from "@td/constants"; import { getFirstTransporter } from "../database"; import { bsddWasteQuantities } from "../helpers/bsddWasteQuantities"; +import { getNotificationSubscribers } from "../../users/notifications"; const { NOTIFY_DREAL_WHEN_FORM_DECLINED } = process.env; @@ -42,14 +42,6 @@ export async function renderFormRefusedEmail( name: `${filename}.pdf` }; - const emitterCompanyAdmins = await getCompanyAdminUsers( - form.emitterCompanySiret! - ); - const destinationCompanyAdmins = await getCompanyAdminUsers(destinationSiret); - const tempStorerCompanyAdmins = isFinalDestinationRefusal - ? await getCompanyAdminUsers(form.recipientCompanySiret!) - : []; - let drealsRecipients: typeof Dreals = []; if (notifyDreal) { @@ -66,17 +58,23 @@ export async function renderFormRefusedEmail( drealsRecipients = Dreals.filter(d => formDepartments.includes(d.Dept)); } - const to = emitterCompanyAdmins.map(admin => ({ - email: admin.email, - name: admin.name ?? "" - })); + const to = await getNotificationSubscribers( + UserNotification.BSD_REFUSAL, + [form.emitterCompanySiret].filter(Boolean) + ); + + const destinationCC = await getNotificationSubscribers( + UserNotification.BSD_REFUSAL, + [ + form.recipientCompanySiret, + isFinalDestinationRefusal ? forwardedIn?.recipientCompanySiret : null + ].filter(Boolean) + ); // include drealsRecipients if settings says so - const cc = [ - ...destinationCompanyAdmins, - ...tempStorerCompanyAdmins, - ...(notifyDreal ? drealsRecipients : []) - ].map(admin => ({ email: admin.email, name: admin.name ?? "" })); + const cc = [...destinationCC, ...(notifyDreal ? drealsRecipients : [])].map( + admin => ({ email: admin.email, name: admin.name ?? "" }) + ); // Get formNotAccepted or formPartiallyRefused mail function according to wasteAcceptationStatus value const mailTemplate = { 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__/markAsAccepted.integration.ts b/back/src/forms/resolvers/mutations/__tests__/markAsAccepted.integration.ts index eb274f15f1..c9e9c49a19 100644 --- a/back/src/forms/resolvers/mutations/__tests__/markAsAccepted.integration.ts +++ b/back/src/forms/resolvers/mutations/__tests__/markAsAccepted.integration.ts @@ -5,6 +5,7 @@ import { EmptyReturnADR, Status, TransportMode, + UserNotification, UserRole, WasteAcceptationStatus } from "@prisma/client"; @@ -29,6 +30,7 @@ import { generateBsddPdfToBase64 } from "../../../pdf/generateBsddPdf"; import getReadableId from "../../../readableId"; import { updateAppendix2Queue } from "../../../../queue/producers/updateAppendix2"; import { waitForJobsCompletion } from "../../../../queue/helpers"; +import { associateUserToCompany } from "../../../../users/database"; // No mails jest.mock("../../../../mailer/mailing"); @@ -241,8 +243,32 @@ describe("Test Form reception", () => { }); it("should mark a received form as refused", async () => { - const { emitterCompany, recipient, recipientCompany, form } = + const { emitter, emitterCompany, recipient, recipientCompany, form } = await prepareDB(); + + // should received email + const emitter2 = await userFactory(); + await associateUserToCompany(emitter2.id, emitterCompany.orgId, "ADMIN", { + notifications: [UserNotification.BSD_REFUSAL] + }); + + // should not receive email + const emitter3 = await userFactory(); + await associateUserToCompany(emitter3.id, emitterCompany.orgId, "ADMIN", { + notifications: [] + }); + + // should received email + const recipient2 = await userFactory(); + await associateUserToCompany( + recipient2.id, + recipientCompany.orgId, + "ADMIN", + { + notifications: [UserNotification.BSD_REFUSAL] + } + ); + await prisma.form.update({ where: { id: form.id }, data: { @@ -289,14 +315,57 @@ describe("Test Form reception", () => { expect(sendMail as jest.Mock).toHaveBeenCalledWith( expect.objectContaining({ subject: - "Le déchet de l’entreprise company_1 a été totalement refusé à réception" + "Le déchet de l’entreprise company_1 a été totalement refusé à réception", + to: [ + { email: emitter.email, name: emitter.name }, + { email: emitter2.email, name: emitter2.name } + ], + cc: [ + { email: recipient.email, name: recipient.name }, + { email: recipient2.email, name: recipient2.name } + ] }) ); }); - it("should mark a received form as partially refused", async () => { - const { emitterCompany, recipient, recipientCompany, form } = + it("should mark a received form as partially refused and send emails to subscribers", async () => { + const { emitter, emitterCompany, recipient, recipientCompany, form } = await prepareDB(); + + // should received email + const emitter2 = await userFactory(); + await associateUserToCompany(emitter2.id, emitterCompany.orgId, "ADMIN", { + notifications: [UserNotification.BSD_REFUSAL] + }); + + // should not receive email + const emitter3 = await userFactory(); + await associateUserToCompany(emitter3.id, emitterCompany.orgId, "ADMIN", { + notifications: [] + }); + + // should received email + const recipient2 = await userFactory(); + await associateUserToCompany( + recipient2.id, + recipientCompany.orgId, + "ADMIN", + { + notifications: [UserNotification.BSD_REFUSAL] + } + ); + + // should not receive email + const recipient3 = await userFactory(); + await associateUserToCompany( + recipient3.id, + recipientCompany.orgId, + "ADMIN", + { + notifications: [] + } + ); + await prisma.form.update({ where: { id: form.id }, data: { @@ -342,7 +411,15 @@ describe("Test Form reception", () => { expect(sendMail as jest.Mock).toHaveBeenCalledWith( expect.objectContaining({ subject: - "Le déchet de l’entreprise company_1 a été partiellement refusé à réception" + "Le déchet de l’entreprise company_1 a été partiellement refusé à réception", + to: [ + { email: emitter.email, name: emitter.name }, + { email: emitter2.email, name: emitter2.name } + ], + cc: [ + { email: recipient.email, name: recipient.name }, + { email: recipient2.email, name: recipient2.name } + ] }) ); }); 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/resolvers/mutations/__tests__/markAsSealed.integration.ts b/back/src/forms/resolvers/mutations/__tests__/markAsSealed.integration.ts index 03a0b9e518..314af03310 100644 --- a/back/src/forms/resolvers/mutations/__tests__/markAsSealed.integration.ts +++ b/back/src/forms/resolvers/mutations/__tests__/markAsSealed.integration.ts @@ -16,7 +16,7 @@ import { } from "../../../../__tests__/factories"; import makeClient from "../../../../__tests__/testClient"; import { sendMail } from "../../../../mailer/mailing"; -import { renderMail, contentAwaitsGuest } from "@td/mail"; +import { renderMail, yourCompanyIsIdentifiedOnABsd } from "@td/mail"; import { MARK_AS_SEALED } from "./mutations"; import { updateAppendix2Queue } from "../../../../queue/producers/updateAppendix2"; import { waitForJobsCompletion } from "../../../../queue/helpers"; @@ -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(); @@ -1073,19 +1147,26 @@ describe("Mutation.markAsSealed", () => { variables: { id: form.id } }); - expect(sendMail as jest.Mock).toHaveBeenCalledWith( - renderMail(contentAwaitsGuest, { + const expectedMail = { + ...renderMail(yourCompanyIsIdentifiedOnABsd, { to: [ { email: form.emitterCompanyMail!, name: form.emitterCompanyContact! } ], variables: { - company: { + emitter: { siret: form.emitterCompanySiret!, name: form.emitterCompanyName! + }, + destination: { + siret: form.recipientCompanySiret!, + name: form.recipientCompanyName! } } - }) - ); + }), + params: { hideRegisteredUserInfo: true } + }; + + expect(sendMail as jest.Mock).toHaveBeenCalledWith(expectedMail); }); it("should not send an email to emitter contact if contact email already appears in a previous BSD", async () => { 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/back/src/forms/resolvers/mutations/markAsAccepted.ts b/back/src/forms/resolvers/mutations/markAsAccepted.ts index 9a475516f9..1aa2d898f5 100644 --- a/back/src/forms/resolvers/mutations/markAsAccepted.ts +++ b/back/src/forms/resolvers/mutations/markAsAccepted.ts @@ -90,7 +90,7 @@ const markAsAcceptedResolver: MutationResolvers["markAsAccepted"] = async ( if (isWasteRefused(acceptedForm)) { const refusedEmail = await renderFormRefusedEmail(acceptedForm); - if (refusedEmail) { + if (refusedEmail && refusedEmail.to?.length) { sendMail(refusedEmail); } } diff --git a/back/src/forms/resolvers/mutations/markAsSealed.ts b/back/src/forms/resolvers/mutations/markAsSealed.ts index be04f98f2d..39fe452681 100644 --- a/back/src/forms/resolvers/mutations/markAsSealed.ts +++ b/back/src/forms/resolvers/mutations/markAsSealed.ts @@ -1,5 +1,5 @@ import { EmitterType, Form, Status } from "@prisma/client"; -import { contentAwaitsGuest, renderMail } from "@td/mail"; +import { yourCompanyIsIdentifiedOnABsd, renderMail } from "@td/mail"; import { prisma } from "@td/prisma"; import { UserInputError } from "../../../common/errors"; import { checkIsAuthenticated } from "../../../common/permissions"; @@ -141,24 +141,36 @@ async function mailToNonExistentEmitter( if ( form.emitterCompanyMail && form.emitterCompanySiret && + form.emitterCompanyName && + form.recipientCompanyName && + form.recipientCompanySiret && !contactAlreadyMentionned ) { - await sendMail( - renderMail(contentAwaitsGuest, { - to: [ - { - email: form.emitterCompanyMail, - name: form.emitterCompanyContact ?? "" - } - ], - variables: { - company: { - siret: form.emitterCompanySiret, - name: form.emitterCompanyName ?? "" - } + const mail = renderMail(yourCompanyIsIdentifiedOnABsd, { + to: [ + { + email: form.emitterCompanyMail, + name: form.emitterCompanyContact ?? "" } - }) - ); + ], + variables: { + emitter: { + siret: form.emitterCompanySiret, + name: form.emitterCompanyName + }, + destination: { + siret: form.recipientCompanySiret, + name: form.recipientCompanyName + } + } + }); + + await sendMail({ + ...mail, + // permet de cacher le message "Vous avez reçu cet e-mail car vous + // êtes inscrit sur la plateforme Trackdéchets" dans le template Brevo + params: { hideRegisteredUserInfo: true } + }); } } diff --git a/back/src/forms/resolvers/queries/__tests__/forms.integration.ts b/back/src/forms/resolvers/queries/__tests__/forms.integration.ts index cc7140e547..72f058d1be 100644 --- a/back/src/forms/resolvers/queries/__tests__/forms.integration.ts +++ b/back/src/forms/resolvers/queries/__tests__/forms.integration.ts @@ -888,13 +888,14 @@ describe("Integration / Forms query for transporters", () => { // the transporter makes the query const { query } = makeClient(user); - const { data } = await query>(FORMS, { + const { data, errors } = await query>(FORMS, { variables: { siret: transporter.siret, roles: ["TRANSPORTER"] } }); + expect(errors).toBeUndefined(); expect(data.forms.length).toBe(1); expect(data.forms[0].id).toBe(form.id); }); 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/back/src/forms/updateAppendix2.ts b/back/src/forms/updateAppendix2.ts index 6d8362b01b..17862fcaa5 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 = @@ -103,19 +109,26 @@ 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 && + nextStatus === "PROCESSED" + ) { + const groupedForms = await findGroupedFormsById(form.id); + for (const formId of groupedForms.map(f => f.id)) { + await enqueueUpdateAppendix2Job({ + formId, + userId: user.id, + auth: user.auth + }); + } } } } diff --git a/back/src/index.ts b/back/src/index.ts index 990e04194e..c55320f0d9 100644 --- a/back/src/index.ts +++ b/back/src/index.ts @@ -16,7 +16,7 @@ export { closeQueues } from "./queue/producers"; export { initSentry } from "./common/sentry"; export * from "./utils"; export { deleteBsd } from "./common/elastic"; -export { getCompaniesAndActiveAdminsByCompanyOrgIds } from "./companies/database"; +export { getCompaniesAndSubscribersByCompanyOrgIds } from "./companies/database"; export { formatDate } from "./common/pdf"; export { sendMail } from "./mailer/mailing"; export { BsdUpdateQueueItem, updatesQueue } from "./queue/producers/bsdUpdate"; @@ -82,3 +82,4 @@ export { gericoQueue } from "./queue/producers/gerico"; export { getBsdasriFromActivityEvents } from "./activity-events/bsdasri"; export { getBsdaFromActivityEvents } from "./activity-events/bsda"; export { getBsddFromActivityEvents } from "./activity-events/bsdd"; +export { cleanUpIsReturnForTab } from "./common/elasticHelpers"; diff --git a/back/src/mailer/mailing.ts b/back/src/mailer/mailing.ts index e5b6c76177..1299ac6c6a 100644 --- a/back/src/mailer/mailing.ts +++ b/back/src/mailer/mailing.ts @@ -2,8 +2,16 @@ import { backend, Mail, Contact } from "@td/mail"; import { addToMailQueue } from "../queue/producers/mail"; import { logger } from "@td/logger"; +type SendMailOpts = { + sync: boolean; +}; + // push job to the job queue for the api server not to execute the sendMail itself -export async function sendMail(mail: Mail): Promise { +export async function sendMail(mail: Mail, opts?: SendMailOpts): Promise { + if (opts?.sync) { + await sendMailSync(mail); + return; + } try { await addToMailQueue(mail); } catch (err) { 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/registry/__tests__/streams.integration.ts b/back/src/registry/__tests__/streams.integration.ts index 645390a83e..009bdd6b44 100644 --- a/back/src/registry/__tests__/streams.integration.ts +++ b/back/src/registry/__tests__/streams.integration.ts @@ -111,6 +111,7 @@ describe("wastesReader", () => { .fill(1) .map(() => bsvhuFactory({ + userId: destination.user.id, opt: { destinationCompanySiret: destination.company.siret, destinationReceptionDate: new Date(), 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..4be7119354 --- /dev/null +++ b/back/src/registryDelegation/permissions.ts @@ -0,0 +1,140 @@ +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 +) { + if (delegation.revokedBy) { + throw new ForbiddenError("Cette délégation a déjà été révoquée."); + } + + if (delegation.cancelledBy) { + throw new ForbiddenError("Cette délégation a été annulée."); + } + + const companyAssociation = await prisma.companyAssociation.findFirst({ + where: { + userId: user.id, + companyId: delegation.delegatorId + }, + select: { + role: true + } + }); + + if (!companyAssociation) { + throw new ForbiddenError( + "Vous devez faire partie de l'entreprise délégante d'une délégation pour pouvoir la révoquer." + ); + } + + if ( + !can(companyAssociation.role, Permission.CompanyCanManageRegistryDelegation) + ) { + throw new ForbiddenError( + "Vous n'avez pas les permissions suffisantes pour pouvoir révoquer une délégation." + ); + } +} + +export async function checkCanCancel( + user: User, + delegation: RegistryDelegation +) { + if (delegation.cancelledBy) { + throw new ForbiddenError("Cette délégation a déjà été annulée."); + } + + if (delegation.revokedBy) { + throw new ForbiddenError("Cette délégation a été révoquée."); + } + + const companyAssociation = await prisma.companyAssociation.findFirst({ + where: { + userId: user.id, + companyId: delegation.delegateId + }, + select: { + role: true + } + }); + + if (!companyAssociation) { + throw new ForbiddenError( + "Vous devez faire partie de l'entreprise délégataire d'une délégation pour pouvoir l'annuler." + ); + } + + if ( + !can(companyAssociation.role, Permission.CompanyCanManageRegistryDelegation) + ) { + throw new ForbiddenError( + "Vous n'avez pas les permissions suffisantes pour pouvoir annuler 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..6cc39114dc --- /dev/null +++ b/back/src/registryDelegation/resolvers/Mutation.ts @@ -0,0 +1,19 @@ +import { MutationResolvers } from "../../generated/graphql/types"; +import cancelRegistryDelegation from "./mutations/cancelRegistryDelegation"; +import createRegistryDelegation from "./mutations/createRegistryDelegation"; +import revokeRegistryDelegation from "./mutations/revokeRegistryDelegation"; + +export type RegistryDelegationMutationResolvers = Pick< + MutationResolvers, + | "createRegistryDelegation" + | "revokeRegistryDelegation" + | "cancelRegistryDelegation" +>; + +const Mutation: RegistryDelegationMutationResolvers = { + createRegistryDelegation, + revokeRegistryDelegation, + cancelRegistryDelegation +}; + +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..92f0a970aa --- /dev/null +++ b/back/src/registryDelegation/resolvers/__tests__/status.integration.ts @@ -0,0 +1,98 @@ +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([ + // REVOKED + [ + { revokedBy: "someuserid", startDate: nowPlusXHours(-2) }, + "REVOKED" as RegistryDelegationStatus + ], + [ + { revokedBy: "someuserid", startDate: nowPlusXHours(2) }, + "REVOKED" as RegistryDelegationStatus + ], + [ + { + revokedBy: "someuserid", + startDate: nowPlusXHours(2), + endDate: nowPlusXHours(3) + }, + "REVOKED" as RegistryDelegationStatus + ], + [ + { + revokedBy: "someuserid", + startDate: nowPlusXHours(-4), + endDate: nowPlusXHours(-3) + }, + "REVOKED" as RegistryDelegationStatus + ], + // CANCELLED + [ + { cancelledBy: "someuserid", startDate: nowPlusXHours(-2) }, + "CANCELLED" as RegistryDelegationStatus + ], + [ + { cancelledBy: "someuserid", startDate: nowPlusXHours(2) }, + "CANCELLED" as RegistryDelegationStatus + ], + [ + { + cancelledBy: "someuserid", + startDate: nowPlusXHours(2), + endDate: nowPlusXHours(3) + }, + "CANCELLED" as RegistryDelegationStatus + ], + [ + { + cancelledBy: "someuserid", + startDate: nowPlusXHours(-4), + endDate: nowPlusXHours(-3) + }, + "CANCELLED" as RegistryDelegationStatus + ], + // EXPIRED + [ + { + startDate: nowPlusXHours(-4), + endDate: nowPlusXHours(-3) + }, + "EXPIRED" 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__/cancelRegistryDelegation.integration.ts b/back/src/registryDelegation/resolvers/mutations/__tests__/cancelRegistryDelegation.integration.ts new file mode 100644 index 0000000000..803be8dfc2 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/__tests__/cancelRegistryDelegation.integration.ts @@ -0,0 +1,209 @@ +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 CANCEL_REGISTRY_DELEGATION = gql` + mutation cancelRegistryDelegation($delegationId: ID!) { + cancelRegistryDelegation(delegationId: $delegationId) { + id + status + delegate { + orgId + } + } + } +`; + +const cancelDelegation = async (user: User | null, delegationId: string) => { + const { mutate } = makeClient(user); + const { errors, data } = await mutate< + Pick + >(CANCEL_REGISTRY_DELEGATION, { + variables: { + delegationId + } + }); + + const delegation = await prisma.registryDelegation.findFirst({ + where: { id: delegationId } + }); + + return { data, errors, delegation }; +}; + +describe("mutation cancelRegistryDelegation", () => { + afterAll(resetDatabase); + + describe("successful use-cases", () => { + it("should cancel delegation", async () => { + // Given + const { delegation, delegateUser, delegateCompany } = + await registryDelegationFactory(); + + // When + const { + errors, + data, + delegation: updatedDelegation + } = await cancelDelegation(delegateUser, delegation.id); + + // Then + expect(errors).toBeUndefined(); + expect(data.cancelRegistryDelegation.status).toBe("CANCELLED"); + expect(data.cancelRegistryDelegation.delegate.orgId).toBe( + delegateCompany.orgId + ); + expect(updatedDelegation?.cancelledBy).toBe(delegateUser.id); + + // Should create an event + const eventsAfterCreate = await getStream(delegation!.id); + expect(eventsAfterCreate.length).toBe(1); + expect(eventsAfterCreate[0]).toMatchObject({ + type: "RegistryDelegationUpdated", + actor: delegateUser.id, + streamId: delegation!.id + }); + }); + + it("delegate can cancel delegation", async () => { + // Given + const { delegation, delegateUser, delegateCompany } = + await registryDelegationFactory(); + + // When + const { + errors, + data, + delegation: updatedDelegation + } = await cancelDelegation(delegateUser, delegation.id); + + // Then + expect(errors).toBeUndefined(); + expect(data.cancelRegistryDelegation.status).toBe("CANCELLED"); + expect(data.cancelRegistryDelegation.delegate.orgId).toBe( + delegateCompany.orgId + ); + expect(updatedDelegation?.cancelledBy).toBe(delegateUser.id); + + // Should create an event + const eventsAfterCreate = await getStream(delegation!.id); + expect(eventsAfterCreate.length).toBe(1); + expect(eventsAfterCreate[0]).toMatchObject({ + type: "RegistryDelegationUpdated", + actor: delegateUser.id, + streamId: delegation!.id + }); + }); + }); + + describe("authentication & roles", () => { + it("user must be authenticated", async () => { + // Given + const { delegation } = await registryDelegationFactory(); + + // When + const { errors } = await cancelDelegation(null, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Vous n'êtes pas connecté."); + }); + + it("user must belong to the delegate company", async () => { + // Given + const { delegation } = await registryDelegationFactory(); + const user = await userFactory(); + + // When + const { errors } = await cancelDelegation(user, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous devez faire partie de l'entreprise délégataire d'une délégation pour pouvoir l'annuler." + ); + }); + + it("user must be admin", async () => { + // Given + const { delegation, delegateCompany } = await registryDelegationFactory(); + const user = await userInCompany("MEMBER", delegateCompany.id); + + // When + const { errors } = await cancelDelegation(user, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous n'avez pas les permissions suffisantes pour pouvoir annuler une délégation." + ); + }); + + it("delegator can NOT cancel delegation", async () => { + // Given + const { delegation, delegatorUser } = await registryDelegationFactory(); + + // When + const { errors } = await cancelDelegation(delegatorUser, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous devez faire partie de l'entreprise délégataire d'une délégation pour pouvoir l'annuler." + ); + }); + }); + + describe("async validation", () => { + it("should throw if delegation does not exist", async () => { + // Given + const user = await userFactory(); + + // When + const { errors } = await cancelDelegation( + user, + "cxxxxxxxxxxxxxxxxxxxxxxxx" + ); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "La demande de délégation cxxxxxxxxxxxxxxxxxxxxxxxx n'existe pas." + ); + }); + + it("cannot cancel an already cancelled delegation", async () => { + // Given + const { delegation, delegateUser } = await registryDelegationFactory({ + cancelledBy: "someuserid" + }); + + // When + const { errors } = await cancelDelegation(delegateUser, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Cette délégation a déjà été annulée."); + }); + + it("cannot cancel a revoked delegation", async () => { + // Given + const { delegation, delegateUser } = await registryDelegationFactory({ + revokedBy: "someuserid" + }); + + // When + const { errors } = await cancelDelegation(delegateUser, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Cette délégation a été révoquée."); + }); + }); +}); 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..241a6ceec5 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/__tests__/createRegistryDelegation.integration.ts @@ -0,0 +1,699 @@ +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 + } + } +`; + +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(); + + // 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?.revokedBy).toBeFalsy(); + expect(delegation?.cancelledBy).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" + ); + }); + + it("delegator company must not be dormant", async () => { + // Given + const { user, company: delegator } = await userWithCompanyFactory( + "ADMIN", + { isDormantSince: new Date() } + ); + const delegate = await companyFactory(); + + // When + const { errors } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + `L'entreprise ${delegator.siret} visée comme délégante est en sommeil` + ); + }); + + it("delegate company must not be dormant", async () => { + // Given + const { user, company: delegator } = await userWithCompanyFactory(); + const delegate = await companyFactory({ isDormantSince: new Date() }); + + // When + const { errors } = await createDelegation(user, { + delegateOrgId: delegate.orgId, + delegatorOrgId: delegator.orgId + }); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + `L'entreprise ${delegate.siret} visée comme délégataire est en sommeil` + ); + }); + }); + + 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 revoqued", 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: { revokedBy: "someuserid" } + }); + + // 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 overlapping delegation but it's been cancelled", 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: { cancelledBy: "someuserid" } + }); + + // 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 revoked", 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: { revokedBy: "someuserid" } + }); + + // 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 cancelled", 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: { cancelledBy: "someuserid" } + }); + + // 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..3e89fcfc34 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/__tests__/revokeRegistryDelegation.integration.ts @@ -0,0 +1,197 @@ +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 + 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.status).toBe("REVOKED"); + expect(data.revokeRegistryDelegation.delegate.orgId).toBe( + delegateCompany.orgId + ); + expect(updatedDelegation?.revokedBy).toBe(delegatorUser.id); + + // 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.status).toBe("REVOKED"); + expect(updatedDelegation?.revokedBy).toBe(delegatorUser.id); + }); + }); + + 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 the delegator company", 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 d'une délégation pour pouvoir la révoquer." + ); + }); + + it("user must be admin", async () => { + // Given + const { delegation, delegatorCompany } = + await registryDelegationFactory(); + const user = await userInCompany("MEMBER", delegatorCompany.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 révoquer une délégation." + ); + }); + + it("delegate can NOT revoke delegation", async () => { + // Given + const { delegation, delegateUser } = await registryDelegationFactory(); + + // When + const { errors } = await revokeDelegation(delegateUser, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe( + "Vous devez faire partie de l'entreprise délégante d'une délégation pour pouvoir la révoquer." + ); + }); + }); + + 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." + ); + }); + + it("cannot revoke an already revoked delegation", async () => { + // Given + const { delegation, delegatorUser } = await registryDelegationFactory({ + revokedBy: "someuserid" + }); + + // When + const { errors } = await revokeDelegation(delegatorUser, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Cette délégation a déjà été révoquée."); + }); + + it("cannot revoke a cancelled delegation", async () => { + // Given + const { delegation, delegatorUser } = await registryDelegationFactory({ + cancelledBy: "someuserid" + }); + + // When + const { errors } = await revokeDelegation(delegatorUser, delegation.id); + + // Then + expect(errors).not.toBeUndefined(); + expect(errors[0].message).toBe("Cette délégation a été annulée."); + }); + }); +}); diff --git a/back/src/registryDelegation/resolvers/mutations/cancelRegistryDelegation.ts b/back/src/registryDelegation/resolvers/mutations/cancelRegistryDelegation.ts new file mode 100644 index 0000000000..c8d7f17765 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/cancelRegistryDelegation.ts @@ -0,0 +1,41 @@ +import { applyAuthStrategies, AuthType } from "../../../auth"; +import { checkIsAuthenticated } from "../../../common/permissions"; +import { + ResolversParentTypes, + RegistryDelegation, + MutationCancelRegistryDelegationArgs +} from "../../../generated/graphql/types"; +import { GraphQLContext } from "../../../types"; +import { checkCanCancel } from "../../permissions"; +import { parseMutationRevokeRegistryDelegationArgs } from "../../validation"; +import { fixTyping } from "../typing"; +import { findDelegationByIdOrThrow } from "../utils"; +import { cancelDelegation } from "./utils/cancelRegistryDelegation.utils"; + +const cancelRegistryDelegation = async ( + _: ResolversParentTypes["Mutation"], + args: MutationCancelRegistryDelegationArgs, + 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 cancel delegation + await checkCanCancel(user, delegation); + + // Cancel delegation + const cancelledDelegation = await cancelDelegation(user, delegation); + + return fixTyping(cancelledDelegation); +}; + +export default cancelRegistryDelegation; 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/cancelRegistryDelegation.utils.ts b/back/src/registryDelegation/resolvers/mutations/utils/cancelRegistryDelegation.utils.ts new file mode 100644 index 0000000000..b6049a62f3 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/utils/cancelRegistryDelegation.utils.ts @@ -0,0 +1,13 @@ +import { RegistryDelegation } from "@prisma/client"; +import { getRegistryDelegationRepository } from "../../../repository"; + +export const cancelDelegation = async ( + user: Express.User, + delegation: RegistryDelegation +) => { + const delegationRepository = getRegistryDelegationRepository(user); + return delegationRepository.update( + { id: delegation.id }, + { cancelledBy: user.id } + ); +}; 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..55c9d01a81 --- /dev/null +++ b/back/src/registryDelegation/resolvers/mutations/utils/createRegistryDelegation.utils.ts @@ -0,0 +1,111 @@ +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, non-cancelled delegation 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, + revokedBy: null, + cancelledBy: null, + 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..ecdc58f09d --- /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 }, + { revokedBy: user.id } + ); +}; 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..072bf6b7e2 --- /dev/null +++ b/back/src/registryDelegation/resolvers/queries/__tests__/registryDelegation.integration.ts @@ -0,0 +1,115 @@ +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 + 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..8e8faeb190 --- /dev/null +++ b/back/src/registryDelegation/resolvers/queries/__tests__/registryDelegations.integration.ts @@ -0,0 +1,297 @@ +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 + 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..9b7cab66ca --- /dev/null +++ b/back/src/registryDelegation/resolvers/subResolvers/delegate.ts @@ -0,0 +1,19 @@ +import { prisma } from "@td/prisma"; +import { + RegistryDelegation, + RegistryDelegationResolvers +} from "../../../generated/graphql/types"; +import { toGqlCompanyPrivate } from "../../../companies/converters"; + +const getDelegate = async (delegation: RegistryDelegation) => { + const company = await prisma.registryDelegation + .findUniqueOrThrow({ + where: { id: delegation.id } + }) + .delegate(); + + return toGqlCompanyPrivate(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..10327a834b --- /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 { toGqlCompanyPrivate } from "../../../companies/converters"; + +const getDelegator = async (delegation: RegistryDelegation) => { + const company = await prisma.registryDelegation + .findUniqueOrThrow({ + where: { id: delegation.id } + }) + .delegator(); + + return toGqlCompanyPrivate(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..c82bae7f8d --- /dev/null +++ b/back/src/registryDelegation/resolvers/subResolvers/status.ts @@ -0,0 +1,17 @@ +import { RegistryDelegation } from "@prisma/client"; +import { RegistryDelegationResolvers } from "../../../generated/graphql/types"; +import { getDelegationStatus } from "../utils"; + +/** + * For frontend's convenience, we compute the status here + */ +const getStatus = (delegation: RegistryDelegation) => { + return getDelegationStatus(delegation); +}; + +export const statusResolver: RegistryDelegationResolvers["status"] = + // Little cheat here on the typing, because we don't want to send the + // revokedBy & cancelledBy to the front so they are excluded from the GQL type, + // however we need them to compute the status. So we use the Prisma RegistryDelegation + // here, and force the typing + delegation => getStatus(delegation as unknown as RegistryDelegation); 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..321242be03 --- /dev/null +++ b/back/src/registryDelegation/resolvers/utils.ts @@ -0,0 +1,105 @@ +import { prisma } from "@td/prisma"; +import { UserInputError } from "../../common/errors"; +import { getRegistryDelegationRepository } from "../repository"; +import { Company, Prisma, RegistryDelegation } from "@prisma/client"; +import { 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 (delegator.isDormantSince) { + throw new UserInputError( + `L'entreprise ${delegatorOrgId} visée comme délégante est en sommeil` + ); + } + + if (!delegate) { + throw new UserInputError( + `L'entreprise ${delegateOrgId} visée comme délégataire n'existe pas dans Trackdéchets` + ); + } + + if (delegate.isDormantSince) { + throw new UserInputError( + `L'entreprise ${delegateOrgId} visée comme délégataire est en sommeil` + ); + } + + 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 { revokedBy, cancelledBy, startDate, endDate } = delegation; + + if (revokedBy) return "REVOKED" as RegistryDelegationStatus; + + if (cancelledBy) return "CANCELLED" as RegistryDelegationStatus; + + if (startDate > NOW) return "INCOMING" as RegistryDelegationStatus; + + if (!endDate || endDate > NOW) return "ONGOING" as RegistryDelegationStatus; + + return "EXPIRED" 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..eb887eb34c --- /dev/null +++ b/back/src/registryDelegation/typeDefs/private/registryDelegation.enums.graphql @@ -0,0 +1,29 @@ +""" +Statut d'une demande de délégation +""" +enum RegistryDelegationStatus { + """ + À venir + """ + INCOMING + + """ + En cours + """ + ONGOING + + """ + Annulée + """ + CANCELLED + + """ + Révoquée + """ + REVOKED + + """ + Expirée + """ + EXPIRED +} 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..7592b933e7 --- /dev/null +++ b/back/src/registryDelegation/typeDefs/private/registryDelegation.mutations.graphql @@ -0,0 +1,21 @@ +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égant. Une fois révoquée, une délégation ne peut plus être modifiée. + """ + revokeRegistryDelegation(delegationId: ID!): RegistryDelegation! + + """ + Permet d'annuler une demande de délégation. Peut être utilisé par le délégataire. + Une fois annulée, une délégation ne peut plus être modifiée. + """ + cancelRegistryDelegation(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..faa94cc94a --- /dev/null +++ b/back/src/registryDelegation/typeDefs/private/registryDelegation.objects.graphql @@ -0,0 +1,66 @@ +""" +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 + + """ + 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/scripts/bin/cleanUpIsReturnForTab.ts b/back/src/scripts/bin/cleanUpIsReturnForTab.ts new file mode 100644 index 0000000000..2274037fb4 --- /dev/null +++ b/back/src/scripts/bin/cleanUpIsReturnForTab.ts @@ -0,0 +1,15 @@ +import { logger } from "@td/logger"; +import { cleanUpIsReturnForTab } from "../../common/elasticHelpers"; + +(async () => { + try { + const body = await cleanUpIsReturnForTab(); + + logger.info( + `[cleanUpIsReturnForTab] Update ended! ${body.updated} bsds updated in ${body.took}ms!` + ); + } catch (error) { + logger.error("Error in cleanUpIsReturnForTab script, exiting", error); + throw new Error(`Error in cleanUpIsReturnForTab script : ${error}`); + } +})(); diff --git a/back/src/users/__tests__/database.integration.ts b/back/src/users/__tests__/database.integration.ts index e56d8e230d..c19bbc8be7 100644 --- a/back/src/users/__tests__/database.integration.ts +++ b/back/src/users/__tests__/database.integration.ts @@ -8,6 +8,8 @@ import { } from "../../__tests__/factories"; import { associateUserToCompany, createUserAccountHash } from "../database"; import { getUserRoles } from "../../permissions"; +import { UserRole } from "@prisma/client"; +import { getDefaultNotifications } from "../notifications"; describe("createUserAccountHash", () => { afterAll(resetDatabase); @@ -50,7 +52,7 @@ describe("associateUserToCompany", () => { it("should throw error if association already exists", async () => { const { user, company } = await userWithCompanyFactory("MEMBER"); try { - await associateUserToCompany(user.id, company.siret, "MEMBER"); + await associateUserToCompany(user.id, company.orgId, "MEMBER"); } catch (err) { expect(err.extensions.code).toEqual(ErrorCode.BAD_USER_INPUT); expect(err.message).toEqual( @@ -59,16 +61,29 @@ describe("associateUserToCompany", () => { } }); - it("should associate an user to a company", async () => { - const user = await userFactory(); - const company = await companyFactory(); + it.each([UserRole.ADMIN, UserRole.MEMBER, UserRole.READER, UserRole.DRIVER])( + "should associate a user to a company with tole %p", + async role => { + const user = await userFactory(); + const company = await companyFactory(); - await associateUserToCompany(user.id, company.siret, "MEMBER"); - const refreshedUser = await prisma.user.findUniqueOrThrow({ - where: { id: user.id } - }); - const userRoles = await getUserRoles(user.id); - expect(userRoles).toEqual({ [company.orgId]: "MEMBER" }); - expect(refreshedUser.firstAssociationDate).toBeTruthy(); - }); + await associateUserToCompany(user.id, company.orgId, role); + const refreshedUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id } + }); + const companyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + expect(companyAssociation.role).toEqual(role); + + const expectedNotifications = getDefaultNotifications(role); + + expect(companyAssociation.notifications).toEqual(expectedNotifications); + + const userRoles = await getUserRoles(user.id); + expect(userRoles).toEqual({ [company.orgId]: role }); + expect(refreshedUser.firstAssociationDate).toBeTruthy(); + } + ); }); diff --git a/back/src/users/database.ts b/back/src/users/database.ts index bc61f1c851..451e377b37 100644 --- a/back/src/users/database.ts +++ b/back/src/users/database.ts @@ -17,6 +17,7 @@ import { deleteCachedUserRoles } from "../common/redis/users"; import { hashPassword, passwordVersion } from "./utils"; import { UserInputError } from "../common/errors"; import { PrismaTransaction } from "../common/repository/types"; +import { ALL_NOTIFICATIONS, getDefaultNotifications } from "./notifications"; export async function getUserCompanies(userId: string): Promise { const companyAssociations = await prisma.companyAssociation.findMany({ @@ -87,9 +88,9 @@ export async function deleteUserAccountHash( * @param role */ export async function associateUserToCompany( - userId, - orgId, - role, + userId: string, + orgId: string, + role: UserRole, opt: Partial = {} ) { // check for current associations @@ -110,11 +111,14 @@ export async function associateUserToCompany( ); } + const notifications = getDefaultNotifications(role); + const association = await prisma.companyAssociation.create({ data: { user: { connect: { id: userId } }, role, company: { connect: { orgId } }, + notifications, ...opt } }); @@ -203,7 +207,9 @@ export async function acceptNewUserCompanyInvitations(user: User) { data: { company: { connect: { orgId: existingHash.companySiret } }, user: { connect: { id: user.id } }, - role: existingHash.role + role: existingHash.role, + notifications: + existingHash.role === UserRole.ADMIN ? ALL_NOTIFICATIONS : [] } }) ) diff --git a/back/src/users/notifications.ts b/back/src/users/notifications.ts new file mode 100644 index 0000000000..f6a3e8574d --- /dev/null +++ b/back/src/users/notifications.ts @@ -0,0 +1,70 @@ +import { UserNotification, UserRole } from "@prisma/client"; +import { Recipient } from "@td/mail"; +import { prisma } from "@td/prisma"; + +// Récupère la liste des des utilisateurs abonnés à un type +// de notification donnée au sein d'un ou plusieurs établissements +export async function getNotificationSubscribers( + notification: UserNotification, + orgIds: string[] +): Promise { + const companies = await prisma.company.findMany({ + where: { orgId: { in: orgIds } }, + include: { + companyAssociations: { + where: { + notifications: { has: notification }, + user: { + isActive: true + } + }, + include: { + user: { select: { name: true, email: true } } + } + } + } + }); + + return companies.flatMap(c => c.companyAssociations.map(a => a.user)); +} + +/** + * Renvoie les notififications auxquelles un utilisateur est abonné par + * défaut lorsqu'il rejoint un établissement ou change de rôle + */ +export function getDefaultNotifications(role: UserRole) { + return role === UserRole.ADMIN ? ALL_NOTIFICATIONS : []; +} + +// if you modify this structure, please modify +// in front/src/common/notifications +export const ALL_NOTIFICATIONS: UserNotification[] = [ + UserNotification.MEMBERSHIP_REQUEST, + UserNotification.REVISION_REQUEST, + UserNotification.BSD_REFUSAL, + UserNotification.SIGNATURE_CODE_RENEWAL, + UserNotification.BSDA_FINAL_DESTINATION_UPDATE +]; + +// if you modify this structure, please modify +// in front/src/common/notifications +export const authorizedNotifications = { + [UserRole.ADMIN]: ALL_NOTIFICATIONS, + [UserRole.MEMBER]: [ + UserNotification.REVISION_REQUEST, + UserNotification.BSD_REFUSAL, + UserNotification.SIGNATURE_CODE_RENEWAL, + UserNotification.BSDA_FINAL_DESTINATION_UPDATE + ], + [UserRole.READER]: [ + UserNotification.REVISION_REQUEST, + UserNotification.BSD_REFUSAL, + UserNotification.SIGNATURE_CODE_RENEWAL, + UserNotification.BSDA_FINAL_DESTINATION_UPDATE + ], + DRIVER: [ + UserNotification.BSD_REFUSAL, + UserNotification.SIGNATURE_CODE_RENEWAL, + UserNotification.BSDA_FINAL_DESTINATION_UPDATE + ] +}; diff --git a/back/src/users/resolvers/Mutation.ts b/back/src/users/resolvers/Mutation.ts index 96b8205c96..f86ecec4ef 100644 --- a/back/src/users/resolvers/Mutation.ts +++ b/back/src/users/resolvers/Mutation.ts @@ -19,6 +19,7 @@ import revokeAllAccessTokens from "./mutations/revokeAllAccessTokens"; import resetPassword from "./mutations/resetPassword"; import anonymizeUser from "./mutations/anonymizeUser"; import changeUserRole from "./mutations/changeUserRole"; +import setCompanyNotifications from "./mutations/setCompanyNotifications"; const Mutation: MutationResolvers = { signup, @@ -40,7 +41,8 @@ const Mutation: MutationResolvers = { revokeAccessToken, createAccessToken, revokeAllAccessTokens, - changeUserRole + changeUserRole, + setCompanyNotifications }; export default Mutation; diff --git a/back/src/users/resolvers/User.ts b/back/src/users/resolvers/User.ts index 2885e86964..aa0534b207 100644 --- a/back/src/users/resolvers/User.ts +++ b/back/src/users/resolvers/User.ts @@ -1,7 +1,7 @@ import { libelleFromCodeNaf } from "../../companies/sirene/utils"; -import { convertUrls } from "../../companies/database"; import { UserResolvers, CompanyPrivate } from "../../generated/graphql/types"; import { prisma } from "@td/prisma"; +import { toGqlCompanyPrivate } from "../../companies/converters"; const userResolvers: UserResolvers = { // Returns the list of companies a user belongs to @@ -18,7 +18,7 @@ const userResolvers: UserResolvers = { })); return companies.map(async company => { - const companyPrivate: CompanyPrivate = convertUrls(company); + const companyPrivate: CompanyPrivate = toGqlCompanyPrivate(company); const { codeNaf: naf, address } = company; const libelleNaf = libelleFromCodeNaf(naf!); diff --git a/back/src/users/resolvers/mutations/__tests__/acceptMembershipRequest.integration.ts b/back/src/users/resolvers/mutations/__tests__/acceptMembershipRequest.integration.ts index 10ef4a135b..8a223e4b64 100644 --- a/back/src/users/resolvers/mutations/__tests__/acceptMembershipRequest.integration.ts +++ b/back/src/users/resolvers/mutations/__tests__/acceptMembershipRequest.integration.ts @@ -9,6 +9,8 @@ import { import makeClient from "../../../../__tests__/testClient"; import { renderMail, membershipRequestAccepted } from "@td/mail"; import { Mutation } from "../../../../generated/graphql/types"; +import { UserRole } from "@prisma/client"; +import { ALL_NOTIFICATIONS } from "../../../notifications"; // No mails jest.mock("../../../../mailer/mailing"); @@ -112,7 +114,7 @@ describe("mutation acceptMembershipRequest", () => { ); }); - it.each(["MEMBER", "ADMIN"])( + it.each([UserRole.ADMIN, UserRole.MEMBER, UserRole.READER, UserRole.DRIVER])( "should associate requesting user to company with role %p", async role => { const { user, company } = await userWithCompanyFactory("ADMIN"); @@ -151,6 +153,12 @@ describe("mutation acceptMembershipRequest", () => { expect(companyAssociations).toHaveLength(1); expect(companyAssociations[0].role).toEqual(role); + const expectedNotifications = role === "ADMIN" ? ALL_NOTIFICATIONS : []; + + expect(companyAssociations[0].notifications).toEqual( + expectedNotifications + ); + // when a new user is invited and accepts invitation, `automaticallyAccepted` is false expect(companyAssociations[0].automaticallyAccepted).toEqual(false); diff --git a/back/src/users/resolvers/mutations/__tests__/changeUserRole.integration.ts b/back/src/users/resolvers/mutations/__tests__/changeUserRole.integration.ts index b67bf8b927..42254ca280 100644 --- a/back/src/users/resolvers/mutations/__tests__/changeUserRole.integration.ts +++ b/back/src/users/resolvers/mutations/__tests__/changeUserRole.integration.ts @@ -9,6 +9,8 @@ import { hash } from "bcrypt"; import { AuthType } from "../../../../auth"; import { Mutation } from "../../../../generated/graphql/types"; import { ErrorCode, NotCompanyAdminErrorMsg } from "../../../../common/errors"; +import { UserRole } from "@prisma/client"; +import { ALL_NOTIFICATIONS } from "../../../notifications"; const CHANGE_USER_ROLE = ` mutation ChangeUserRole($userId: ID!, $orgId: ID!, $role: UserRole!){ @@ -22,36 +24,155 @@ const CHANGE_USER_ROLE = ` describe("mutation changeUserRole", () => { afterAll(resetDatabase); - test("admin can change a company user's role", async () => { - const { user: admin, company } = await userWithCompanyFactory("ADMIN"); - const userToModify = await userFactory({ - companyAssociations: { - create: { - company: { connect: { id: company.id } }, - role: "MEMBER" + test.each([UserRole.MEMBER, UserRole.READER, UserRole.DRIVER])( + "admin can change a company user with role ADMIN to role %p", + async role => { + const { user: admin, company } = await userWithCompanyFactory("ADMIN"); + const userToModify = await userFactory({ + companyAssociations: { + create: { + company: { connect: { id: company.id } }, + role: UserRole.ADMIN, + notifications: ALL_NOTIFICATIONS + } } - } - }); + }); - const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); - const { data } = await mutate>( - CHANGE_USER_ROLE, - { - variables: { - userId: userToModify.id, - orgId: company.orgId, - role: "ADMIN" + const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); + const { data } = await mutate>( + CHANGE_USER_ROLE, + { + variables: { + userId: userToModify.id, + orgId: company.orgId, + role + } } - } - ); - expect(data.changeUserRole.email).toEqual(userToModify.email); - expect(data.changeUserRole.role).toEqual("ADMIN"); - const companyAssociations = await prisma.user - .findUniqueOrThrow({ where: { id: userToModify.id } }) - .companyAssociations(); - expect(companyAssociations).toHaveLength(1); - expect(companyAssociations[0].role).toEqual("ADMIN"); - }); + ); + expect(data.changeUserRole.email).toEqual(userToModify.email); + expect(data.changeUserRole.role).toEqual(role); + const companyAssociations = await prisma.user + .findUniqueOrThrow({ where: { id: userToModify.id } }) + .companyAssociations(); + expect(companyAssociations).toHaveLength(1); + expect(companyAssociations[0].role).toEqual(role); + expect(companyAssociations[0].notifications).toEqual([]); + } + ); + + test.each([UserRole.ADMIN, UserRole.READER, UserRole.DRIVER])( + "admin can change a company user with role MEMBER to role %p", + async role => { + const { user: admin, company } = await userWithCompanyFactory("ADMIN"); + const userToModify = await userFactory({ + companyAssociations: { + create: { + company: { connect: { id: company.id } }, + role: UserRole.MEMBER, + notifications: [] + } + } + }); + + const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); + const { data } = await mutate>( + CHANGE_USER_ROLE, + { + variables: { + userId: userToModify.id, + orgId: company.orgId, + role + } + } + ); + expect(data.changeUserRole.email).toEqual(userToModify.email); + expect(data.changeUserRole.role).toEqual(role); + const companyAssociations = await prisma.user + .findUniqueOrThrow({ where: { id: userToModify.id } }) + .companyAssociations(); + expect(companyAssociations).toHaveLength(1); + expect(companyAssociations[0].role).toEqual(role); + expect(companyAssociations[0].notifications).toEqual( + role === UserRole.ADMIN ? ALL_NOTIFICATIONS : [] + ); + } + ); + + test.each([UserRole.ADMIN, UserRole.MEMBER, UserRole.DRIVER])( + "admin can change a company user with role READER to role %p", + async role => { + const { user: admin, company } = await userWithCompanyFactory("ADMIN"); + const userToModify = await userFactory({ + companyAssociations: { + create: { + company: { connect: { id: company.id } }, + role: UserRole.READER, + notifications: [] + } + } + }); + + const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); + const { data } = await mutate>( + CHANGE_USER_ROLE, + { + variables: { + userId: userToModify.id, + orgId: company.orgId, + role + } + } + ); + expect(data.changeUserRole.email).toEqual(userToModify.email); + expect(data.changeUserRole.role).toEqual(role); + const companyAssociations = await prisma.user + .findUniqueOrThrow({ where: { id: userToModify.id } }) + .companyAssociations(); + expect(companyAssociations).toHaveLength(1); + expect(companyAssociations[0].role).toEqual(role); + expect(companyAssociations[0].notifications).toEqual( + role === UserRole.ADMIN ? ALL_NOTIFICATIONS : [] + ); + } + ); + + test.each([UserRole.ADMIN, UserRole.MEMBER, UserRole.READER])( + "admin can change a company user with role DRIVER to role %p", + async role => { + const { user: admin, company } = await userWithCompanyFactory("ADMIN"); + const userToModify = await userFactory({ + companyAssociations: { + create: { + company: { connect: { id: company.id } }, + role: UserRole.DRIVER, + notifications: [] + } + } + }); + + const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); + const { data } = await mutate>( + CHANGE_USER_ROLE, + { + variables: { + userId: userToModify.id, + orgId: company.orgId, + role + } + } + ); + expect(data.changeUserRole.email).toEqual(userToModify.email); + expect(data.changeUserRole.role).toEqual(role); + const companyAssociations = await prisma.user + .findUniqueOrThrow({ where: { id: userToModify.id } }) + .companyAssociations(); + expect(companyAssociations).toHaveLength(1); + expect(companyAssociations[0].role).toEqual(role); + expect(companyAssociations[0].notifications).toEqual( + role === UserRole.ADMIN ? ALL_NOTIFICATIONS : [] + ); + } + ); test("admin can change an invited user's role", async () => { const { user: admin, company } = await userWithCompanyFactory("ADMIN"); diff --git a/back/src/users/resolvers/mutations/__tests__/inviteUserToCompany.integration.ts b/back/src/users/resolvers/mutations/__tests__/inviteUserToCompany.integration.ts index cf7b8da6dd..84415599c6 100644 --- a/back/src/users/resolvers/mutations/__tests__/inviteUserToCompany.integration.ts +++ b/back/src/users/resolvers/mutations/__tests__/inviteUserToCompany.integration.ts @@ -10,6 +10,9 @@ import { prisma } from "@td/prisma"; import { AuthType } from "../../../../auth"; import { Mutation } from "../../../../generated/graphql/types"; import { ErrorCode, NotCompanyAdminErrorMsg } from "../../../../common/errors"; +import { UserRole } from "@prisma/client"; +import { templateIds } from "@td/mail"; +import { getDefaultNotifications } from "../../../notifications"; const INVITE_USER_TO_COMPANY = ` mutation InviteUserToCompany($email: String!, $siret: String!, $role: UserRole!){ @@ -32,95 +35,119 @@ beforeEach(() => { describe("mutation inviteUserToCompany", () => { afterAll(resetDatabase); - test("admin user can invite existing user to company", async () => { - const { user: admin, company } = await userWithCompanyFactory("ADMIN"); - const user = await userFactory(); - const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); - const { data } = await mutate>( - INVITE_USER_TO_COMPANY, - { - variables: { email: user.email, siret: company.siret, role: "MEMBER" } - } - ); - expect(data.inviteUserToCompany.users!.length).toBe(2); - expect(data.inviteUserToCompany.users).toEqual( - expect.arrayContaining([{ email: admin.email }, { email: user.email }]) - ); - const companyAssociations = await prisma.user - .findUniqueOrThrow({ where: { id: user.id } }) - .companyAssociations(); - expect(companyAssociations).toHaveLength(1); - expect(companyAssociations[0].role).toEqual("MEMBER"); - - // when invited user was already on TD, `automaticallyAccepted` is true - expect(companyAssociations[0].automaticallyAccepted).toEqual(true); - expect(companyAssociations[0].createdAt).toBeTruthy(); - - const userCompany = await prisma.companyAssociation - .findUniqueOrThrow({ - where: { - id: companyAssociations[0].id + test.each([ + UserRole.ADMIN, + UserRole.MEMBER, + UserRole.READER, + UserRole.DRIVER + ])( + "admin user can invite existing user to company with role %p", + async role => { + const { user: admin, company } = await userWithCompanyFactory("ADMIN"); + const user = await userFactory(); + const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); + const { data } = await mutate>( + INVITE_USER_TO_COMPANY, + { + variables: { email: user.email, siret: company.siret, role } } - }) - .company(); - expect(userCompany?.siret).toEqual(company.siret); - expect(userCompany?.siret).toEqual(company.siret); - }); - - test("admin user can invite a new user to a company", async () => { - // set up an user, a company, its admin and an invitation (UserAccountHash) - const { user: admin, company } = await userWithCompanyFactory("ADMIN"); - - const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); - - // Call the mutation to send an invitation - const invitedUserEmail = "newuser@example.test"; - await mutate(INVITE_USER_TO_COMPANY, { - variables: { - email: invitedUserEmail, - siret: company.siret, - role: "MEMBER" - } - }); - - // Check userAccountHash has been successfully created - const hashes = await prisma.userAccountHash.findMany({ - where: { email: invitedUserEmail, companySiret: company.siret! } - }); - expect(hashes.length).toEqual(1); - - // Check email was sent - const hashValue = hashes[0].hash; - - // Check that the job was added to the queue - expect(addToMailQueue as jest.Mock as jest.Mock).toHaveBeenCalledTimes( - 1 - ); - - const addJobArgs: any = (addToMailQueue as jest.Mock).mock.calls[0]; - - // the right payload - expect(addJobArgs[0]).toMatchObject({ - subject: "Vous avez été invité à rejoindre Trackdéchets", - templateId: 9, - to: [{ email: "newuser@example.test", name: "newuser@example.test" }], - vars: { - API_URL: "http://api.trackdechets.local", - UI_URL: "http://trackdechets.local", - companyName: company.name, - companyOrgId: company.siret, - hash: encodeURIComponent(hashValue) - } - }); - expect(addJobArgs[0].body).toContain( - `vous a invité à rejoindre\n Trackdéchets` - ); - expect(addJobArgs[0].body).toContain( - `` - ); - }); + ); + expect(data.inviteUserToCompany.users!.length).toBe(2); + expect(data.inviteUserToCompany.users).toEqual( + expect.arrayContaining([{ email: admin.email }, { email: user.email }]) + ); + const companyAssociations = await prisma.user + .findUniqueOrThrow({ where: { id: user.id } }) + .companyAssociations(); + expect(companyAssociations).toHaveLength(1); + expect(companyAssociations[0].role).toEqual(role); + + // when invited user was already on TD, `automaticallyAccepted` is true + expect(companyAssociations[0].automaticallyAccepted).toEqual(true); + expect(companyAssociations[0].createdAt).toBeTruthy(); + + const expectedEmailNotification = getDefaultNotifications(role); + + expect(companyAssociations[0].notifications).toEqual( + expectedEmailNotification + ); + + const userCompany = await prisma.companyAssociation + .findUniqueOrThrow({ + where: { + id: companyAssociations[0].id + } + }) + .company(); + expect(userCompany?.siret).toEqual(company.siret); + expect(userCompany?.siret).toEqual(company.siret); + } + ); + + test.each([ + UserRole.ADMIN, + UserRole.MEMBER, + UserRole.READER, + UserRole.DRIVER + ])( + "admin user can invite a new user to a company with role %p", + async role => { + // set up an user, a company, its admin and an invitation (UserAccountHash) + const { user: admin, company } = await userWithCompanyFactory("ADMIN"); + + const { mutate } = makeClient({ ...admin, auth: AuthType.Session }); + + // Call the mutation to send an invitation + const invitedUserEmail = "newuser@example.test"; + await mutate(INVITE_USER_TO_COMPANY, { + variables: { + email: invitedUserEmail, + siret: company.siret, + role + } + }); + + // Check userAccountHash has been successfully created + const hashes = await prisma.userAccountHash.findMany({ + where: { email: invitedUserEmail, companySiret: company.siret! } + }); + expect(hashes.length).toEqual(1); + + expect(hashes[0].role).toEqual(role); + + // Check email was sent + const hashValue = hashes[0].hash; + + // Check that the job was added to the queue + expect( + addToMailQueue as jest.Mock as jest.Mock + ).toHaveBeenCalledTimes(1); + + const addJobArgs: any = (addToMailQueue as jest.Mock).mock.calls[0]; + + // the right payload + expect(addJobArgs[0]).toMatchObject({ + subject: "Vous avez été invité à rejoindre Trackdéchets", + templateId: templateIds.LAYOUT, + to: [{ email: "newuser@example.test", name: "newuser@example.test" }], + vars: { + API_URL: "http://api.trackdechets.local", + UI_URL: "http://trackdechets.local", + companyName: company.name, + companyOrgId: company.siret, + hash: encodeURIComponent(hashValue) + } + }); + expect(addJobArgs[0].body).toContain( + `vous a invité à rejoindre\n Trackdéchets` + ); + expect(addJobArgs[0].body).toContain( + `` + ); + } + ); test("TD admin user can invite a new user to a company", async () => { const company = await companyFactory(); diff --git a/back/src/users/resolvers/mutations/__tests__/joinWithInvite.integration.ts b/back/src/users/resolvers/mutations/__tests__/joinWithInvite.integration.ts index 8bad71374f..08bf645c8f 100644 --- a/back/src/users/resolvers/mutations/__tests__/joinWithInvite.integration.ts +++ b/back/src/users/resolvers/mutations/__tests__/joinWithInvite.integration.ts @@ -4,6 +4,8 @@ import { companyFactory } from "../../../../__tests__/factories"; import { getUserCompanies } from "../../../database"; import makeClient from "../../../../__tests__/testClient"; import { Mutation } from "../../../../generated/graphql/types"; +import { UserRole } from "@prisma/client"; +import { getDefaultNotifications } from "../../../notifications"; const JOIN_WITH_INVITE = ` mutation JoinWithInvite($inviteHash: String!, $name: String!, $password: String!){ @@ -57,56 +59,64 @@ describe("joinWithInvite mutation", () => { expect(errors[0].message).toEqual("Cette invitation a déjà été acceptée"); }); - it("should create user, associate it to company and mark invitation as joined", async () => { - const company = await companyFactory(); - const invitee = "john.snow@trackdechets.fr"; - - const invitation = await prisma.userAccountHash.create({ - data: { - email: invitee, - companySiret: company.siret!, - role: "MEMBER", - hash: "hash" - } - }); - - const { data } = await mutate>( - JOIN_WITH_INVITE, - { - variables: { - inviteHash: invitation.hash, - name: "John Snow", - password: "password" + it.each([UserRole.ADMIN, UserRole.MEMBER, UserRole.READER, UserRole.DRIVER])( + "should create user, associate it to company with role %p and mark invitation as joined", + async role => { + const company = await companyFactory(); + const invitee = "john.snow@trackdechets.fr"; + + const invitation = await prisma.userAccountHash.create({ + data: { + email: invitee, + companySiret: company.siret!, + role, + hash: "hash" } - } - ); + }); + + const { data } = await mutate>( + JOIN_WITH_INVITE, + { + variables: { + inviteHash: invitation.hash, + name: "John Snow", + password: "password" + } + } + ); - expect(data.joinWithInvite.email).toEqual(invitee); + expect(data.joinWithInvite.email).toEqual(invitee); - // should mark invitation as joined - const updatedInvitation = await prisma.userAccountHash.findUniqueOrThrow({ - where: { - id: invitation.id - } - }); - expect(updatedInvitation.acceptedAt).not.toBeNull(); + // should mark invitation as joined + const updatedInvitation = await prisma.userAccountHash.findUniqueOrThrow({ + where: { + id: invitation.id + } + }); + expect(updatedInvitation.acceptedAt).not.toBeNull(); - // check invitee is company member - const isCompanyMember = - (await prisma.companyAssociation.findFirst({ + const companyAssociation = await prisma.companyAssociation.findFirst({ where: { user: { email: invitee }, company: { siret: company.siret } } - })) != null; - expect(isCompanyMember).toEqual(true); + }); - const createdUser = await prisma.user.findUniqueOrThrow({ - where: { email: invitee } - }); - expect(createdUser.activatedAt).toBeTruthy(); - expect(createdUser.firstAssociationDate).toBeTruthy(); - }); + // check invitee is company member + expect(companyAssociation).not.toBeNull(); + expect(companyAssociation?.role).toEqual(role); + + const expectedNotifications = getDefaultNotifications(role); + + expect(companyAssociation?.notifications).toEqual(expectedNotifications); + + const createdUser = await prisma.user.findUniqueOrThrow({ + where: { email: invitee } + }); + expect(createdUser.activatedAt).toBeTruthy(); + expect(createdUser.firstAssociationDate).toBeTruthy(); + } + ); it("should accept other pending invitations", async () => { const company1 = await companyFactory(); diff --git a/back/src/users/resolvers/mutations/__tests__/sendMembershipRequest.integration.ts b/back/src/users/resolvers/mutations/__tests__/sendMembershipRequest.integration.ts index 3ce3a23e2e..e565189731 100644 --- a/back/src/users/resolvers/mutations/__tests__/sendMembershipRequest.integration.ts +++ b/back/src/users/resolvers/mutations/__tests__/sendMembershipRequest.integration.ts @@ -16,6 +16,7 @@ import { } from "@td/mail"; import { Mutation } from "../../../../generated/graphql/types"; import { subMinutes } from "date-fns"; +import { UserNotification } from "@prisma/client"; // No mails jest.mock("../../../../mailer/mailing"); @@ -54,8 +55,19 @@ describe("mutation sendMembershipRequest", () => { email: "john.snow@trackdechets.fr", createdAt: subMinutes(new Date(), 5) }); + const admin2 = await userFactory({ + email: "aria.starck@trackdechets.fr", + createdAt: subMinutes(new Date(), 5) + }); const company = await companyFactory(); - await associateUserToCompany(admin.id, company.siret, "ADMIN"); + // this user should receive the notification + await associateUserToCompany(admin.id, company.orgId, "ADMIN", { + notifications: [UserNotification.MEMBERSHIP_REQUEST] + }); + // this user should not receive the notification + await associateUserToCompany(admin2.id, company.orgId, "ADMIN", { + notifications: [] + }); const { mutate } = makeClient(requester); const { data } = await mutate>( SEND_MEMBERSHIP_REQUEST, @@ -127,7 +139,7 @@ describe("mutation sendMembershipRequest", () => { createdAt: subMinutes(new Date(), 5) }); const company = await companyFactory(); - await associateUserToCompany(admin.id, company.siret, "ADMIN"); + await associateUserToCompany(admin.id, company.orgId, "ADMIN"); const { mutate } = makeClient(requester); const { data } = await mutate>( SEND_MEMBERSHIP_REQUEST, @@ -248,7 +260,7 @@ describe("mutation sendMembershipRequest", () => { email: `admin${userIndex}@trackdechets.fr` }); const company = await companyFactory(); - await associateUserToCompany(admin.id, company.siret, "ADMIN"); + await associateUserToCompany(admin.id, company.orgId, "ADMIN"); await prisma.membershipRequest.create({ data: { diff --git a/back/src/users/resolvers/mutations/__tests__/setCompanyNotifications.integration.ts b/back/src/users/resolvers/mutations/__tests__/setCompanyNotifications.integration.ts new file mode 100644 index 0000000000..8f42836d64 --- /dev/null +++ b/back/src/users/resolvers/mutations/__tests__/setCompanyNotifications.integration.ts @@ -0,0 +1,308 @@ +import gql from "graphql-tag"; +import { + companyFactory, + userFactory, + userWithCompanyFactory +} from "../../../../__tests__/factories"; +import makeClient from "../../../../__tests__/testClient"; +import { + Mutation, + MutationSetCompanyNotificationsArgs +} from "../../../../generated/graphql/types"; +import { prisma } from "@td/prisma"; +import { UserNotification, UserRole } from "@prisma/client"; +import { ALL_NOTIFICATIONS } from "../../../notifications"; + +export const SET_COMPANY_NOTIFICATIONS = gql` + mutation SetCompanyNotifications($input: SetCompanyNotificationsInput!) { + setCompanyNotifications(input: $input) { + id + orgId + userNotifications + } + } +`; + +describe("Mutation { setCompanyNotifications }", () => { + test("Users who don't belong to company cannot subscribe to notifications", async () => { + const user = await userFactory(); + const company = await companyFactory(); + + const { mutate } = makeClient(user); + + const newNotifications = [UserNotification.MEMBERSHIP_REQUEST]; + + const { errors } = await mutate< + Pick, + MutationSetCompanyNotificationsArgs + >(SET_COMPANY_NOTIFICATIONS, { + variables: { + input: { + companyOrgId: company.siret!, + notifications: newNotifications + } + } + }); + + expect(errors).toEqual([ + expect.objectContaining({ + message: `Vous n'êtes pas membre de l'entreprise portant le siret "${company.siret}".` + }) + ]); + }); + + test.each(ALL_NOTIFICATIONS)( + "User with role ADMIN can subscribe to notification %p", + async notification => { + const { user, company } = await userWithCompanyFactory(UserRole.ADMIN); + + const companyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(companyAssociation.notifications).toEqual(ALL_NOTIFICATIONS); + + const { mutate } = makeClient(user); + + const newNotifications = [notification]; + + const { data, errors } = await mutate< + Pick, + MutationSetCompanyNotificationsArgs + >(SET_COMPANY_NOTIFICATIONS, { + variables: { + input: { + companyOrgId: company.orgId, + notifications: newNotifications + } + } + }); + + expect(errors).toBeUndefined(); + + expect(data.setCompanyNotifications.userNotifications).toEqual( + newNotifications + ); + + const updatedCompanyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(updatedCompanyAssociation.notifications).toEqual(newNotifications); + } + ); + + test.each([ + UserNotification.REVISION_REQUEST, + UserNotification.BSD_REFUSAL, + UserNotification.SIGNATURE_CODE_RENEWAL, + UserNotification.BSDA_FINAL_DESTINATION_UPDATE + ])( + "User with role MEMBER can subscribe to notification %p", + async notification => { + const { user, company } = await userWithCompanyFactory(UserRole.MEMBER); + + const companyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(companyAssociation.notifications).toEqual([]); + + const { mutate } = makeClient(user); + + const newNotifications = [notification]; + + const { data, errors } = await mutate< + Pick, + MutationSetCompanyNotificationsArgs + >(SET_COMPANY_NOTIFICATIONS, { + variables: { + input: { + companyOrgId: company.orgId, + notifications: newNotifications + } + } + }); + + expect(errors).toBeUndefined(); + expect(data.setCompanyNotifications.userNotifications).toEqual( + newNotifications + ); + + const updatedCompanyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(updatedCompanyAssociation.notifications).toEqual(newNotifications); + } + ); + + test.each([ + UserNotification.REVISION_REQUEST, + UserNotification.BSD_REFUSAL, + UserNotification.SIGNATURE_CODE_RENEWAL, + UserNotification.BSDA_FINAL_DESTINATION_UPDATE + ])( + "User with role READER can subscribe to notification %p", + async notification => { + const { user, company } = await userWithCompanyFactory(UserRole.READER); + + const companyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(companyAssociation.notifications).toEqual([]); + + const { mutate } = makeClient(user); + + const newNotifications = [notification]; + + const { data, errors } = await mutate< + Pick, + MutationSetCompanyNotificationsArgs + >(SET_COMPANY_NOTIFICATIONS, { + variables: { + input: { + companyOrgId: company.orgId, + notifications: newNotifications + } + } + }); + + expect(errors).toBeUndefined(); + expect(data.setCompanyNotifications.userNotifications).toEqual( + newNotifications + ); + + const updatedCompanyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(updatedCompanyAssociation.notifications).toEqual(newNotifications); + } + ); + + test.each([ + UserNotification.BSD_REFUSAL, + UserNotification.SIGNATURE_CODE_RENEWAL, + UserNotification.BSDA_FINAL_DESTINATION_UPDATE + ])( + "User with role DRIVER can subscribe to notification %p", + async notification => { + const { user, company } = await userWithCompanyFactory(UserRole.DRIVER); + + const companyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(companyAssociation.notifications).toEqual([]); + + const { mutate } = makeClient(user); + + const newNotifications = [notification]; + + const { data, errors } = await mutate< + Pick, + MutationSetCompanyNotificationsArgs + >(SET_COMPANY_NOTIFICATIONS, { + variables: { + input: { + companyOrgId: company.orgId, + notifications: newNotifications + } + } + }); + + expect(errors).toBeUndefined(); + expect(data.setCompanyNotifications.userNotifications).toEqual( + newNotifications + ); + + const updatedCompanyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(updatedCompanyAssociation.notifications).toEqual(newNotifications); + } + ); + + test.each([UserRole.MEMBER, UserRole.DRIVER, UserRole.READER])( + "users with role %p should not be able to subscribe to MEMBERSHIP_REQUEST notifications", + async role => { + const { user, company } = await userWithCompanyFactory(role); + + const companyAssociation = + await prisma.companyAssociation.findFirstOrThrow({ + where: { companyId: company.id, userId: user.id } + }); + + expect(companyAssociation.notifications).toEqual([]); + + const { mutate } = makeClient(user); + + const newNotifications = [UserNotification.MEMBERSHIP_REQUEST]; + + const { errors } = await mutate< + Pick, + MutationSetCompanyNotificationsArgs + >(SET_COMPANY_NOTIFICATIONS, { + variables: { + input: { + companyOrgId: company.orgId, + notifications: newNotifications + } + } + }); + + expect(errors).toEqual([ + expect.objectContaining({ + message: + "Votre rôle au sein de l'établissement ne vous permet pas de recevoir les notifications de type MEMBERSHIP_REQUEST" + }) + ]); + } + ); + + test("Users with role DRIVER should not be able to susbcribe to REVISION_REQUEST notifications", async () => { + const { user, company } = await userWithCompanyFactory(UserRole.DRIVER); + + const companyAssociation = await prisma.companyAssociation.findFirstOrThrow( + { + where: { companyId: company.id, userId: user.id } + } + ); + + expect(companyAssociation.notifications).toEqual([]); + + const { mutate } = makeClient(user); + + const newNotifications = [UserNotification.REVISION_REQUEST]; + + const { errors } = await mutate< + Pick, + MutationSetCompanyNotificationsArgs + >(SET_COMPANY_NOTIFICATIONS, { + variables: { + input: { + companyOrgId: company.orgId, + notifications: newNotifications + } + } + }); + + expect(errors).toEqual([ + expect.objectContaining({ + message: + "Votre rôle au sein de l'établissement ne vous permet pas de recevoir les notifications de type REVISION_REQUEST" + }) + ]); + }); +}); diff --git a/back/src/users/resolvers/mutations/acceptMembershipRequest.ts b/back/src/users/resolvers/mutations/acceptMembershipRequest.ts index 94ce2f6def..581bef7738 100644 --- a/back/src/users/resolvers/mutations/acceptMembershipRequest.ts +++ b/back/src/users/resolvers/mutations/acceptMembershipRequest.ts @@ -11,10 +11,10 @@ import { MembershipRequestAlreadyAccepted, MembershipRequestAlreadyRefused } from "../../errors"; -import { convertUrls } from "../../../companies/database"; import { membershipRequestAccepted, renderMail } from "@td/mail"; import { checkUserPermissions, Permission } from "../../../permissions"; import { NotCompanyAdminErrorMsg } from "../../../common/errors"; +import { toGqlCompanyPrivate } from "../../../companies/converters"; const acceptMembershipRequestResolver: MutationResolvers["acceptMembershipRequest"] = async (_, { id, role }, context) => { @@ -84,7 +84,7 @@ const acceptMembershipRequestResolver: MutationResolvers["acceptMembershipReques const dbCompany = await prisma.company.findUnique({ where: { id: company.id } }); - return convertUrls(dbCompany!); + return toGqlCompanyPrivate(dbCompany!); }; export default acceptMembershipRequestResolver; diff --git a/back/src/users/resolvers/mutations/changeUserRole.ts b/back/src/users/resolvers/mutations/changeUserRole.ts index 94005f7982..b63c8c6302 100644 --- a/back/src/users/resolvers/mutations/changeUserRole.ts +++ b/back/src/users/resolvers/mutations/changeUserRole.ts @@ -25,6 +25,7 @@ import { updateCompanyAssociation, updateUserAccountHash } from "../../database"; +import { ALL_NOTIFICATIONS } from "../../notifications"; const changeUserRoleResolver: MutationResolvers["changeUserRole"] = async ( parent, @@ -46,12 +47,24 @@ const changeUserRoleResolver: MutationResolvers["changeUserRole"] = async ( where: { company: { orgId: args.orgId }, userId: args.userId } }); if (association) { + if (args.role === association.role) { + // Évite de faire l'update si le rôle est inchangé + return userAssociationToCompanyMember( + { ...association, user }, + company.orgId, + user.id, + isTDAdmin + ); + } + const updatedAssociation = await updateCompanyAssociation({ associationId: association.id, data: { - role: args.role + role: args.role, + notifications: args.role === "ADMIN" ? ALL_NOTIFICATIONS : [] } }); + if (!updatedAssociation) { throw new UserInputError( `L'utilisateur n'est pas membre de l'entreprise` diff --git a/back/src/users/resolvers/mutations/deleteInvitation.ts b/back/src/users/resolvers/mutations/deleteInvitation.ts index dddb9650b1..6265ad2d9a 100644 --- a/back/src/users/resolvers/mutations/deleteInvitation.ts +++ b/back/src/users/resolvers/mutations/deleteInvitation.ts @@ -1,10 +1,7 @@ import { prisma } from "@td/prisma"; import { applyAuthStrategies, AuthType } from "../../../auth"; import { checkIsAuthenticated } from "../../../common/permissions"; -import { - convertUrls, - getCompanyOrCompanyNotFound -} from "../../../companies/database"; +import { getCompanyOrCompanyNotFound } from "../../../companies/database"; import { MutationResolvers } from "../../../generated/graphql/types"; import { getUserAccountHashOrNotFound } from "../../database"; import { @@ -12,6 +9,7 @@ import { Permission } from "../../../permissions"; import { NotCompanyAdminErrorMsg } from "../../../common/errors"; +import { toGqlCompanyPrivate } from "../../../companies/converters"; const deleteInvitationResolver: MutationResolvers["deleteInvitation"] = async ( parent, @@ -36,7 +34,7 @@ const deleteInvitationResolver: MutationResolvers["deleteInvitation"] = async ( const dbCompany = await prisma.company.findUnique({ where: { orgId: siret } }); - return convertUrls(dbCompany!); + return toGqlCompanyPrivate(dbCompany!); }; export default deleteInvitationResolver; diff --git a/back/src/users/resolvers/mutations/inviteUserToCompanyService.ts b/back/src/users/resolvers/mutations/inviteUserToCompanyService.ts index c0f599e50c..aa35dd7161 100644 --- a/back/src/users/resolvers/mutations/inviteUserToCompanyService.ts +++ b/back/src/users/resolvers/mutations/inviteUserToCompanyService.ts @@ -1,11 +1,6 @@ import { prisma } from "@td/prisma"; - import { sendMail } from "../../../mailer/mailing"; - -import { - convertUrls, - getCompanyOrCompanyNotFound -} from "../../../companies/database"; +import { getCompanyOrCompanyNotFound } from "../../../companies/database"; import { CompanyPrivate, MutationInviteUserToCompanyArgs @@ -15,6 +10,7 @@ import { associateUserToCompany, createUserAccountHash } from "../../database"; import { inviteUserToJoin, notifyUserOfInvite, renderMail } from "@td/mail"; import { User } from "@prisma/client"; +import { toGqlCompanyPrivate } from "../../../companies/converters"; export async function inviteUserToCompanyFn( user: User, @@ -68,5 +64,5 @@ export async function inviteUserToCompanyFn( await sendMail(mail); } - return convertUrls(company); + return toGqlCompanyPrivate(company); } diff --git a/back/src/users/resolvers/mutations/refuseMembershipRequest.ts b/back/src/users/resolvers/mutations/refuseMembershipRequest.ts index ac02892dba..0bf001fb95 100644 --- a/back/src/users/resolvers/mutations/refuseMembershipRequest.ts +++ b/back/src/users/resolvers/mutations/refuseMembershipRequest.ts @@ -1,4 +1,3 @@ -import { convertUrls } from "../../../companies/database"; import { prisma } from "@td/prisma"; import { applyAuthStrategies, AuthType } from "../../../auth"; import { sendMail } from "../../../mailer/mailing"; @@ -12,6 +11,7 @@ import { import { renderMail, membershipRequestRefused } from "@td/mail"; import { checkUserPermissions, Permission } from "../../../permissions"; import { NotCompanyAdminErrorMsg } from "../../../common/errors"; +import { toGqlCompanyPrivate } from "../../../companies/converters"; const refuseMembershipRequestResolver: MutationResolvers["refuseMembershipRequest"] = async (parent, { id }, context) => { @@ -74,7 +74,7 @@ const refuseMembershipRequestResolver: MutationResolvers["refuseMembershipReques const dbCompany = await prisma.company.findUnique({ where: { id: company.id } }); - return convertUrls(dbCompany!); + return toGqlCompanyPrivate(dbCompany!); }; export default refuseMembershipRequestResolver; diff --git a/back/src/users/resolvers/mutations/removeUserFromCompany.ts b/back/src/users/resolvers/mutations/removeUserFromCompany.ts index 88bd241848..c0b52875b7 100644 --- a/back/src/users/resolvers/mutations/removeUserFromCompany.ts +++ b/back/src/users/resolvers/mutations/removeUserFromCompany.ts @@ -1,10 +1,7 @@ import { prisma } from "@td/prisma"; import { applyAuthStrategies, AuthType } from "../../../auth"; import { checkIsAuthenticated } from "../../../common/permissions"; -import { - convertUrls, - getCompanyOrCompanyNotFound -} from "../../../companies/database"; +import { getCompanyOrCompanyNotFound } from "../../../companies/database"; import { MutationResolvers } from "../../../generated/graphql/types"; import { getCompanyAssociationOrNotFound } from "../../database"; import { deleteCachedUserRoles } from "../../../common/redis/users"; @@ -13,6 +10,7 @@ import { Permission } from "../../../permissions"; import { NotCompanyAdminErrorMsg } from "../../../common/errors"; +import { toGqlCompanyPrivate } from "../../../companies/converters"; const removeUserFromCompanyResolver: MutationResolvers["removeUserFromCompany"] = async (parent, { userId, siret }, context) => { @@ -40,7 +38,7 @@ const removeUserFromCompanyResolver: MutationResolvers["removeUserFromCompany"] where: { orgId: siret } }); - return convertUrls(dbCompany!); + return toGqlCompanyPrivate(dbCompany!); }; export default removeUserFromCompanyResolver; diff --git a/back/src/users/resolvers/mutations/sendMembershipRequest.ts b/back/src/users/resolvers/mutations/sendMembershipRequest.ts index 97e01b2b35..cc347b3f3b 100644 --- a/back/src/users/resolvers/mutations/sendMembershipRequest.ts +++ b/back/src/users/resolvers/mutations/sendMembershipRequest.ts @@ -14,6 +14,8 @@ import { } from "@td/mail"; import { getEmailDomain, canSeeEmail } from "../../utils"; import { UserInputError } from "../../../common/errors"; +import { getNotificationSubscribers } from "../../notifications"; +import { UserNotification } from "@prisma/client"; const sendMembershipRequestResolver: MutationResolvers["sendMembershipRequest"] = async (parent, { siret }, context) => { @@ -49,31 +51,33 @@ const sendMembershipRequestResolver: MutationResolvers["sendMembershipRequest"] ); } - const emails = admins.map(a => a.email); + const subscribers = await getNotificationSubscribers( + UserNotification.MEMBERSHIP_REQUEST, + [siret] + ); const membershipRequest = await prisma.membershipRequest.create({ data: { user: { connect: { id: user.id } }, company: { connect: { id: company.id } }, - sentTo: emails + sentTo: subscribers.map(r => r.email) } }); - // send membership request to all admins of the company - const recipients = admins.map(a => ({ email: a.email, name: a.name! })); - - await sendMail( - renderMail(membershipRequestMail, { - to: recipients, - variables: { - userEmail: user.email, - companyName: company.name, - companySiret: company.orgId, - companyGivenName: company.givenName, - membershipRequestId: membershipRequest.id - } - }) - ); + if (subscribers) { + await sendMail( + renderMail(membershipRequestMail, { + to: subscribers, + variables: { + userEmail: user.email, + companyName: company.name, + companySiret: company.orgId, + companyGivenName: company.givenName, + membershipRequestId: membershipRequest.id + } + }) + ); + } // send membership request confirmation to requester // Iot let him/her know about admin emails, we filter them (same domain name, no public email providers) diff --git a/back/src/users/resolvers/mutations/setCompanyNotifications.ts b/back/src/users/resolvers/mutations/setCompanyNotifications.ts new file mode 100644 index 0000000000..f0ceeac1a1 --- /dev/null +++ b/back/src/users/resolvers/mutations/setCompanyNotifications.ts @@ -0,0 +1,50 @@ +import { GraphQLContext } from "../../../types"; +import { applyAuthStrategies, AuthType } from "../../../auth"; +import { NotCompanyMember, UserInputError } from "../../../common/errors"; + +import { checkIsAuthenticated } from "../../../common/permissions"; +import { getCompanyOrCompanyNotFound } from "../../../companies/database"; +import { MutationResolvers } from "../../../generated/graphql/types"; +import { prisma } from "@td/prisma"; +import { toGqlCompanyPrivate } from "../../../companies/converters"; +import { authorizedNotifications } from "../../notifications"; + +const setCompanyNotificationsResolver: MutationResolvers["setCompanyNotifications"] = + async (parent, args, context: GraphQLContext) => { + applyAuthStrategies(context, [AuthType.Session]); + + const { companyOrgId, notifications } = args.input; + + const user = checkIsAuthenticated(context); + + const company = await getCompanyOrCompanyNotFound({ orgId: companyOrgId }); + + const companyAssociation = await prisma.companyAssociation.findFirst({ + where: { companyId: company.id, userId: user.id } + }); + + if (!companyAssociation) { + throw new NotCompanyMember(company.orgId); + } + + const unauthorizedNotifications = notifications.filter( + notification => + !authorizedNotifications[companyAssociation.role].includes(notification) + ); + + if (unauthorizedNotifications.length) { + throw new UserInputError( + "Votre rôle au sein de l'établissement ne vous permet pas de recevoir " + + `les notifications de type ${unauthorizedNotifications.join(", ")}` + ); + } + + const updatedCompanyAssociation = await prisma.companyAssociation.update({ + where: { id: companyAssociation.id }, + data: { notifications }, + include: { company: true } + }); + + return toGqlCompanyPrivate(updatedCompanyAssociation.company); + }; +export default setCompanyNotificationsResolver; diff --git a/back/src/users/resolvers/queries/__tests__/myCompanies.integration.ts b/back/src/users/resolvers/queries/__tests__/myCompanies.integration.ts index 933a0fd82b..25039c34e2 100644 --- a/back/src/users/resolvers/queries/__tests__/myCompanies.integration.ts +++ b/back/src/users/resolvers/queries/__tests__/myCompanies.integration.ts @@ -38,6 +38,7 @@ const MY_COMPANIES = gql` cursor node { id + orgId siret givenName name @@ -47,6 +48,7 @@ const MY_COMPANIES = gql` } userRole userPermissions + userNotifications } } } @@ -462,4 +464,43 @@ describe("query { myCompanies }", () => { "BSD_CAN_REVISE" ]); }); + + it("should return userNotifications", async () => { + const user = await userFactory(); + const company1 = await companyFactory(); + const company2 = await companyFactory(); + const company3 = await companyFactory(); + + await associateUserToCompany(user.id, company1.orgId, "ADMIN", { + notifications: ["MEMBERSHIP_REQUEST"] + }); + await associateUserToCompany(user.id, company2.orgId, "ADMIN", { + notifications: ["BSD_REFUSAL"] + }); + await associateUserToCompany(user.id, company3.orgId, "ADMIN", { + notifications: ["SIGNATURE_CODE_RENEWAL", "REVISION_REQUEST"] + }); + const { query } = makeClient(user); + const { data } = await query>(MY_COMPANIES); + expect(data.myCompanies.edges).toHaveLength(3); + + const userNotificationsByOrgId = data.myCompanies.edges.reduce( + (notifications, edge) => { + return { + ...notifications, + [edge.node.orgId]: edge.node.userNotifications + }; + }, + {} + ); + + expect(userNotificationsByOrgId[company1.orgId]).toEqual([ + "MEMBERSHIP_REQUEST" + ]); + expect(userNotificationsByOrgId[company2.orgId]).toEqual(["BSD_REFUSAL"]); + expect(userNotificationsByOrgId[company3.orgId]).toEqual([ + "SIGNATURE_CODE_RENEWAL", + "REVISION_REQUEST" + ]); + }); }); diff --git a/back/src/users/resolvers/queries/myCompanies.ts b/back/src/users/resolvers/queries/myCompanies.ts index 80a688f915..a24e93cb11 100644 --- a/back/src/users/resolvers/queries/myCompanies.ts +++ b/back/src/users/resolvers/queries/myCompanies.ts @@ -1,19 +1,16 @@ import { Company } from "@prisma/client"; import { getConnection } from "../../../common/pagination"; import { checkIsAuthenticated } from "../../../common/permissions"; -import { convertUrls } from "../../../companies/database"; -import { - CompanyPrivate, - QueryResolvers -} from "../../../generated/graphql/types"; +import { QueryResolvers } from "../../../generated/graphql/types"; import { prisma } from "@td/prisma"; import { AuthType } from "../../../auth"; import { MIN_MY_COMPANIES_SEARCH, MAX_MY_COMPANIES_SEARCH } from "@td/constants"; + +import { toGqlCompanyPrivate } from "../../../companies/converters"; import { UserInputError } from "../../../common/errors"; -import { libelleFromCodeNaf } from "../../../companies/sirene/utils"; const myCompaniesResolver: QueryResolvers["myCompanies"] = async ( _parent, @@ -85,12 +82,7 @@ const myCompaniesResolver: QueryResolvers["myCompanies"] = async ( } ] }), - formatNode: (company: Company) => { - const companyPrivate: CompanyPrivate = convertUrls(company); - const { codeNaf: naf, address } = company; - const libelleNaf = libelleFromCodeNaf(naf!); - return { ...companyPrivate, naf, libelleNaf, address }; - }, + formatNode: (company: Company) => toGqlCompanyPrivate(company), ...paginationArgs }); }; diff --git a/back/src/users/typeDefs/private/user.inputs.graphql b/back/src/users/typeDefs/private/user.inputs.graphql index 27f6f09ceb..6fb05a1a76 100644 --- a/back/src/users/typeDefs/private/user.inputs.graphql +++ b/back/src/users/typeDefs/private/user.inputs.graphql @@ -34,3 +34,13 @@ input CreatePasswordResetRequestInput { email: String! captcha: CaptchaInput! } + +input SetCompanyNotificationsInput { + "Identifiant de l'établissement" + companyOrgId: String! + """ + Notifications auxquelles l'utilisateur souhaite être abonné pour l'établisement + identifié par le paramètre companyOrgId + """ + notifications: [UserNotification!]! +} diff --git a/back/src/users/typeDefs/private/user.mutations.graphql b/back/src/users/typeDefs/private/user.mutations.graphql index 7c8bae3c36..8df876d8f7 100644 --- a/back/src/users/typeDefs/private/user.mutations.graphql +++ b/back/src/users/typeDefs/private/user.mutations.graphql @@ -117,4 +117,11 @@ type Mutation { Change le rôle d'un utilisateur de l'entreprise """ changeUserRole(userId: ID!, orgId: ID!, role: UserRole!): CompanyMember! + + """ + USAGE INTERNE + Modifie les préfèrences d'abonnement de l'utilisateur connecté aux notifications + d'un établissement donné + """ + setCompanyNotifications(input: SetCompanyNotificationsInput!): CompanyPrivate! } diff --git a/back/src/users/typeDefs/user.enums.graphql b/back/src/users/typeDefs/user.enums.graphql index 8b70db73d6..89f2a3e3b5 100644 --- a/back/src/users/typeDefs/user.enums.graphql +++ b/back/src/users/typeDefs/user.enums.graphql @@ -27,6 +27,24 @@ enum UserRole { ADMIN } +""" +Notifications auxquelles un utilisateur peut s'abonner pour un établissement donné. +Par défaut les utilisateurs avec le rôle ADMIN sont abonnés à toutes les notifications +tandis que les autres rôles ne sont abonnés à aucune notification. +""" +enum UserNotification { + # Notification de demande de rattachement + MEMBERSHIP_REQUEST + # Notification de renouvellement du code signature + SIGNATURE_CODE_RENEWAL + # Notifications en cas de refus total ou partiel d'un BSD + BSD_REFUSAL + # Notification lors de la modification de la destination finale amiante + BSDA_FINAL_DESTINATION_UPDATE + # Notification lors d'une demande de révision + REVISION_REQUEST +} + enum UserPermission { "Lire un BSD." BSD_CAN_READ @@ -66,6 +84,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/e2e/src/data/company.ts b/e2e/src/data/company.ts index 8487f8364a..63b35da7be 100644 --- a/e2e/src/data/company.ts +++ b/e2e/src/data/company.ts @@ -1,6 +1,6 @@ import { prisma } from "@td/prisma"; import { generateUniqueTestSiret, randomNbrChain } from "back"; -import { CompanyType, Prisma } from "@prisma/client"; +import { CompanyType, Prisma, WasteVehiclesType } from "@prisma/client"; interface VhuAgrement { agrementNumber: string; @@ -162,6 +162,10 @@ export const seedDefaultCompanies = async () => { { name: "I - Broyeur / casse automobile", companyTypes: [CompanyType.WASTE_VEHICLES], + wasteVehiclesTypes: [ + WasteVehiclesType.BROYEUR, + WasteVehiclesType.DEMOLISSEUR + ], contact: "Monsieur Démolisseur et Broyeur", contactPhone: "0458758956", contactEmail: "monsieurbroyeuretdemolisseur@gmail.com" @@ -202,6 +206,7 @@ export const seedDefaultCompanies = async () => { { name: "N - Démolisseur", companyTypes: [CompanyType.WASTE_VEHICLES], + wasteVehiclesTypes: [WasteVehiclesType.DEMOLISSEUR], contact: "Monsieur Démolisseur", contactPhone: "0473625689", contactEmail: "monsieurdemolisseur@gmail.com" @@ -218,6 +223,7 @@ export const seedDefaultCompanies = async () => { { name: "O - Broyeur", companyTypes: [CompanyType.WASTE_VEHICLES], + wasteVehiclesTypes: [WasteVehiclesType.BROYEUR], contact: "Monsieur Broyeur", contactPhone: "0475875695", contactEmail: "monsieurbroyeur@gmail.com" 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/index.html b/front/index.html index 3ba0e363eb..cc26891b78 100644 --- a/front/index.html +++ b/front/index.html @@ -2,50 +2,40 @@ + + + + + - - - - - - - - - Trackdéchets | La traçabilité des déchets en toute sécurité diff --git a/front/project.json b/front/project.json index 94a54d93df..c8514e4b05 100644 --- a/front/project.json +++ b/front/project.json @@ -7,7 +7,7 @@ "dsfr": { "executor": "nx:run-commands", "options": { - "command": "cd front && copy-dsfr-to-public && only-include-used-icons" + "command": "cd front && react-dsfr update-icons && react-dsfr copy-static-assets" } }, "build": { diff --git a/front/src/App.tsx b/front/src/App.tsx index 41d51cc854..077a7965ba 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -1,6 +1,6 @@ import React from "react"; import { ApolloProvider } from "@apollo/client"; -import { BrowserRouter as Router } from "react-router-dom"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; import client from "./graphql-client"; import LayoutContainer from "./Apps/common/Components/layout/LayoutContainer"; import setYupLocale from "./common/setYupLocale"; @@ -13,20 +13,22 @@ import { PermissionsProvider } from "./common/contexts/PermissionsContext"; // See https://github.com/jquense/yup#using-a-custom-locale-dictionary setYupLocale(); +const router = createBrowserRouter([ + { path: "*", element: } +]); + export default function App() { return ( - - - -

- - - + + +
+ +
+
+
diff --git a/front/src/Apps/Account/Account.tsx b/front/src/Apps/Account/Account.tsx index 46ed73c423..570b8887d0 100644 --- a/front/src/Apps/Account/Account.tsx +++ b/front/src/Apps/Account/Account.tsx @@ -14,6 +14,7 @@ import AccountApplications from "./AccountApplications/AccountApplications"; import "../Dashboard/dashboard.scss"; import { useMedia } from "../../common/use-media"; import { MEDIA_QUERIES } from "../../common/config"; +import AccountNotifications from "./AccountNotifications/AccountNotifications"; export const GET_ME = gql` { @@ -41,7 +42,7 @@ export default function Account() { return (
{!isMobile && } -
+
} /> @@ -54,6 +55,15 @@ export default function Account() { } /> + + + + } + /> +
-

{title}

+

{title}

{subtitle && (

{subtitle} diff --git a/front/src/Apps/Account/AccountInfo/AccountInfoActionBar.tsx b/front/src/Apps/Account/AccountInfo/AccountInfoActionBar.tsx index 0330f9d1db..17e7f2bbe2 100644 --- a/front/src/Apps/Account/AccountInfo/AccountInfoActionBar.tsx +++ b/front/src/Apps/Account/AccountInfo/AccountInfoActionBar.tsx @@ -18,7 +18,7 @@ const AccountInfoActionBar = ({ isDisabled }: AccountInfoActionBarProps) => (

- {title &&

{title}

} + {title &&

{title}

}
{!isEditing && (
diff --git a/front/src/Apps/Account/AccountMembershipRequest.tsx b/front/src/Apps/Account/AccountMembershipRequest.tsx index f983899e9a..fbb0f535e7 100644 --- a/front/src/Apps/Account/AccountMembershipRequest.tsx +++ b/front/src/Apps/Account/AccountMembershipRequest.tsx @@ -117,9 +117,9 @@ export default function AccountMembershipRequest() { return (
-

+

Un utilisateur aimerait rejoindre l'établissement {name} ({siret}) -

+
diff --git a/front/src/Apps/Account/AccountMenu.tsx b/front/src/Apps/Account/AccountMenu.tsx index 784a790d94..2f9be3071f 100644 --- a/front/src/Apps/Account/AccountMenu.tsx +++ b/front/src/Apps/Account/AccountMenu.tsx @@ -6,7 +6,12 @@ import { Accordion } from "@codegouvfr/react-dsfr/Accordion"; export const AccountMenuContent = () => ( <> - +
  • ( Mes paramètres
  • +
  • + + isActive + ? "sidebarv2__item sidebarv2__item--indented sidebarv2__item--active" + : "sidebarv2__item sidebarv2__item--indented" + } + > + Notifications + +
- +
  • + + {/* Liste paginée des établissements avec un bouton "Charger plus" + et une barre de recherche */} + ( + [ + , + , +
    + +
    + ])} + headers={["Établissements", "Notifications", ""]} + /> + )} + /> + + ); +} diff --git a/front/src/Apps/Account/AccountNotifications/CompanyDisplay.module.scss b/front/src/Apps/Account/AccountNotifications/CompanyDisplay.module.scss new file mode 100644 index 0000000000..ab8c74598a --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/CompanyDisplay.module.scss @@ -0,0 +1,9 @@ +.companyName { + color: var(--text-action-high-blue-france); + font-weight: bold; +} + +.roleTag { + color: var(--text-action-high-blue-france); + background-color: var(--background-action-low-blue-france); +} diff --git a/front/src/Apps/Account/AccountNotifications/CompanyDisplay.tsx b/front/src/Apps/Account/AccountNotifications/CompanyDisplay.tsx new file mode 100644 index 0000000000..34ee346724 --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/CompanyDisplay.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { CompanyPrivate } from "@td/codegen-ui"; +import Tag from "@codegouvfr/react-dsfr/Tag"; +import { userRoleLabel } from "../../Companies/common/utils"; +import styles from "./CompanyDisplay.module.scss"; + +type AccountCompanyNotificationsProps = { + company: CompanyPrivate; +}; + +export default function CompanyDisplay({ + company +}: AccountCompanyNotificationsProps) { + return ( + <> +

    + {company.name} + {company.givenName && ` - ${company.givenName}`} +

    +

    {company.orgId}

    + + {userRoleLabel(company.userRole!)} + + + ); +} diff --git a/front/src/Apps/Account/AccountNotifications/NotificationsDisplay.tsx b/front/src/Apps/Account/AccountNotifications/NotificationsDisplay.tsx new file mode 100644 index 0000000000..2fbfdce5a0 --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/NotificationsDisplay.tsx @@ -0,0 +1,27 @@ +import { CompanyPrivate, UserNotification } from "@td/codegen-ui"; +import React from "react"; + +type AccountCompanyNotificationsProps = { + company: CompanyPrivate; +}; + +const notificationLabels: { [key in UserNotification]: string } = { + [UserNotification.MembershipRequest]: "Rattachement", + [UserNotification.SignatureCodeRenewal]: "Code signature", + [UserNotification.BsdRefusal]: "Refus", + [UserNotification.BsdaFinalDestinationUpdate]: "Destination finale amiante", + [UserNotification.RevisionRequest]: "Révision" +}; + +export function NotificationsDisplay({ + company +}: AccountCompanyNotificationsProps) { + if (!company.userNotifications || company.userNotifications.length === 0) { + return
    Désactivé
    ; + } + return ( +
    + {company.userNotifications.map(n => notificationLabels[n]).join(", ")} +
    + ); +} diff --git a/front/src/Apps/Account/AccountNotifications/NotificationsUpdateAllButton.tsx b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateAllButton.tsx new file mode 100644 index 0000000000..71c7a9aab5 --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateAllButton.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import AccountNotificationsUpdateAllModal from "./NotificationsUpdateAllModal"; + +type AccountNotificationsUpdateAllButtonProps = { + // nombre total d'établissements + totalCount: number; +}; + +export default function NotificationsUpdateAllButton({ + totalCount +}: AccountNotificationsUpdateAllButtonProps) { + const modal = createModal({ + id: `update-all-notifications`, + isOpenedByDefault: false + }); + + return ( + <> + + + + + + ); +} diff --git a/front/src/Apps/Account/AccountNotifications/NotificationsUpdateAllModal.tsx b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateAllModal.tsx new file mode 100644 index 0000000000..b6b650418a --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateAllModal.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +type AccountNotificationsUpdateAllModalProps = { + // nombre total d'établissements + totalCount: number; +}; + +export default function AccountNotificationsUpdateAllModal({ + totalCount +}: AccountNotificationsUpdateAllModalProps) { + return ( +
    + {/* TODO implémenter les actions en masse dans cette modale */} + Mise à jour en masse de {totalCount} établissements +
    + ); +} diff --git a/front/src/Apps/Account/AccountNotifications/NotificationsUpdateButton.tsx b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateButton.tsx new file mode 100644 index 0000000000..8c87573d93 --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateButton.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import { CompanyPrivate } from "@td/codegen-ui"; +import NotificationsUpdateModal from "./NotificationsUpdateModal"; + +type AccountCompanyNotificationsUpdateButtonProps = { + company: CompanyPrivate; +}; + +export default function NotificationsUpdateButton({ + company +}: AccountCompanyNotificationsUpdateButtonProps) { + const btnLabel = `Gérer (${company.userNotifications.length})`; + + const modalTitle = `Gérer les notifications`; + + const iconId = company.userNotifications?.length + ? "ri-notification-3-line" + : "ri-notification-off-line"; + + const modal = createModal({ + id: `${company.orgId}-notifications-update`, + isOpenedByDefault: false + }); + + return ( + <> + + + + + + ); +} diff --git a/front/src/Apps/Account/AccountNotifications/NotificationsUpdateModal.module.scss b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateModal.module.scss new file mode 100644 index 0000000000..fb8b86ec5e --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateModal.module.scss @@ -0,0 +1,5 @@ +.buttons { + display: flex; + justify-content: right; + gap: 15px; +} diff --git a/front/src/Apps/Account/AccountNotifications/NotificationsUpdateModal.tsx b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateModal.tsx new file mode 100644 index 0000000000..e4f854f6a4 --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/NotificationsUpdateModal.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import { + CompanyPrivate, + Mutation, + MutationSetCompanyNotificationsArgs, + UserNotification +} from "@td/codegen-ui"; +import { useForm } from "react-hook-form"; +import Checkbox from "@codegouvfr/react-dsfr/Checkbox"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { useMutation } from "@apollo/client"; +import { SET_COMPANY_NOTIFICATIONS } from "./queries"; +import styles from "./NotificationsUpdateModal.module.scss"; +import { + ALL_NOTIFICATIONS, + authorizedNotifications as authorizedNotificationsByUserRole +} from "../../../common/notifications"; + +type AccountCompanyNotificationsUpdateModalProps = { + company: CompanyPrivate; + close: () => void; +}; + +type FormValues = { [key in UserNotification]: boolean }; + +export default function NotificationsUpdateModal({ + company, + close +}: AccountCompanyNotificationsUpdateModalProps) { + const [setCompanyNotifications, { loading, data, error }] = useMutation< + Pick, + MutationSetCompanyNotificationsArgs + >(SET_COMPANY_NOTIFICATIONS); + + const defaultValues: FormValues = ALL_NOTIFICATIONS.reduce( + (values, notification) => ({ + ...values, + [notification]: company.userNotifications.includes(notification) + }), + {} as FormValues + ); + + const authorizedNotifications = company.userRole + ? authorizedNotificationsByUserRole[company.userRole] + : []; + + const { register, handleSubmit } = useForm({ + defaultValues + }); + + const onSubmit = async (data: FormValues) => { + const notifications = Object.keys(data).filter(k => data[k]); + const { errors } = await setCompanyNotifications({ + variables: { + input: { + companyOrgId: company.orgId, + notifications: notifications as UserNotification[] + } + } + }); + if (!errors) { + close(); + } + }; + + let checkboxState: "default" | "error" | "success" = "default"; + + if (error) { + checkboxState = "error"; + } else if (data) { + checkboxState = "success"; + } + + const optionsLabels = [ + { + hintText: + "Seuls les membres avec le rôle Administrateur sont en mesure de recevoir " + + "et d'accepter / refuser / effectuer des demandes de rattachement à leur établissement. " + + "Nous vous conseillons donc vivement, pour chaque établissement de conserver au moins un " + + "administrateur abonné à ce type de notification.", + label: "aux demandes de rattachement", + notification: UserNotification.MembershipRequest + }, + { + hintText: + "Un courriel sera envoyé à chaque renouvellement du code de signature", + label: "au renouvellement du code de signature", + notification: UserNotification.SignatureCodeRenewal + }, + { + hintText: + "un courriel sera envoyé à chaque refus total ou partiel d'un bordereau", + label: "au refus total et partiel des bordereaux", + notification: UserNotification.BsdRefusal + }, + { + hintText: + "Un courriel sera envoyé lorsque le BSDA est envoyé à un exutoire" + + " différent de celui prévu lors de la signature producteur", + label: "à la modification de la destination finale amiante", + notification: UserNotification.BsdaFinalDestinationUpdate + }, + { + hintText: + "Un courriel sera envoyé à chaque fois qu'une révision sera restée sans réponse 14 jours après sa demande", + label: "aux demandes de révision", + notification: UserNotification.RevisionRequest + } + ]; + + const options = optionsLabels + .filter(({ notification }) => + authorizedNotifications.includes(notification) + ) + .map(({ label, hintText, notification }) => ({ + label, + hintText, + nativeInputProps: { + ...register(notification) + } + })); + + return ( + <> +
    + Je souhaite recevoir par courriel les notifications de l'établissement{" "} + {company.name} ({company.siret}) relatives : +
    + + + +
    + + +
    + + + ); +} diff --git a/front/src/Apps/Account/AccountNotifications/queries.ts b/front/src/Apps/Account/AccountNotifications/queries.ts new file mode 100644 index 0000000000..a8aa8bec31 --- /dev/null +++ b/front/src/Apps/Account/AccountNotifications/queries.ts @@ -0,0 +1,11 @@ +import gql from "graphql-tag"; + +export const SET_COMPANY_NOTIFICATIONS = gql` + mutation SetCompanyNotifications($input: SetCompanyNotificationsInput!) { + setCompanyNotifications(input: $input) { + id + orgId + userNotifications + } + } +`; diff --git a/front/src/Apps/Companies/CompaniesList/CompaniesList.tsx b/front/src/Apps/Companies/CompaniesList/CompaniesList.tsx index 237fef45b0..e1279f3082 100644 --- a/front/src/Apps/Companies/CompaniesList/CompaniesList.tsx +++ b/front/src/Apps/Companies/CompaniesList/CompaniesList.tsx @@ -1,28 +1,19 @@ -import React, { useState, useMemo, useEffect } from "react"; -import { useQuery } from "@apollo/client"; +import React, { useState } from "react"; import { Link, useNavigate, generatePath } from "react-router-dom"; -import { Query, CompanyPrivate, UserRole } from "@td/codegen-ui"; -import { Loader } from "../../common/Components"; -import { NotificationError } from "../../common/Components/Error/Error"; -import routes from "../../routes"; -import { debounce } from "../../../common/helper"; import { - MIN_MY_COMPANIES_SEARCH, - MAX_MY_COMPANIES_SEARCH -} from "@td/constants"; + CompanyPrivate, + UserRole, + CompanyPrivateConnection +} from "@td/codegen-ui"; +import routes from "../../routes"; import styles from "./CompaniesList.module.scss"; import AccountContentWrapper from "../../Account/AccountContentWrapper"; -import { MY_COMPANIES } from "../common/queries"; import DropdownMenu from "../../common/Components/DropdownMenu/DropdownMenu"; import { useDownloadMyCompaniesCsv, useDownloadMyCompaniesXls } from "../common/hooks/useDownloadCompanies"; - -// Prevent to short and long clues -const isSearchClueValid = clue => - clue.length >= MIN_MY_COMPANIES_SEARCH && - clue.length <= MAX_MY_COMPANIES_SEARCH; +import SearchableCompaniesList from "./SearchableCompaniesList"; export const userRole = (role: UserRole) => { let icon = "fr-icon-user-line"; @@ -53,88 +44,76 @@ export const userRole = (role: UserRole) => { }; export default function CompaniesList() { - const [isFiltered, setIsFiltered] = useState(false); - const [inputValue, setInputValue] = useState(""); - const [searchClue, setSearchClue] = useState(""); - const [displayPreloader, setDisplayPreloader] = useState(false); - const [userCompaniesTotalCount, setUserCompaniesTotalCount] = useState(0); - const navigate = useNavigate(); - - const { data, loading, error, refetch, fetchMore } = useQuery< - Pick - >(MY_COMPANIES, { fetchPolicy: "network-only", variables: { first: 10 } }); - - const debouncedSearch = useMemo( - () => - debounce(params => { - setSearchClue(params.search); - refetch(params).then(() => setDisplayPreloader(false)); - }, 1000), - [refetch, setDisplayPreloader] + const [downloadMyCompaniesCsv] = useDownloadMyCompaniesCsv(); + const [downloadMyCompaniesXls] = useDownloadMyCompaniesXls(); + const [companiesTotalCount, setCompaniesTotalCount] = useState( + null ); - const handleOnChangeSearch = newSearchClue => { - if (isSearchClueValid(newSearchClue)) { - setDisplayPreloader(true); - setIsFiltered(true); - debouncedSearch({ - search: newSearchClue - }); - } + const pluralize = count => (count > 1 ? "s" : ""); - if (newSearchClue.length === 0) { - handleOnResetSearch(); + const onMyCompaniesQueryCompleted = ( + data: CompanyPrivateConnection, + isFiltered: boolean + ) => { + if (!isFiltered) { + if (data.totalCount === 0) { + // No results and we're not filtering, redirect to the create company screen + navigate(routes.companies.orientation); + } + setCompaniesTotalCount(data.totalCount); } }; - const handleOnResetSearch = () => { - setInputValue(""); - setDisplayPreloader(true); - setIsFiltered(false); - debouncedSearch({ search: "" }); + const renderCompanies = (companies: CompanyPrivate[]) => { + return ( +
    + {companies.map(company => ( + +
    +

    + {company.name} + {company.givenName && ` - ${company.givenName}`} +

    +

    {company.orgId}

    +

    {company.address}

    +

    + {userRole(company.userRole!)} +

    +
    +
    + ); }; - useEffect(() => { - if (data) { - const companies = data.myCompanies?.edges.map(({ node }) => node); - const totalCount = data.myCompanies?.totalCount ?? 0; - - setUserCompaniesTotalCount(previousTotalCount => { - return previousTotalCount || totalCount; - }); - - if (!companies || (userCompaniesTotalCount === 0 && totalCount === 0)) { - if (!isFiltered) { - // No results and we're not filtering, redirect to the create company screen - navigate(routes.companies.orientation); - } - } - } - }, [ - navigate, - data, - isFiltered, - userCompaniesTotalCount, - setUserCompaniesTotalCount - ]); - - const pluralize = count => (count > 1 ? "s" : ""); - const listTitle = `Vous êtes membre de ${userCompaniesTotalCount} établissement${pluralize( - userCompaniesTotalCount - )}`; - - const [downloadMyCompaniesCsv] = useDownloadMyCompaniesCsv(); - const [downloadMyCompaniesXls] = useDownloadMyCompaniesXls(); + const subtitle = companiesTotalCount + ? `Vous êtes membre de ${companiesTotalCount} établissement${pluralize( + companiesTotalCount + )}` + : ""; - const getPageContent = (content, subtitle?) => ( + return ( - Créer un établissement + Créer un établissement } > -
    -
    -
    - -
    -
    -
    -
    {content}
    -
    -
    +
    ); - - if (error) { - return ; - } - - if (displayPreloader || loading) { - return getPageContent(, listTitle); - } - - if (data) { - const companies = data.myCompanies?.edges.map(({ node }) => node); - const totalCount = data.myCompanies?.totalCount ?? 0; - - const searchTitle = `${totalCount} résultat${pluralize( - totalCount - )} pour la recherche : ${searchClue}`; - - if (!companies || totalCount === 0) { - if (isFiltered) { - return getPageContent( -
    - Aucun résultat pour la recherche: {searchClue} -
    , - listTitle - ); - } - - return ; - } - - const renderCompanies = companies => { - return companies.map((company: CompanyPrivate) => ( - -
    -

    - {company.name} - {company.givenName && ` - ${company.givenName}`} -

    -

    {company.orgId}

    -

    {company.address}

    -

    - {userRole(company.userRole!)} -

    -
    -
    + + + + + + + + + {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; + + const canRevokeOrCancel = + status === RegistryDelegationStatus.Ongoing || + status === RegistryDelegationStatus.Incoming; + + 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)} + {canRevokeOrCancel && ( + + )} +
    +
+
+
+ +
+ ({ + onClick: event => { + event.preventDefault(); + gotoPage(pageNumber - 1); + }, + href: "#", + key: `pagination-link-${pageNumber}` + })} + className={"fr-mt-1w"} + /> +
+ + {delegationToRevokeOrCancel && ( + setDelegationToRevokeOrCancel(null)} + /> + )} +
+ ); +}; diff --git a/front/src/Apps/Companies/CompanyRegistryDelegation/RevokeOrCancelRegistryDelegationModal.tsx b/front/src/Apps/Companies/CompanyRegistryDelegation/RevokeOrCancelRegistryDelegationModal.tsx new file mode 100644 index 0000000000..a1fc35f0e9 --- /dev/null +++ b/front/src/Apps/Companies/CompanyRegistryDelegation/RevokeOrCancelRegistryDelegationModal.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { + Mutation, + MutationCancelRegistryDelegationArgs, + MutationRevokeRegistryDelegationArgs +} from "@td/codegen-ui"; +import { Modal } from "../../../common/components"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { useMutation } from "@apollo/client"; +import { + CANCEL_REGISTRY_DELEGATION, + 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 RevokeOrCancelRegistryDelegationModal = ({ + delegationId, + to, + from, + onClose +}: Props) => { + const [revokeRegistryDelegation, { loading: loadingRevoke }] = 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(); + }; + + const [cancelRegistryDelegation, { loading: loadingCancel }] = useMutation< + Pick, + MutationCancelRegistryDelegationArgs + >(CANCEL_REGISTRY_DELEGATION); + + const onCancel = async () => { + await cancelRegistryDelegation({ + variables: { + delegationId + }, + onCompleted: () => toast.success("Délégation annulé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"; + } + + const loading = loadingRevoke || loadingCancel; + + 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/Companies/CompanySignature/CompanySignatureAutomation.tsx b/front/src/Apps/Companies/CompanySignature/CompanySignatureAutomation.tsx index 70bcb9fe37..0ec43a4229 100644 --- a/front/src/Apps/Companies/CompanySignature/CompanySignatureAutomation.tsx +++ b/front/src/Apps/Companies/CompanySignature/CompanySignatureAutomation.tsx @@ -187,7 +187,7 @@ const CompanySignatureAutomation = ({ return (
-

Signature automatique (annexe 1)

+

Signature automatique (annexe 1)

{isAdmin ? ( { return (
-

Code signature

+

Code signature

{isAdmin && (

Ce code sécurisé de signature vous permet de signer un BSD au départ diff --git a/front/src/Apps/Companies/CompanySignature/CompanySignatureDasriDirectTakeOver.tsx b/front/src/Apps/Companies/CompanySignature/CompanySignatureDasriDirectTakeOver.tsx index db88f48fe0..e40113d1a9 100644 --- a/front/src/Apps/Companies/CompanySignature/CompanySignatureDasriDirectTakeOver.tsx +++ b/front/src/Apps/Companies/CompanySignature/CompanySignatureDasriDirectTakeOver.tsx @@ -55,7 +55,7 @@ const CompanySignatureDasriDirectTakeOver = ({ return (

-

Emport direct de DASRI

+

Emport direct de DASRI

{isAdmin ? (
-

{title}

+

{title}

{showEditCta && ( + + )} {permissions.includes(UserPermission.BsdCanUpdate) && canUpdateBsd(bsd, currentSiret) && (
  • diff --git a/front/src/Apps/Dashboard/Components/BsdAdditionalActionsButton/bsdAdditionalActionsButtonTypes.ts b/front/src/Apps/Dashboard/Components/BsdAdditionalActionsButton/bsdAdditionalActionsButtonTypes.ts index 5f2bfb8519..2fbf12e36d 100644 --- a/front/src/Apps/Dashboard/Components/BsdAdditionalActionsButton/bsdAdditionalActionsButtonTypes.ts +++ b/front/src/Apps/Dashboard/Components/BsdAdditionalActionsButton/bsdAdditionalActionsButtonTypes.ts @@ -10,6 +10,7 @@ export interface BsdAdditionalActionsButtonProps { onPdf?: Function; onDuplicate: Function; onUpdate?: Function; + onClone?: Function; onDelete?: Function; onRevision?: Function; onAppendix1?: Function; diff --git a/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx b/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx index 66945c25b6..e5b3687edf 100644 --- a/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx +++ b/front/src/Apps/Dashboard/Components/BsdCard/BsdCard.tsx @@ -58,9 +58,12 @@ import { NON_RENSEIGNE } from "../../../common/wordings/dashboard/wordingsDashbo import "./bsdCard.scss"; import { getCurrentTransporterInfos } from "../../bsdMapper"; import { isDefined } from "../../../../common/helper"; +import { useCloneBsd } from "../Clone/useCloneBsd"; function BsdCard({ bsd, + posInSet = 0, + setSize = -1, bsdCurrentTab, currentSiret, onValidate, @@ -117,6 +120,9 @@ function BsdCard({ const [duplicateBsff, { loading: isDuplicatingBsff }] = useBsffDuplicate({ ...options }); + const [cloneBsd, { loading: isCloningBsd }] = useCloneBsd({ + ...options + }); const [duplicateBsvhu, { loading: isDuplicatingBsvhu }] = useBsvhuDuplicate({ ...options }); @@ -183,18 +189,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 +208,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 +215,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 @@ -257,6 +257,10 @@ function BsdCard({ ] ); + const onClone = useCallback(() => { + cloneBsd(); + }, [cloneBsd]); + const onDuplicate = useCallback( (bsd: BsdDisplay) => { if (bsd.type === BsdType.Bsdd) { @@ -336,7 +340,13 @@ function BsdCard({ return ( <> -
    +
    {bsdDisplay && ( <>
    @@ -399,7 +409,8 @@ function BsdCard({ transporterNumberPlate: currentTransporterInfos?.transporterNumberPlate }} - hasEditableInfos + hasEditableInfos={isToCollectTab} + info={currentTransporterInfos?.transporterNumberPlate?.toString()} isDisabled={ isCollectedTab || !permissions.includes(UserPermission.BsdCanUpdate) @@ -407,6 +418,12 @@ function BsdCard({ onClick={handleEditableInfoClick} /> )} + {bsdDisplay?.destination?.["cap"] && ( + + )}
    {isMobile &&
    }
    @@ -464,6 +481,9 @@ function BsdCard({ onClick={handleValidationClick} > {ctaPrimaryLabel} + + bordereau numéro {bsdDisplay.readableid} + )} @@ -489,6 +509,7 @@ function BsdCard({ onDuplicate, onUpdate, onRevision, + onClone, onPdf, onAppendix1, onBsdSuite, @@ -513,7 +534,7 @@ function BsdCard({ )}
    - {isDuplicating && } + {(isDuplicating || isCloningBsd) && } { { { { { { { { { { { { { { { { void; diff --git a/front/src/Apps/Dashboard/Components/BsdCardList/BsdCardList.tsx b/front/src/Apps/Dashboard/Components/BsdCardList/BsdCardList.tsx index 81ab79c1c9..bcb102836d 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, @@ -59,6 +60,7 @@ function BsdCardList({ bsdCurrentTab === "reviewedTab" || bsdCurrentTab === "toReviewTab"; const isActTab = bsdCurrentTab === "actTab"; const isToCollectTab = bsdCurrentTab === "toCollectTab"; + const isReturnTab = bsdCurrentTab === "returnTab"; const isCollectedTab = bsdCurrentTab === "collectedTab"; const isAllBsdsTab = !!useMatch(routes.dashboard.bsds.index); @@ -79,6 +81,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(); @@ -290,7 +347,13 @@ function BsdCardList({ if (status === FormStatus.Draft || bsd["isDraft"]) { handleDraftValidation(bsd as Bsd); } else { - if (hasRoadControlButton(bsdDisplay as BsdDisplay, isCollectedTab)) { + if ( + hasRoadControlButton( + bsdDisplay as BsdDisplay, + isCollectedTab, + isReturnTab + ) + ) { const path = routes.dashboard.roadControl; redirectToPath(path, bsd.id); } else { @@ -301,6 +364,7 @@ function BsdCardList({ }, [ isReviewsTab, + isReturnTab, isCollectedTab, siret, handleDraftValidation, @@ -383,8 +447,8 @@ function BsdCardList({ return ( <> -
      - {bsds?.map(({ node }) => { +
        + {bsds?.map(({ node }, index) => { const key = `${node.id}${node.status}`; const hasAutomaticSignature = siretsWithAutomaticSignature?.includes( node?.emitter?.company?.siret @@ -393,6 +457,8 @@ function BsdCardList({
      • , 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/Components/DashboardTabs/DashboardTabs.scss b/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.scss index 5af86001e1..c181680701 100644 --- a/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.scss +++ b/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.scss @@ -43,10 +43,7 @@ } .dashboard-tabs-notifications { - position: absolute; pointer-events: none; - right: 0px; - top: 3px; background-color: #000091; color: white; font-size: 14px; diff --git a/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.tsx b/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.tsx index f2bea3f342..b1fd9584cf 100644 --- a/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.tsx +++ b/front/src/Apps/Dashboard/Components/DashboardTabs/DashboardTabs.tsx @@ -23,7 +23,8 @@ import { REVIEWS, TO_COLLECT, TO_REVIEW, - TRANSPORT + TRANSPORT, + RETURN } from "../../../common/wordings/dashboard/wordingsDashboard"; import "./DashboardTabs.scss"; @@ -115,7 +116,7 @@ const DashboardTabs = ({ currentCompany, companies }: DashboardTabsProps) => { return (
        -
        +
        { {showMyBsds && ( <> {
      - +
      • { )} {showTransportTabs && ( - +
        • { {COLLECTED}
        • +
        • + + isActive + ? "sidebarv2__item sidebarv2__item--indented sidebarv2__item--active" + : "sidebarv2__item sidebarv2__item--indented" + } + > + {RETURN} + +
        )} 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/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/Dashboard/Components/Revision/revisionMapper.ts b/front/src/Apps/Dashboard/Components/Revision/revisionMapper.ts index 77a8641a52..e668202ce9 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 }, { 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/Dashboard/Creation/FormStepsContent.tsx b/front/src/Apps/Dashboard/Creation/FormStepsContent.tsx index e21f97e797..eaba8d7462 100644 --- a/front/src/Apps/Dashboard/Creation/FormStepsContent.tsx +++ b/front/src/Apps/Dashboard/Creation/FormStepsContent.tsx @@ -1,11 +1,13 @@ -import React, { useRef, useState } from "react"; +import React, { useState } from "react"; import { FormProvider, UseFormReturn } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import FormStepsTabs from "../../Forms/Components/FormStepsTabs/FormStepsTabs"; import { Loader } from "../../common/Components"; import { SealedFieldsContext } from "./context"; import { - TabsName, + NormalizedError, + SupportedBsdTypes, + TabId, getNextTab, getPrevTab, getTabs, @@ -13,7 +15,7 @@ import { } from "./utils"; interface FormStepsContentProps { - isCrematorium?: boolean; + bsdType: SupportedBsdTypes; sealedFields?: string[]; isLoading: boolean; useformMethods: UseFormReturn; @@ -26,11 +28,11 @@ interface FormStepsContentProps { transporter: React.JSX.Element; destination: React.JSX.Element; }; - setPublishErrors: Function; - errorTabIds?: string[] | (TabsName | undefined)[]; + setPublishErrors: (normalizedErrors: NormalizedError[]) => void; + errorTabIds?: TabId[]; } const FormStepsContent = ({ - isCrematorium = false, + bsdType, tabsContent, sealedFields = [], isLoading, @@ -41,21 +43,17 @@ const FormStepsContent = ({ setPublishErrors, errorTabIds }: FormStepsContentProps) => { - const [selectedTabId, setSelectedTabId] = useState("waste"); + const [selectedTabId, setSelectedTabId] = useState(TabId.waste); const navigate = useNavigate(); - const tabList = getTabs(isCrematorium, errorTabIds); + const tabList = getTabs(bsdType, errorTabIds); const tabIds = tabList.map(tab => tab.tabId); const lastTabId = tabIds[tabIds.length - 1]; const firstTabId = tabIds[0]; - const ref = useRef(null); const scrollToTop = () => { - const element = ref.current; + const element = document.getElementById("formStepsTabsContent"); if (element) { - const scrollPos = element.scrollHeight - window.innerHeight; - if (scrollPos > 0) { - ref.current?.scrollIntoView({ behavior: "instant", block: "start" }); - } + element.scroll({ top: 0, behavior: "smooth" }); } }; const onSubmit = (data, e) => { @@ -76,6 +74,7 @@ const FormStepsContent = ({ }; const onTabChange = tabId => { setSelectedTabId(tabId); + scrollToTop(); }; return ( @@ -83,31 +82,31 @@ const FormStepsContent = ({ {!isLoading && ( -
        - onSubmit(data, e), - () => onErrors() - )} - onCancel={() => navigate(-1)} - onPrevTab={() => - setSelectedTabId(getPrevTab(tabIds, selectedTabId)) - } - onNextTab={() => - setSelectedTabId(getNextTab(tabIds, selectedTabId)) - } - onTabChange={onTabChange} - > - {tabsContent[selectedTabId] ??

        } -
        -
        + onSubmit(data, e), + () => onErrors() + )} + onCancel={() => navigate(-1)} + onPrevTab={() => { + setSelectedTabId(getPrevTab(tabIds, selectedTabId)); + scrollToTop(); + }} + onNextTab={() => { + setSelectedTabId(getNextTab(tabIds, selectedTabId)); + scrollToTop(); + }} + onTabChange={onTabChange} + > + {tabsContent[selectedTabId] ??

        } +
        )}
        diff --git a/front/src/Apps/Dashboard/Creation/bspaoh/FormSteps.tsx b/front/src/Apps/Dashboard/Creation/bspaoh/FormSteps.tsx index 5617652801..f4fdeda27c 100644 --- a/front/src/Apps/Dashboard/Creation/bspaoh/FormSteps.tsx +++ b/front/src/Apps/Dashboard/Creation/bspaoh/FormSteps.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery } from "@apollo/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { + BsdType, BspaohInput, Mutation, MutationCreateBspaohArgs, @@ -34,8 +35,7 @@ import { Destination } from "./steps/Destination"; import { getErrorTabIds, getPublishErrorMessages, - getPublishErrorTabIds, - getTabs + getPublishErrorTabIds } from "../utils"; const paohToInput = (paoh: BspaohInput): BspaohInput => { @@ -139,17 +139,23 @@ export function ControlledTabs(props: Readonly) { } } - const tabIds = getTabs().map(tab => tab.tabId); const errorsFromPublishApi = publishErrors || props?.publishErrorsFromRedirect; const publishErrorTabIds = getPublishErrorTabIds( - errorsFromPublishApi, - tabIds + BsdType.Bspaoh, + errorsFromPublishApi ); const formStateErrorsKeys = Object.keys(methods?.formState?.errors); - const errorTabIds = getErrorTabIds(publishErrorTabIds, formStateErrorsKeys); + const errorTabIds = getErrorTabIds( + BsdType.Bspaoh, + publishErrorTabIds, + formStateErrorsKeys + ); - const publishErrorMessages = getPublishErrorMessages(errorsFromPublishApi); + const publishErrorMessages = getPublishErrorMessages( + BsdType.Bspaoh, + errorsFromPublishApi + ); const tabsContent = { waste: , @@ -179,13 +185,13 @@ export function ControlledTabs(props: Readonly) { return ( <> { - 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/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/Dashboard/Creation/bsvhu/BsvhuFormSteps.tsx b/front/src/Apps/Dashboard/Creation/bsvhu/BsvhuFormSteps.tsx index 4874b644f3..ae08f871f8 100644 --- a/front/src/Apps/Dashboard/Creation/bsvhu/BsvhuFormSteps.tsx +++ b/front/src/Apps/Dashboard/Creation/bsvhu/BsvhuFormSteps.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery } from "@apollo/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { + BsdType, BsvhuInput, Mutation, MutationCreateBsvhuArgs, @@ -32,7 +33,7 @@ import { getErrorTabIds, getPublishErrorMessages, getPublishErrorTabIds, - getTabs + TabId } from "../utils"; const vhuToInput = (paoh: BsvhuInput): BsvhuInput => { @@ -134,49 +135,61 @@ const BsvhuFormSteps = ({ } }; - const tabIds = getTabs().map(tab => tab.tabId); const errorsFromPublishApi = publishErrors || publishErrorsFromRedirect; const publishErrorTabIds = getPublishErrorTabIds( - errorsFromPublishApi, - tabIds + BsdType.Bsvhu, + errorsFromPublishApi ); const formStateErrorsKeys = Object.keys(methods?.formState?.errors); - const errorTabIds = getErrorTabIds(publishErrorTabIds, formStateErrorsKeys); + const errorTabIds = getErrorTabIds( + BsdType.Bsvhu, + publishErrorTabIds, + formStateErrorsKeys + ); - const publishErrorMessages = getPublishErrorMessages(errorsFromPublishApi); + const publishErrorMessages = useMemo( + () => getPublishErrorMessages(BsdType.Bsvhu, errorsFromPublishApi), + [errorsFromPublishApi] + ); - const tabsContent = { - waste: ( - error.tabId === "waste")} - /> - ), - emitter: ( - error.tabId === "emitter" - )} - /> - ), - transporter: ( - error.tabId === "transporter" - )} - /> - ), - destination: ( - error.tabId === "destination" - )} - /> - ) - }; + const tabsContent = useMemo( + () => ({ + waste: ( + error.tabId === TabId.waste + )} + /> + ), + emitter: ( + error.tabId === TabId.emitter + )} + /> + ), + transporter: ( + error.tabId === TabId.transporter + )} + /> + ), + destination: ( + error.tabId === TabId.destination + )} + /> + ) + }), + [publishErrorMessages] + ); return ( <> { - 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/Destination.tsx b/front/src/Apps/Dashboard/Creation/bsvhu/steps/Destination.tsx index 2d9096cc80..ed2a475fd0 100644 --- a/front/src/Apps/Dashboard/Creation/bsvhu/steps/Destination.tsx +++ b/front/src/Apps/Dashboard/Creation/bsvhu/steps/Destination.tsx @@ -1,7 +1,7 @@ import Input from "@codegouvfr/react-dsfr/Input"; import RadioButtons from "@codegouvfr/react-dsfr/RadioButtons"; import Select from "@codegouvfr/react-dsfr/Select"; -import { CompanySearchResult, FavoriteType } from "@td/codegen-ui"; +import { CompanySearchResult, CompanyType, FavoriteType } from "@td/codegen-ui"; import React, { useEffect, useMemo, useState, useContext } from "react"; import { useFormContext } from "react-hook-form"; import { useParams } from "react-router-dom"; @@ -32,68 +32,64 @@ const DestinationBsvhu = ({ errors }) => { }, [isDangerousWasteCode, setValue]); useEffect(() => { - if ( - errors?.length && - errors?.length !== Object.keys(formState.errors)?.length && - !destination?.company?.siret - ) { + if (errors?.length) { setFieldError( errors, `${actor}.company.siret`, formState.errors?.[actor]?.["company"]?.siret, setError ); - - setFieldError( - errors, - `${actor}.company.contact`, - formState.errors?.[actor]?.["company"]?.contact, - setError - ); - - setFieldError( - errors, - `${actor}.company.address`, - formState.errors?.[actor]?.["company"]?.address, - setError - ); - - setFieldError( - errors, - `${actor}.company.phone`, - formState.errors?.[actor]?.["company"]?.phone, - setError - ); - - setFieldError( - errors, - `${actor}.company.mail`, - formState.errors?.[actor]?.["company"]?.mail, - setError - ); - - setFieldError( - errors, - `${actor}.company.vatNumber`, - formState.errors?.[actor]?.["company"]?.vatNumber, - setError - ); - - setFieldError( - errors, - `${actor}.agrementNumber`, - formState.errors?.[actor]?.["agrementNumber"], - setError - ); + if (!destination?.company?.contact) { + setFieldError( + errors, + `${actor}.company.contact`, + formState.errors?.[actor]?.["company"]?.contact, + setError + ); + } + if (!destination?.company?.address) { + setFieldError( + errors, + `${actor}.company.address`, + formState.errors?.[actor]?.["company"]?.address, + setError + ); + } + if (!destination?.company?.phone) { + setFieldError( + errors, + `${actor}.company.phone`, + formState.errors?.[actor]?.["company"]?.phone, + setError + ); + } + if (!destination?.company?.mail) { + setFieldError( + errors, + `${actor}.company.mail`, + formState.errors?.[actor]?.["company"]?.mail, + setError + ); + } + if (!destination?.company?.vatNumber) { + setFieldError( + errors, + `${actor}.company.vatNumber`, + formState.errors?.[actor]?.["company"]?.vatNumber, + setError + ); + } + if (!destination?.agrementNumber) { + setFieldError( + errors, + `${actor}.agrementNumber`, + formState.errors?.[actor]?.["agrementNumber"], + setError + ); + } } - }, [ - errors, - errors?.length, - formState.errors, - formState.errors.length, - setError, - destination?.company?.siret - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [errors]); const updateAgrementNumber = (destination, type?) => { const destinationType = type || destination?.type; @@ -144,6 +140,21 @@ const DestinationBsvhu = ({ errors }) => { ] ); + const selectedCompanyError = (company?: CompanySearchResult) => { + // 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) { + return "Cet établissement n'est pas inscrit sur Trackdéchets, il ne peut pas être ajouté sur le bordereau."; + } else if (!company.companyTypes?.includes(CompanyType.WasteVehicles)) { + return "Cet établissement n'a pas le profil Installation de traitement de VHU."; + } else if (formState.errors?.destination?.["company"]?.siret?.message) { + return formState.errors?.destination?.["company"]?.siret?.message; + } + } + return null; + }; + return ( <> {!!sealedFields.length && } @@ -189,43 +200,51 @@ const DestinationBsvhu = ({ errors }) => { favoriteType={FavoriteType.Destination} disabled={sealedFields.includes(`${actor}.company.siret`)} selectedCompanyOrgId={orgId} + selectedCompanyError={selectedCompanyError} onCompanySelected={company => { if (company) { + if (company.siret !== destination?.company?.siret) { + setValue(`${actor}.company.contact`, company.contact); + setValue(`${actor}.company.phone`, company.contactPhone); + + setValue(`${actor}.company.mail`, company.contactEmail); + if (errors?.length) { + // server errors + clearCompanyError(destination, actor, clearErrors); + clearErrors(`${actor}.agrementNumber`); + } + } else { + setValue( + `${actor}.company.contact`, + destination?.company?.contact || company.contact + ); + setValue( + `${actor}.company.phone`, + destination?.company?.phone || company.contactPhone + ); + + setValue( + `${actor}.company.mail`, + destination?.company?.mail || company.contactEmail + ); + } setValue(`${actor}.company.orgId`, company.orgId); setValue(`${actor}.company.siret`, company.siret); setValue(`${actor}.company.name`, company.name); setValue(`${actor}.company.vatNumber`, company.vatNumber); setValue(`${actor}.company.address`, company.address); - setValue( - `${actor}.company.contact`, - destination?.company?.contact || company.contact - ); - setValue( - `${actor}.company.phone`, - destination?.company?.phone || company.contactPhone - ); - - setValue( - `${actor}.company.mail`, - destination?.company?.mail || company.contactEmail - ); setSelectedDestination(company); updateAgrementNumber(company, destination?.type); - - if (errors?.length) { - // server errors - clearCompanyError(destination, actor, clearErrors); - clearErrors(`${actor}.agrementNumber`); - } } }} /> - {formState.errors?.destination?.["company"]?.siret && ( -

        - {formState.errors?.destination?.["company"]?.siret?.message} -

        - )} + {!destination?.company?.siret && + formState.errors?.destination?.["company"]?.siret && ( +

        + {formState.errors?.destination?.["company"]?.siret?.message} +

        + )} { destination?.operation?.nextDestinationcompany ?.contactEmail || company.contactEmail ); - - const agrementNumber = - company?.vhuAgrementBroyeur?.agrementNumber; - - if (agrementNumber) { - setValue( - "destination.agrementNumber", - company?.vhuAgrementBroyeur?.agrementNumber - ); - } else { - setValue("destination.agrementNumber", ""); - } } }} /> diff --git a/front/src/Apps/Dashboard/Creation/bsvhu/steps/Emitter.tsx b/front/src/Apps/Dashboard/Creation/bsvhu/steps/Emitter.tsx index 704419a23c..a09e6200a4 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 { clearCompanyError, 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,64 +28,73 @@ const EmitterBsvhu = ({ errors }) => { register("emitter.company.name"); register("emitter.company.vatNumber"); register("emitter.company.address"); + register("emitter.company.city"); + register("emitter.company.street"); + register("emitter.company.postalCode"); + register("emitter.irregularSituation"); + register("emitter.noSiret"); }, [register]); useEffect(() => { const actor = "emitter"; - if ( - errors?.length && - errors?.length !== Object.keys(formState.errors)?.length && - (!emitter?.company?.siret || !emitter?.company?.orgId) - ) { - setFieldError( - errors, - `${actor}.company.siret`, - formState.errors?.[actor]?.["company"]?.siret, - setError - ); - - setFieldError( - errors, - `${actor}.company.contact`, - formState.errors?.[actor]?.["company"]?.contact, - setError - ); - - setFieldError( - errors, - `${actor}.company.address`, - formState.errors?.[actor]?.["company"]?.address, - setError - ); - - setFieldError( - errors, - `${actor}.company.phone`, - formState.errors?.[actor]?.["company"]?.phone, - setError - ); - - setFieldError( - errors, - `${actor}.company.mail`, - formState.errors?.[actor]?.["company"]?.mail, - setError - ); - - setFieldError( - errors, - `${actor}.company.vatNumber`, - formState.errors?.[actor]?.["company"]?.vatNumber, - setError - ); - } + if (errors?.length) { + if (!emitter?.company?.siret && !emitter?.noSiret) { + setFieldError( + errors, + `${actor}.company.siret`, + formState.errors?.[actor]?.["company"]?.siret, + setError + ); + } + if (!emitter?.company?.name) { + setFieldError( + errors, + `${actor}.company.name`, + formState.errors?.[actor]?.["company"]?.name, + setError + ); + } + if (!emitter?.company?.contact) { + setFieldError( + errors, + `${actor}.company.contact`, + formState.errors?.[actor]?.["company"]?.contact, + setError + ); + } - if ( - (formState.errors?.emitter?.["company"]?.orgId?.message || - formState.errors?.emitter?.["company"]?.siret?.message) && - emitter?.company?.siret - ) { - clearErrors(`${actor}.company.orgId`); + if (!emitter?.company?.address) { + setFieldError( + errors, + `${actor}.company.address`, + formState.errors?.[actor]?.["company"]?.address, + setError + ); + } + if (!emitter?.company?.phone) { + setFieldError( + errors, + `${actor}.company.phone`, + formState.errors?.[actor]?.["company"]?.phone, + setError + ); + } + if (!emitter?.company?.mail) { + setFieldError( + errors, + `${actor}.company.mail`, + formState.errors?.[actor]?.["company"]?.mail, + setError + ); + } + if (!emitter?.company?.vatNumber) { + setFieldError( + errors, + `${actor}.company.vatNumber`, + formState.errors?.[actor]?.["company"]?.vatNumber, + setError + ); + } } }, [ errors, @@ -92,25 +103,61 @@ const EmitterBsvhu = ({ errors }) => { formState.errors?.length, setError, emitter?.company?.siret, - emitter?.company?.orgId, - clearErrors + emitter?.company?.orgId ]); + useEffect(() => { + if (errors?.length && (emitter?.noSiret || emitter?.company?.siret)) { + clearCompanyError(emitter, "emitter", clearErrors); + } + }, [clearErrors, emitter?.noSiret, emitter?.company?.siret, errors?.length]); + const orgId = useMemo( () => emitter?.company?.orgId ?? emitter?.company?.siret ?? null, [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 && }
        + { + setValue( + "emitter.irregularSituation", + e.currentTarget.checked + ); + if (!e.currentTarget.checked) { + setValue("emitter.noSiret", false); + } + } + } + } + ]} + disabled={sealedFields.includes(`emitter.irregularSituation`)} + />

        Entreprise

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

        )} + {emitter.irregularSituation && ( + <> + + + {emitter.noSiret && ( + <> + { + // `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.street`, details.name); + setValue(`emitter.company.postalCode`, details.postcode); + }} + /> + {formState.errors?.emitter?.["company"]?.address?.message && ( +

        + {formState.errors?.emitter?.["company"]?.address?.message} +

        + )} +
        + +
        + + )} + + )} + { register(`${actor}.recepisse.isExempted`); useEffect(() => { - if ( - errors?.length && - errors?.length !== Object.keys(formState.errors)?.length - ) { - setFieldError( - errors, - `${actor}.company.siret`, - formState.errors?.[actor]?.["company"]?.siret, - setError - ); - - setFieldError( - errors, - `${actor}.company.contact`, - formState.errors?.[actor]?.["company"]?.contact, - setError - ); - - setFieldError( - errors, - `${actor}.company.address`, - formState.errors?.[actor]?.["company"]?.address, - setError - ); - - setFieldError( - errors, - `${actor}.company.phone`, - formState.errors?.[actor]?.["company"]?.phone, - setError - ); - - setFieldError( - errors, - `${actor}.company.mail`, - formState.errors?.[actor]?.["company"]?.mail, - setError - ); - - setFieldError( - errors, - `${actor}.company.vatNumber`, - formState.errors?.[actor]?.["company"]?.vatNumber, - setError - ); + if (errors?.length) { + if (!transporter?.company?.siret) { + setFieldError( + errors, + `${actor}.company.siret`, + formState.errors?.[actor]?.["company"]?.siret, + setError + ); + } + if (transporter?.company?.contact) { + setFieldError( + errors, + `${actor}.company.contact`, + formState.errors?.[actor]?.["company"]?.contact, + setError + ); + } + if (transporter?.company?.address) { + setFieldError( + errors, + `${actor}.company.address`, + formState.errors?.[actor]?.["company"]?.address, + setError + ); + } + if (transporter?.company?.phone) { + setFieldError( + errors, + `${actor}.company.phone`, + formState.errors?.[actor]?.["company"]?.phone, + setError + ); + } + if (transporter?.company?.mail) { + setFieldError( + errors, + `${actor}.company.mail`, + formState.errors?.[actor]?.["company"]?.mail, + setError + ); + } + if (transporter?.company?.vatNumber) { + setFieldError( + errors, + `${actor}.company.vatNumber`, + formState.errors?.[actor]?.["company"]?.vatNumber, + setError + ); + } } }, [ errors, errors?.length, formState.errors, - formState.errors?.length, - setError + formState.errors.length, + setError, + transporter?.company?.address, + transporter?.company?.contact, + transporter?.company?.mail, + transporter?.company?.phone, + transporter?.company?.siret, + transporter?.company?.vatNumber ]); const orgId = useMemo( () => transporter?.company?.orgId ?? transporter?.company?.siret ?? null, [transporter?.company?.orgId, transporter?.company?.siret] ); + const selectedCompanyError = (company?: CompanySearchResult) => { + if (company) { + if (!company.isRegistered) { + return "Cet établissement n'est pas inscrit sur Trackdéchets."; + } else if ( + !transporter?.recepisse?.isExempted && + !company.companyTypes?.includes(CompanyType.Transporter) + ) { + return "Cet établissement n'a pas le profil Transporteur. Si vous transportez vos propres déchets, veuillez cocher la case d'exemption."; + } + } + return null; + }; return ( <> @@ -100,34 +123,53 @@ const TransporterBsvhu = ({ errors }) => { favoriteType={FavoriteType.Transporter} disabled={sealedFields.includes(`${actor}.company.siret`)} selectedCompanyOrgId={orgId} + selectedCompanyError={selectedCompanyError} onCompanySelected={company => { if (company) { + if (company.siret !== transporter?.company?.siret) { + setValue(`${actor}.company.contact`, company.contact); + setValue(`${actor}.company.phone`, company.contactPhone); + setValue(`${actor}.company.mail`, company.contactEmail); + if (errors?.length) { + // server errors + clearCompanyError(transporter, actor, clearErrors); + } + } else { + setValue( + `${actor}.company.contact`, + transporter?.company?.contact || company.contact + ); + setValue( + `${actor}.company.phone`, + transporter?.company?.phone || company.contactPhone + ); + + setValue( + `${actor}.company.mail`, + transporter?.company?.mail || company.contactEmail + ); + } setValue(`${actor}.company.orgId`, company.orgId); setValue(`${actor}.company.siret`, company.siret); setValue(`${actor}.company.name`, company.name); setValue(`${actor}.company.vatNumber`, company.vatNumber); setValue(`${actor}.company.address`, company.address); - setValue( - `${actor}.company.contact`, - transporter?.company?.contact || company.contact - ); - setValue( - `${actor}.company.phone`, - transporter?.company?.phone || company.contactPhone - ); - - setValue( - `${actor}.company.mail`, - transporter?.company?.mail || company.contactEmail - ); - - if (errors?.length) { - // server errors - clearCompanyError(transporter, actor, clearErrors); - } } }} /> + {formState.errors?.transporter?.["company"]?.orgId?.message && ( +

        + {formState.errors?.transporter?.["company"]?.orgId?.message} +

        + )} + {formState.errors?.transporter?.["company"]?.siret && ( +

        + {formState.errors?.transporter?.["company"]?.siret?.message} +

        + )} { - const { register, watch, setValue, formState, setError, clearErrors } = +const WasteBsvhu = ({ errors }: { errors: TabError[] }) => { + const { register, watch, setValue, formState, setError } = useFormContext(); // retrieve all hook methods const weight = watch("weight.value"); @@ -26,30 +26,22 @@ const WasteBsvhu = ({ errors }) => { }, [setValue, identificationNumbers]); useEffect(() => { - if ( - errors?.length && - errors?.length !== Object.keys(formState.errors)?.length && - !weight - ) { + if (errors?.length) { setFieldError( errors, "weight.value", formState.errors?.weight?.value, setError ); + setFieldError( + errors, + "identification.numbers", + formState.errors?.identification?.numbers?.length, + setError + ); } - if (errors?.length && weight) { - clearErrors("weight.value"); - } - }, [ - errors, - errors?.length, - formState?.errors, - formState.errors?.weight?.value, - setError, - weight, - clearErrors - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [errors]); return ( <> @@ -125,6 +117,7 @@ const WasteBsvhu = ({ errors }) => { disabled={sealedFields.includes("identification.numbers")} name="identification.numbers" defaultValue={identificationNumbersDefaultValue} + error={formState.errors.identification?.numbers} /> {formState.errors.identification?.numbers?.message && (

        diff --git a/front/src/Apps/Dashboard/Creation/bsvhu/utils/initial-state.ts b/front/src/Apps/Dashboard/Creation/bsvhu/utils/initial-state.ts index 0b6c63547a..707c436373 100644 --- a/front/src/Apps/Dashboard/Creation/bsvhu/utils/initial-state.ts +++ b/front/src/Apps/Dashboard/Creation/bsvhu/utils/initial-state.ts @@ -1,17 +1,37 @@ /* eslint-disable import/no-anonymous-default-export */ +import { BsvhuCompanyInput } from "@td/codegen-ui"; import { getInitialCompany } from "../../../../common/data/initialState"; +const getInitialEmitterCompany = (company?: BsvhuCompanyInput | null) => { + return { + siret: company?.siret ?? "", + name: company?.name ?? "", + address: company?.address ?? "", + city: company?.city ?? "", + street: company?.street ?? "", + postalCode: company?.postalCode ?? "", + contact: company?.contact ?? "", + mail: company?.mail ?? "", + phone: company?.phone ?? "", + vatNumber: company?.vatNumber ?? "", + country: company?.country ?? "", + omiNumber: company?.omiNumber ?? "" + }; +}; + export default { emitter: { - company: getInitialCompany(), + company: getInitialEmitterCompany(), agrementNumber: "", emission: { signature: { author: null, date: null } - } + }, + irregularSituation: false, + noSiret: false }, destination: { type: "BROYEUR", diff --git a/front/src/Apps/Dashboard/Creation/utils.ts b/front/src/Apps/Dashboard/Creation/utils.ts index a67df583fd..b13ff57d61 100644 --- a/front/src/Apps/Dashboard/Creation/utils.ts +++ b/front/src/Apps/Dashboard/Creation/utils.ts @@ -1,8 +1,10 @@ import { FrIconClassName, RiIconClassName } from "@codegouvfr/react-dsfr"; import { getTabClassName } from "../../Forms/Components/FormStepsTabs/utils"; import { toastApolloError } from "./toaster"; +import { BsdType } from "@td/codegen-ui"; +import { ApolloError } from "@apollo/client"; -export const getNextTab = (tabIds, currentTabId) => { +export const getNextTab = (tabIds: TabId[], currentTabId: TabId) => { const idx = tabIds.indexOf(currentTabId); if (idx === -1 || idx === tabIds.length) { return currentTabId; @@ -10,7 +12,7 @@ export const getNextTab = (tabIds, currentTabId) => { return tabIds[idx + 1]; }; -export const getPrevTab = (tabIds, currentTabId) => { +export const getPrevTab = (tabIds: TabId[], currentTabId: TabId) => { const idx = tabIds.indexOf(currentTabId); if (idx === -1 || idx === 0) { return currentTabId; @@ -18,7 +20,27 @@ export const getPrevTab = (tabIds, currentTabId) => { return tabIds[idx - 1]; }; -export type TabsName = "waste" | "emitter" | "transporter" | "destination"; +export enum TabId { + waste = "waste", + emitter = "emitter", + transporter = "transporter", + destination = "destination" +} + +export type NormalizedError = { + code: string; + path: string[]; + message: string; +}; + +export type SupportedBsdTypes = BsdType.Bsvhu | BsdType.Bspaoh; + +export type TabError = { + tabId: TabId; + name: string; + message: string; +}; + export type IconIdName = | "fr-icon-arrow-right-line" | "tabError fr-icon-warning-line fr-icon-arrow-right-line" @@ -26,83 +48,127 @@ export type IconIdName = | RiIconClassName; export const getTabs = ( - isCrematorium?: boolean, - errorTabIds?: string[] | (TabsName | undefined)[] + bsdType: SupportedBsdTypes, + errorTabIds?: TabId[] ): { - tabId: TabsName; + tabId: TabId; label: string; iconId: IconIdName; }[] => [ { - tabId: "waste", + tabId: TabId.waste, label: "Déchet", iconId: getTabClassName(errorTabIds, "waste") }, { - tabId: "emitter", + tabId: TabId.emitter, label: "Producteur", iconId: getTabClassName(errorTabIds, "emitter") }, { - tabId: "transporter", + tabId: TabId.transporter, label: "Transporteur", iconId: getTabClassName(errorTabIds, "transporter") }, { - tabId: "destination", - label: isCrematorium ? "Crématorium" : "Destination finale", + tabId: TabId.destination, + label: bsdType === BsdType.Bspaoh ? "Crématorium" : "Destination finale", iconId: getTabClassName(errorTabIds, "destination") } ]; -export const getPublishErrorTabIds = (apiErrors, tabIds) => { +const pathPrefixToTab = { + [BsdType.Bsvhu]: (pathPrefix: string): TabId | null => { + if ( + pathPrefix === "weight" || + pathPrefix === "quantity" || + pathPrefix === "wasteCode" || + pathPrefix === "identification" + ) { + return TabId.waste; + } + if (Object.values(TabId).includes(pathPrefix as TabId)) { + return TabId[pathPrefix]; + } + return null; + }, + [BsdType.Bspaoh]: (pathPrefix: string): TabId | null => { + if (Object.values(TabId).includes(pathPrefix as TabId)) { + return TabId[pathPrefix]; + } + return null; + } +}; + +export const getPublishErrorTabIds = ( + bsdType: SupportedBsdTypes, + apiErrors?: NormalizedError[] +): TabId[] => { // search for presence of tabId return in zod path api errors then return tab ids in error const publishErrorTabIds = [ ...new Set( apiErrors?.map(apiError => { - if (apiError.path[0]?.includes("weight")) { - return tabIds.find(key => key === "waste"); + if (apiError.path?.[0] && typeof apiError.path[0] === "string") { + return pathPrefixToTab[bsdType](apiError.path[0]); } - return tabIds.find(key => apiError.path[0]?.includes(key)); + return null; }) ) - ]; + ].filter((tabId): tabId is TabId => !!tabId); - return publishErrorTabIds as string[] | (TabsName | undefined)[]; + return publishErrorTabIds; }; -export const getPublishErrorMessages = apiErrors => { +export const getPublishErrorMessages = ( + bsdType: SupportedBsdTypes, + apiErrors?: NormalizedError[] +): TabError[] => { // return an array of messages with tabId, name (path) and the related message - const publishErrorMessages = apiErrors?.map(apiError => { - const errorPath = apiError?.path; - const pathPrefix = errorPath?.[0]; - const tabId = pathPrefix === "weight" ? "waste" : pathPrefix; - const name = errorPath.join("."); - const message = apiError.message; - return { tabId, name, message }; - }); + const publishErrorMessages = + apiErrors + ?.map(apiError => { + const errorPath = apiError?.path; + const pathPrefix = errorPath?.[0]; + const tabId = pathPrefixToTab[bsdType](pathPrefix); + const name = errorPath.join("."); + const message = apiError.message; + return { tabId, name, message }; + }) + .filter( + ( + err + ): err is { + tabId: TabId; + name: string; + message: string; + } => !!err.tabId + ) ?? []; return publishErrorMessages; }; -export const getErrorTabIds = (apiErrorTabIds, formStateErrorsKeys) => { +export const getErrorTabIds = ( + bsdType: BsdType, + apiErrorTabIds: TabId[], + formStateErrorsKeys: string[] +): TabId[] => { // get tab id in error in order to display icon tab error (either api or front validation we need to display the right icon) const errorTabIds = apiErrorTabIds?.length ? apiErrorTabIds : formStateErrorsKeys?.length > 0 - ? formStateErrorsKeys + ? formStateErrorsKeys.map(errKey => pathPrefixToTab[bsdType](errKey)) : []; return errorTabIds; }; -export const handleGraphQlError = (err, setPublishErrors) => { - if (err?.graphQLErrors?.length) { - const issues = err.graphQLErrors[0]?.extensions?.issues as { - code: string; - path: string[]; - message: string; - }[]; +export const handleGraphQlError = ( + err: ApolloError, + setPublishErrors: (normalizedErrors: NormalizedError[]) => void +) => { + if (err.graphQLErrors?.length) { + const issues = err.graphQLErrors[0]?.extensions + ?.issues as NormalizedError[]; if (issues?.length) { const errorsWithEmptyPath = issues.filter(f => !f.path.length); if (errorsWithEmptyPath.length) { @@ -111,13 +177,7 @@ export const handleGraphQlError = (err, setPublishErrors) => { const errorDetailList = issues?.map(error => { return error; }); - setPublishErrors( - errorDetailList as { - code: string; - path: string[]; - message: string; - }[] - ); + setPublishErrors(errorDetailList as NormalizedError[]); } else { toastApolloError(err); // other case like forbidden, we need to display an error anyway ... } @@ -138,11 +198,10 @@ export const setFieldError = (errors, errorPath, stateError, setError) => { }; export const clearCompanyError = (actor, actorName, clearErrors) => { - if (actor?.["company"]?.siret) { - clearErrors([`${actorName}.company.siret`]); - } - if (actor?.["company"]?.orgId) { - clearErrors([`${actorName}.company.orgId`]); + clearErrors([`${actorName}.company.siret`]); + clearErrors([`${actorName}.company.orgId`]); + if (actor?.["company"]?.name) { + clearErrors([`${actorName}.company.name`]); } if (actor?.["company"]?.contact) { clearErrors([`${actorName}.company.contact`]); diff --git a/front/src/Apps/Dashboard/DashboardRoutes.tsx b/front/src/Apps/Dashboard/DashboardRoutes.tsx index 3c1d50ef35..db9c7eb08d 100644 --- a/front/src/Apps/Dashboard/DashboardRoutes.tsx +++ b/front/src/Apps/Dashboard/DashboardRoutes.tsx @@ -273,6 +273,11 @@ function DashboardRoutes() { element={} /> + } + /> + - {status === BsvhuStatus.Initial && renderInitialModal()} - {status === BsvhuStatus.SignedByProducer && renderSignedByProducerModal()} + {status === BsvhuStatus.Initial && + canRegularSituationSign && + renderInitialModal()} + {status === BsvhuStatus.Initial && + canIrregularSituationSignWithSiretRegistered && + renderInitialModal()} + {(status === BsvhuStatus.SignedByProducer || + (status === BsvhuStatus.Initial && + (canIrregularSituationSignWithNoSiret || + canIrregularSituationSignWithSiretNotRegistered))) && + renderSignedByProducerModal()} {status === BsvhuStatus.Sent && renderSentModal()} ); diff --git a/front/src/Apps/Dashboard/bsdMapper.ts b/front/src/Apps/Dashboard/bsdMapper.ts index 76a33b2d89..3b87d7a193 100644 --- a/front/src/Apps/Dashboard/bsdMapper.ts +++ b/front/src/Apps/Dashboard/bsdMapper.ts @@ -85,11 +85,11 @@ export const getCurrentTransporterInfos = ( isToCollectTab ); case BsdTypename.Bsdasri: - return getBsdasriCurrentTransporterInfos(bsd, currentSiret); + return getBsdasriCurrentTransporterInfos(bsd); case BsdTypename.Bsvhu: return null; case BsdTypename.Bspaoh: - return getBspaohCurrentTransporterInfos(bsd, currentSiret); + return getBspaohCurrentTransporterInfos(bsd); default: return null; } @@ -101,37 +101,47 @@ export const getBsddCurrentTransporterInfos = ( isToCollectTab: boolean ): BsdCurrentTransporterInfos => { let currentTransporter: Transporter | undefined; - // in case the BSD is going through temporary storage, - // fetch the transporter from the temporaryStorageDetail property - if ( - bsdd.status === FormStatus.Resealed || - bsdd.status === FormStatus.Resent || - bsdd.status === FormStatus.SignedByTempStorer || - bsdd.status === FormStatus.TempStored || - bsdd.status === FormStatus.TempStorerAccepted - ) { - currentTransporter = - bsdd.temporaryStorageDetail?.transporter && - bsdd.temporaryStorageDetail?.transporter.company?.orgId === currentSiret - ? bsdd.temporaryStorageDetail?.transporter - : undefined; - } else { - if (isToCollectTab) { - // find the first transporter with this SIRET who hasn't taken over yet + + if (isToCollectTab) { + // the current company is a transporter and we should show his next transport's plate + // if the BSD should be collected, and the BSD is in a temp storage status, the transporter + // is the one on the temp storage + if ( + bsdd.status === FormStatus.Resealed || + bsdd.status === FormStatus.Resent || + bsdd.status === FormStatus.SignedByTempStorer || + bsdd.status === FormStatus.TempStored || + bsdd.status === FormStatus.TempStorerAccepted + ) { + currentTransporter = + bsdd.temporaryStorageDetail?.transporter && + bsdd.temporaryStorageDetail?.transporter.company?.orgId === currentSiret + ? bsdd.temporaryStorageDetail?.transporter + : undefined; + } else { + // else find the first transporter with this SIRET who hasn't taken over yet currentTransporter = bsdd.transporters?.find( transporter => transporter.company?.orgId === currentSiret && !transporter.takenOverAt ); + } + } else { + // all other tabs work like the collected tab: show the last transporter + // that picked up the waste + + // if there is a temp storage with a transporter, and the transporter has picked up + // the waste, show this one + if ( + bsdd.temporaryStorageDetail?.transporter && + bsdd.temporaryStorageDetail.takenOverAt + ) { + currentTransporter = bsdd.temporaryStorageDetail?.transporter; } else { - // find the last transporter with this SIRET who has taken over + // else, show the last transporter that picked up the waste currentTransporter = [...(bsdd.transporters ?? [])] .reverse() - .find( - transporter => - transporter.company?.orgId === currentSiret && - !!transporter.takenOverAt - ); + .find(transporter => !!transporter.takenOverAt); } } if (!currentTransporter) { @@ -139,7 +149,7 @@ export const getBsddCurrentTransporterInfos = ( } return { transporterId: currentTransporter?.id, - transporterNumberPlate: currentTransporter?.numberPlate, + transporterNumberPlate: currentTransporter?.numberPlate?.trim(), transporterCustomInfo: currentTransporter?.customInfo, transporterMode: currentTransporter?.mode ?? undefined }; @@ -162,11 +172,7 @@ export const getMultiModalCurrentTransporterInfos = ( // find the last transporter with this SIRET who has taken over currentTransporter = [...(bsd.transporters ?? [])] .reverse() - .find( - transporter => - transporter.company?.orgId === currentSiret && - !!transporter.transport?.signature?.date - ); + .find(transporter => !!transporter.transport?.signature?.date); } if (!currentTransporter) { return {}; @@ -180,30 +186,24 @@ export const getMultiModalCurrentTransporterInfos = ( }; export const getBsdasriCurrentTransporterInfos = ( - bsdasri: Bsdasri, - currentSiret: string + bsdasri: Bsdasri ): BsdCurrentTransporterInfos => { const currentTransporter = bsdasri.transporter; - if (currentTransporter?.company?.orgId !== currentSiret) { - return {}; - } // since there is only one transporter per BSDASRI, transporterId is useless, // the update is done through the BSD using its id return { - transporterNumberPlate: currentTransporter?.transport?.plates, + transporterNumberPlate: currentTransporter?.transport?.plates?.filter( + plate => plate.trim() + ), transporterCustomInfo: currentTransporter?.customInfo, transporterMode: currentTransporter?.transport?.mode ?? undefined }; }; export const getBspaohCurrentTransporterInfos = ( - bspaoh: Bspaoh, - currentSiret: string + bspaoh: Bspaoh ): BsdCurrentTransporterInfos => { const currentTransporter = bspaoh.transporter; - if (currentTransporter?.company?.orgId !== currentSiret) { - return {}; - } return { transporterNumberPlate: currentTransporter?.transport?.plates, transporterCustomInfo: currentTransporter?.customInfo, @@ -231,6 +231,9 @@ export const mapBsdd = (bsdd: Form): BsdDisplay => { transporter: bsdd.transporter, transporters: bsdd.transporters, ecoOrganisme: bsdd.ecoOrganisme, + broker: bsdd.broker, + trader: bsdd.trader, + intermediaries: bsdd.intermediaries, updatedAt: bsdd.stateSummary?.lastActionOn, emittedByEcoOrganisme: bsdd.emittedByEcoOrganisme, grouping: bsdd.grouping, @@ -267,6 +270,7 @@ const mapBsda = (bsda: Bsda): BsdDisplay => { updatedAt: bsda.updatedAt || bsda["bsdaUpdatedAt"], emittedByEcoOrganisme: bsda.ecoOrganisme, worker: bsda.worker, + broker: bsda.broker, bsdWorkflowType: bsda.type || bsda["bsdaType"], groupedIn: bsda.groupedIn, forwardedIn: bsda.forwardedIn, @@ -309,6 +313,7 @@ export const mapBsdasri = (bsdasri: Bsdasri): BsdDisplay => { emittedByEcoOrganisme: bsdasri.ecoOrganisme?.emittedByEcoOrganisme, bsdWorkflowType: bsdasri?.type, grouping: bsdasri?.grouping, + groupedIn: bsdasri.groupedIn, synthesizing: bsdasri?.synthesizing, allowDirectTakeOver: bsdasri?.allowDirectTakeOver, transporterCustomInfo: truncateTransporterInfo( diff --git a/front/src/Apps/Dashboard/dashboardServices.test.ts b/front/src/Apps/Dashboard/dashboardServices.test.ts index b3e3c23a51..ddddbb97b8 100644 --- a/front/src/Apps/Dashboard/dashboardServices.test.ts +++ b/front/src/Apps/Dashboard/dashboardServices.test.ts @@ -25,7 +25,11 @@ import { isEcoOrgSign, isEmetteurSign, isSignTransportCanSkipEmission, - getOperationCodesFromSearchString + getOperationCodesFromSearchString, + hasRoadControlButton, + getPrimaryActionsLabelFromBsdStatus, + isSiretActorForBsd, + ActorType } from "./dashboardServices"; import { BsdType, @@ -1476,4 +1480,152 @@ describe("dashboardServices", () => { expect(operationCodes).toStrictEqual([]); }); }); + + describe("isSiretActorForBsd", () => { + const bsda: BsdDisplay = { + type: BsdType.Bsda, + bsdWorkflowType: BsdaType.Collection_2710, + emitter: { + isPrivateIndividual: true + }, + transporter: { + company: { siret: "11111111111111" } + }, + worker: { + isDisabled: true + } + } as BsdDisplay; + + it("should return false if testing for a wrong siret", () => { + const currentSiret = "555555555"; + const result = isSiretActorForBsd(bsda, currentSiret, [ + { type: ActorType.Transporter } + ]); + expect(result).toBe(false); + }); + + it("should return true if testing for Transporter, non-strict", () => { + const currentSiret = "11111111111111"; + const result = isSiretActorForBsd(bsda, currentSiret, [ + { type: ActorType.Transporter } + ]); + expect(result).toBe(true); + }); + + it("should return true if testing for Transporter, strict", () => { + const currentSiret = "11111111111111"; + const result = isSiretActorForBsd(bsda, currentSiret, [ + { type: ActorType.Transporter, strict: true } + ]); + expect(result).toBe(true); + }); + + const bsff = { + type: BsdType.Bsff, + isDraft: true, + emitter: { + company: { siret: "555555555" } + }, + transporter: { + company: { siret: "987654321" } + }, + destination: { + company: { siret: "555555555" } + } + } as BsdDisplay; + + it("should return true if testing for Destination, non-strict", () => { + const currentSiret = "555555555"; + const result = isSiretActorForBsd(bsff, currentSiret, [ + { type: ActorType.Destination } + ]); + expect(result).toBe(true); + }); + + it("should return false if testing for Destination, strict", () => { + const currentSiret = "555555555"; + const result = isSiretActorForBsd(bsff, currentSiret, [ + { type: ActorType.Destination, strict: true } + ]); + expect(result).toBe(false); + }); + + const bsd: BsdDisplay = { + status: BsdStatusCode.Sealed, + type: BsdType.Bsdd, + emitter: { company: { siret: "1" } }, + transporter: { company: { siret: "2" } }, + broker: { company: { siret: "4" } }, + trader: { company: { siret: "5" } }, + intermediaries: [{ siret: "6" }], + destination: { company: { siret: "3" } }, + ecoOrganisme: { siret: "2" } + } as BsdDisplay; + + it("should return true if testing for Intermediary, strict", () => { + const currentSiret = "6"; + const result = isSiretActorForBsd(bsd, currentSiret, [ + { type: ActorType.Intermediary } + ]); + expect(result).toBe(true); + }); + + it("should return false if testing for Broker with wrong siret, strict", () => { + const currentSiret = "6"; + const result = isSiretActorForBsd(bsd, currentSiret, [ + { type: ActorType.Broker } + ]); + expect(result).toBe(false); + }); + + it("should return true if testing for EcoOrganisme, non-strict", () => { + const currentSiret = "2"; + const result = isSiretActorForBsd(bsd, currentSiret, [ + { type: ActorType.EcoOrganisme } + ]); + expect(result).toBe(true); + }); + + it("should return false if testing for EcoOrganisme, strict", () => { + const currentSiret = "2"; + const result = isSiretActorForBsd(bsd, currentSiret, [ + { type: ActorType.EcoOrganisme, strict: true } + ]); + expect(result).toBe(false); + }); + + it("should return true if testing for multiple, non-strict", () => { + const currentSiret = "2"; + const result = isSiretActorForBsd(bsd, currentSiret, [ + { type: ActorType.Transporter }, + { type: ActorType.EcoOrganisme } + ]); + expect(result).toBe(true); + }); + }); + + describe("hasRoadControlButton", () => { + it("should return true if isReturnTab", () => { + // When + const result = hasRoadControlButton({} as BsdDisplay, false, true); + + // Then + expect(result).toBeTruthy(); + }); + }); + + describe("getPrimaryActionsLabelFromBsdStatus", () => { + it("should return ROAD_CONTROL if isReturnTab", () => { + // When + const result = getPrimaryActionsLabelFromBsdStatus( + {} as BsdDisplay, + "any-siret", + [], + "returnTab" + ); + + // Then + expect(result).toBe(ROAD_CONTROL); + }); + }); }); diff --git a/front/src/Apps/Dashboard/dashboardServices.ts b/front/src/Apps/Dashboard/dashboardServices.ts index e54942b6f7..0c35dfdd32 100644 --- a/front/src/Apps/Dashboard/dashboardServices.ts +++ b/front/src/Apps/Dashboard/dashboardServices.ts @@ -21,7 +21,8 @@ import { Transporter, BsdaTransporter, BsdasriStatus, - BsffTransporter + BsffTransporter, + BsvhuStatus } from "@td/codegen-ui"; import { ACCEPTE, @@ -205,6 +206,64 @@ const isBsdd = (type: BsdType): boolean => type === BsdType.Bsdd; export const isBsdasri = (type: BsdType): boolean => type === BsdType.Bsdasri; export const isBspaoh = (type: BsdType): boolean => type === BsdType.Bspaoh; +export enum ActorType { + Emitter = "EMITTER", + Transporter = "TRANSPORTER", + NextTransporter = "NEXTTRANSPORTER", + Destination = "DESTINATION", + Intermediary = "INTERMEDIARY", + EcoOrganisme = "ECOORGANISME", + Broker = "BROKER", + Worker = "WORKER", + TempStorage = "TEMPSTORAGE", + Trader = "TRADER" +} + +interface ActorTypes { + type: ActorType; + strict?: boolean; +} + +export const isSiretActorForBsd = ( + bsd: BsdDisplay, + siret: string, + actorTypes: ActorTypes[] +): boolean => { + const actorTypesForSiret = [ + ...(siret === bsd.emitter?.company?.siret ? [ActorType.Emitter] : []), + ...(siret === bsd.ecoOrganisme?.siret ? [ActorType.EcoOrganisme] : []), + ...(siret === bsd.destination?.company?.siret + ? [ActorType.Destination] + : []), + ...(siret === bsd.worker?.company?.siret ? [ActorType.Worker] : []), + ...(siret === bsd.transporter?.company?.siret + ? [ActorType.Transporter] + : []), + ...(siret === bsd.broker?.company?.siret ? [ActorType.Broker] : []), + ...(siret === bsd.trader?.company?.siret ? [ActorType.Trader] : []), + ...(bsd.intermediaries?.some(p => p.siret === siret) + ? [ActorType.Intermediary] + : []), + ...(siret === bsd.temporaryStorageDetail?.destination?.company?.siret + ? [ActorType.TempStorage] + : []), + ...(siret === getNextTransporter(bsd)?.company?.siret + ? [ActorType.NextTransporter] + : []) + ]; + + return actorTypesForSiret.length === 0 + ? false + : actorTypes + .map(({ type, strict = false }) => { + return actorTypesForSiret.includes(type) && strict + ? actorTypesForSiret.includes(type) && + actorTypesForSiret.length === 1 + : actorTypesForSiret.includes(type); + }) + .reduce((acc, curr) => acc && curr, true); +}; + const hasEmitterTransporterAndEcoOrgSiret = ( bsd: BsdDisplay, siret: string @@ -335,8 +394,55 @@ export const isBsdaSignWorker = (bsd: BsdDisplay, currentSiret: string) => { return false; }; +const isIrregularSituation = bsd => bsd.emitter?.irregularSituation; + +// emitter is irregular and has no siret, transporter signature is needed +const canIrregularSituationSignWithNoSiret = ( + bsd: BsdDisplay, + currentSiret: string +) => { + return ( + bsd.status === BsvhuStatus.Initial && + isIrregularSituation(bsd) && + bsd.emitter?.noSiret && + isSameSiretTransporter(currentSiret, bsd) + ); +}; + +// emitter is irregular but has registered siret, he can sign +const canIrregularSituationSignWithSiretRegistered = ( + bsd: BsdDisplay, + currentSiret: string +) => { + return ( + bsd.status === BsvhuStatus.Initial && + isIrregularSituation(bsd) && + !bsd.emitter?.noSiret && + isSameSiretEmitter(currentSiret, bsd) && + !isSameSiretTransporter(currentSiret, bsd) + ); +}; + +// emitter is irregular but has not registered siret, transporter signature is needed +const canIrregularSituationSignWithSiretNotRegistered = ( + bsd: BsdDisplay, + currentSiret: string +) => { + return ( + bsd.status === BsvhuStatus.Initial && + isIrregularSituation(bsd) && + !bsd.emitter?.noSiret && + !isSameSiretEmitter(currentSiret, bsd) && + isSameSiretTransporter(currentSiret, bsd) + ); +}; + export const isBsvhuSign = (bsd: BsdDisplay, currentSiret: string) => - isBsvhu(bsd.type) && isSameSiretEmitter(currentSiret, bsd); + isBsvhu(bsd.type) && + ((isSameSiretEmitter(currentSiret, bsd) && !isIrregularSituation(bsd)) || + canIrregularSituationSignWithNoSiret(bsd, currentSiret) || + canIrregularSituationSignWithSiretRegistered(bsd, currentSiret) || + canIrregularSituationSignWithSiretNotRegistered(bsd, currentSiret)); export const isBsffSign = ( bsd: BsdDisplay, @@ -535,7 +641,11 @@ export const getSealedBtnLabel = ( (permissions.includes(UserPermission.BsdCanSignEmission) || permissions.includes(UserPermission.BsdCanSignTransport)) ) { - if (isAppendix1(bsd) && canAddAppendix1(bsd)) { + if ( + isAppendix1(bsd) && + canAddAppendix1(bsd) && + hasAppendix1Cta(bsd, currentSiret) + ) { return AJOUTER_ANNEXE_1; } @@ -621,6 +731,7 @@ export const getSentBtnLabel = ( if ( isAppendix1(bsd) && canAddAppendix1(bsd) && + hasAppendix1Cta(bsd, currentSiret) && permissions.includes(UserPermission.BsdCanUpdate) ) { return AJOUTER_ANNEXE_1; @@ -1032,6 +1143,10 @@ export const getPrimaryActionsLabelFromBsdStatus = ( hasAutomaticSignature?: boolean, emitterIsExutoireOrTtr?: boolean ) => { + const isReturnTab = bsdCurrentTab === "returnTab"; + + if (isReturnTab) return ROAD_CONTROL; + switch (bsd.status) { case BsdStatusCode.Draft: case BsdStatusCode.Initial: @@ -1265,6 +1380,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 || @@ -1311,9 +1430,10 @@ const canReviewBsdasri = (bsd, siret) => { return false; } - if (bsd.groupedInId || bsd.synthesizedInId) { + if (bsd.groupedIn || bsd.synthesizedIn) { return false; } + const isDestination = isSameSiretDestination(siret, bsd); const isProducer = isSameSiretEmitter(siret, bsd); const isEcoOrganisme = isSameSiretEcorganisme(siret, bsd); @@ -1432,11 +1552,30 @@ export const canUpdateBsd = (bsd, siret) => export const canGeneratePdf = bsd => bsd.type === BsdType.Bsff || !bsd.isDraft; -export const hasAppendix1Cta = (bsd: BsdDisplay): boolean => { +export const hasAppendix1Cta = ( + bsd: BsdDisplay, + currentSiret: string +): boolean => { + const isBroker = isSiretActorForBsd(bsd, currentSiret, [ + { type: ActorType.Broker, strict: true } + ]); + + const isIntermediary = isSiretActorForBsd(bsd, currentSiret, [ + { type: ActorType.Intermediary, strict: true } + ]); + + const isTrader = isSiretActorForBsd(bsd, currentSiret, [ + { type: ActorType.Trader, strict: true } + ]); + return ( bsd.type === BsdType.Bsdd && bsd?.emitterType === EmitterType.Appendix1 && - (BsdStatusCode.Sealed === bsd.status || BsdStatusCode.Sent === bsd.status) + (BsdStatusCode.Sealed === bsd.status || + BsdStatusCode.Sent === bsd.status) && + !isBroker && + !isIntermediary && + !isTrader ); }; @@ -1475,8 +1614,11 @@ export const hasBsdasriEmitterSign = ( export const hasRoadControlButton = ( bsd: BsdDisplay, - isCollectedTab: boolean + isCollectedTab: boolean, + isReturnTab?: boolean ) => { + if (isReturnTab) return true; + // L'action principale sur le paoh collecté est la déclaration de dépôt par le transporteur if (bsd.type === BsdType.Bspaoh) { return false; diff --git a/front/src/Apps/Dashboard/dashboardUtils.tsx b/front/src/Apps/Dashboard/dashboardUtils.tsx index a4818e0656..7f08872c8b 100644 --- a/front/src/Apps/Dashboard/dashboardUtils.tsx +++ b/front/src/Apps/Dashboard/dashboardUtils.tsx @@ -648,7 +648,8 @@ export const getBsdCurrentTab = ({ isToCollectTab, isCollectedTab, isReviewedTab, - isToReviewTab + isToReviewTab, + isReturnTab }): BsdCurrentTab => { if (isDraftTab) { return "draftTab"; @@ -674,6 +675,9 @@ export const getBsdCurrentTab = ({ if (isCollectedTab) { return "collectedTab"; } + if (isReturnTab) { + return "returnTab"; + } // default tab return "allBsdsTab"; }; diff --git a/front/src/Apps/Forms/Components/FormStepsTabs/FormStepsTabs.tsx b/front/src/Apps/Forms/Components/FormStepsTabs/FormStepsTabs.tsx index 0676ce884e..2ebe1e3793 100644 --- a/front/src/Apps/Forms/Components/FormStepsTabs/FormStepsTabs.tsx +++ b/front/src/Apps/Forms/Components/FormStepsTabs/FormStepsTabs.tsx @@ -54,7 +54,9 @@ const FormStepsTabs = ({ tabs={tabList} onTabChange={onTabChange} > -

        {children}
        +
        + {children} +
        diff --git a/front/src/Apps/Forms/Components/IdentificationNumbers/IdentificationNumber.tsx b/front/src/Apps/Forms/Components/IdentificationNumbers/IdentificationNumber.tsx index 1448e3a09d..65147118ef 100644 --- a/front/src/Apps/Forms/Components/IdentificationNumbers/IdentificationNumber.tsx +++ b/front/src/Apps/Forms/Components/IdentificationNumbers/IdentificationNumber.tsx @@ -1,6 +1,7 @@ import Tag from "@codegouvfr/react-dsfr/Tag"; import React, { useEffect, useReducer, useRef } from "react"; import { useFormContext } from "react-hook-form"; +import classNames from "classnames"; import TdTooltip from "../../../../common/components/Tooltip"; import "./identificationNumber.scss"; interface IdentificationNumberProps { @@ -19,7 +20,7 @@ const IdentificationNumber = ({ type, defaultValue }: IdentificationNumberProps) => { - const { setValue, getValues } = useFormContext(); + const { setValue, getValues, clearErrors } = useFormContext(); const SET_INPUT_CODE = "set_input_code"; const ADD_CODE = "add_code"; @@ -85,7 +86,8 @@ const IdentificationNumber = ({ useEffect(() => { setValue(name, state.codes); - }, [state, name, setValue]); + clearErrors(name); + }, [state, name, setValue, clearErrors]); useEffect(() => { if (defaultValue) { @@ -95,11 +97,18 @@ const IdentificationNumber = ({ return ( <> -

        +

        {title}

        -
        +
        {state?.codes?.map((code, idx) => ( {type && ( -

        +

        Vous avez {state.codes.length} {type} pour ce contenant

        )} diff --git a/front/src/Apps/Forms/Components/IdentificationNumbers/identificationNumber.scss b/front/src/Apps/Forms/Components/IdentificationNumbers/identificationNumber.scss index aa8a97c009..0b1aae38e9 100644 --- a/front/src/Apps/Forms/Components/IdentificationNumbers/identificationNumber.scss +++ b/front/src/Apps/Forms/Components/IdentificationNumbers/identificationNumber.scss @@ -5,6 +5,7 @@ font-size: 1rem; line-height: 1.2rem; padding: 0.5rem 1rem 0.5rem 1rem; + margin-top: 0.5em; color: var(--text-default-grey); background-color: rgb(238, 238, 238); --idle: transparent; @@ -12,6 +13,9 @@ --active: var(--background-contrast-grey-active); box-shadow: inset 0 -2px 0 0 var(--border-plain-grey); row-gap: 10px; + &.error { + box-shadow: inset 0 -2px 0 0 var(--border-plain-error); + } } .multiTagsInput { @@ -20,3 +24,9 @@ padding: 0 0.4rem; width: 100%; } + +.multiTags-title { + &.error { + color: var(--text-default-error); + } +} diff --git a/front/src/Apps/common/Components/A11ySkipLinks/A11ySkipLinks.tsx b/front/src/Apps/common/Components/A11ySkipLinks/A11ySkipLinks.tsx new file mode 100644 index 0000000000..2c40523018 --- /dev/null +++ b/front/src/Apps/common/Components/A11ySkipLinks/A11ySkipLinks.tsx @@ -0,0 +1,171 @@ +import { MEDIA_QUERIES } from "../../../../common/config"; +import { useMedia } from "../../../../common/use-media"; +import routes from "../../../routes"; +import React, { useRef } from "react"; +import { matchPath, useLocation } from "react-router-dom"; + +const A11ySkipLinks = () => { + 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/CompanySwitcher/CompanySwitcher.tsx b/front/src/Apps/common/Components/CompanySwitcher/CompanySwitcher.tsx index 186af926ad..a4c5dbb969 100644 --- a/front/src/Apps/common/Components/CompanySwitcher/CompanySwitcher.tsx +++ b/front/src/Apps/common/Components/CompanySwitcher/CompanySwitcher.tsx @@ -138,9 +138,15 @@ const CompanySwitcher = ({ role="presentation" >
        -
        - {company.givenName || company.name} -
        + {current ? ( +

        + {company.givenName || company.name} +

        + ) : ( +
        + {company.givenName || company.name} +
        + )} {current && companies.length > 1 && (
        {company.orgId}
        {current && ( -

        +

        + + Code signature de l'établissement + {company.securityCode}

        )} diff --git a/front/src/Apps/common/Components/CompanySwitcher/companySwitcher.scss b/front/src/Apps/common/Components/CompanySwitcher/companySwitcher.scss index 93003cdeab..fa3f3c1edf 100644 --- a/front/src/Apps/common/Components/CompanySwitcher/companySwitcher.scss +++ b/front/src/Apps/common/Components/CompanySwitcher/companySwitcher.scss @@ -66,10 +66,13 @@ font-size: 14px; height: 100%; display: -webkit-box; + line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-all; + line-height: normal; + margin: 0; } &__infos { @@ -82,6 +85,15 @@ color: #666; font-size: 12px; margin-right: 16px; + min-width: 0; + text-overflow: ellipsis; + overflow: hidden; + word-wrap: normal; + } + + &__code { + flex-basis: auto; + flex-shrink: 0; } &__arrow { 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/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/PageTitle/PageTitle.tsx b/front/src/Apps/common/Components/PageTitle/PageTitle.tsx new file mode 100644 index 0000000000..1a4a5fcb3d --- /dev/null +++ b/front/src/Apps/common/Components/PageTitle/PageTitle.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { matchRoutes, useLocation } from "react-router-dom"; +import { titles } from "../../../routes"; + +const PageTitle = () => { + const currentLocation = useLocation(); + + const paths = Object.keys(titles) + .filter(key => key !== "default") + .map(title => { + return { + path: title + }; + }); + + const matches = matchRoutes(paths, currentLocation); + const titlePath = matches ? matches[0].route.path : "default"; + const title = titles[titlePath] ? titles[titlePath] : titles.default; + + useEffect(() => { + document.title = title; + }, [title]); + + return null; +}; + +export default PageTitle; 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/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/SideBar/sidebar.scss b/front/src/Apps/common/Components/SideBar/sidebar.scss index 64d67d2874..4d8d3da829 100644 --- a/front/src/Apps/common/Components/SideBar/sidebar.scss +++ b/front/src/Apps/common/Components/SideBar/sidebar.scss @@ -3,25 +3,32 @@ .sidebarv2 { width: 250px; - max-height: 700px; + max-height: 100%; padding: 0 8px; border-right: solid 1px $grey-light; position: sticky; top: 56px; &__item { - display: block; - height: 30px; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-all; line-height: 30px; border-left: 3px solid transparent; padding-left: 1rem; + &--indented { padding-left: 1.2rem; } + &--chapter { margin-top: 0.8rem; font-weight: bold; } + &:hover { border-color: $blue-dark; } @@ -31,8 +38,13 @@ font-weight: bold; border-color: $blue-dark; } + li { + display: flex; + justify-content: space-between; + align-items: center; list-style: none; + a { text-decoration: none; color: $black; 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 432ea9804a..d1433b42fe 100644 --- a/front/src/Apps/common/Components/layout/Header.tsx +++ b/front/src/Apps/common/Components/layout/Header.tsx @@ -27,7 +27,8 @@ import { TO_COLLECT, TRANSPORT, TO_REVIEW, - REVIEWED + REVIEWED, + RETURN } from "../../../common/wordings/dashboard/wordingsDashboard"; import routes from "../../../routes"; @@ -281,6 +282,17 @@ function DashboardSubNav({ currentCompany }) { }} />
      • +
      • + +
  • @@ -426,6 +438,7 @@ function MobileSubNav({ currentCompany }) {
    ); } +const allBsdsMenuEntryLbl = "Mes bordereaux"; const getDesktopMenuEntries = ( isAuthenticated, @@ -448,10 +461,9 @@ const getDesktopMenuEntries = ( navlink: true } ]; - const connected = [ { - caption: "Mes bordereaux", + caption: allBsdsMenuEntryLbl, href: currentSiret ? generatePath(routes.dashboard.index, { siret: currentSiret @@ -582,13 +594,17 @@ export default function Header({
    de la transition
    - écologique + écologique, +
    + de l'énergie, du climat,
    + et de la prévention
    + des risques

    Trackdéchets
      {menuEntries.map((e, idx) => ( -
    • +
    • ))} @@ -774,6 +798,7 @@ export default function Header({
    )} +
    + ); } diff --git a/front/src/Apps/common/Components/layout/LayoutContainer.tsx b/front/src/Apps/common/Components/layout/LayoutContainer.tsx index ed24f8b23f..b546714291 100644 --- a/front/src/Apps/common/Components/layout/LayoutContainer.tsx +++ b/front/src/Apps/common/Components/layout/LayoutContainer.tsx @@ -113,12 +113,12 @@ export default function LayoutContainer() { isAdmin={isAdmin} v2banner={ } defaultOrgId={defaultOrgId} 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/bsdasri.ts b/front/src/Apps/common/queries/fragments/bsdasri.ts index 8beeca9c73..1ef65a4c05 100644 --- a/front/src/Apps/common/queries/fragments/bsdasri.ts +++ b/front/src/Apps/common/queries/fragments/bsdasri.ts @@ -134,6 +134,9 @@ export const dashboardDasriFragment = gql` } } } + groupedIn { + id + } # Attention, pour des raisons de performance, seul # l'identifiant est exposé ici. Requêter d'autres champs sur # grouping ne fonctionnera pas diff --git a/front/src/Apps/common/queries/fragments/bsdd.ts b/front/src/Apps/common/queries/fragments/bsdd.ts index 129831112d..dedbf988f3 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 @@ -567,6 +568,15 @@ export const dashboardFormFragment = gql` } currentTransporterSiret nextTransporterSiret + intermediaries { + ...CompanyFragment + } + trader { + ...TraderFragment + } + broker { + ...BrokerFragment + } metadata { latestRevision { id @@ -582,4 +592,7 @@ export const dashboardFormFragment = gql` } } } + ${traderFragment} + ${companyFragment} + ${brokerFragment} `; 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 diff --git a/front/src/Apps/common/queries/fragments/bsvhu.ts b/front/src/Apps/common/queries/fragments/bsvhu.ts index f4ffb866c9..e39aaeeb6a 100644 --- a/front/src/Apps/common/queries/fragments/bsvhu.ts +++ b/front/src/Apps/common/queries/fragments/bsvhu.ts @@ -58,6 +58,8 @@ export const dashboardVhuFragment = gql` isDraft emitter { agrementNumber + irregularSituation + noSiret company { ...DashboardCompanyFragment } @@ -103,6 +105,8 @@ export const FullBsvhuFragment = gql` ...CompanyFragment } agrementNumber + irregularSituation + noSiret emission { signature { author 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..764e17e411 --- /dev/null +++ b/front/src/Apps/common/queries/registryDelegation/queries.ts @@ -0,0 +1,76 @@ +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 + status + } + } +`; + +export const CANCEL_REGISTRY_DELEGATION = gql` + mutation cancelRegistryDelegation($delegationId: ID!) { + cancelRegistryDelegation(delegationId: $delegationId) { + id + status + } + } +`; diff --git a/front/src/Apps/common/types/bsdTypes.ts b/front/src/Apps/common/types/bsdTypes.ts index 677a4cbb1f..6c06a91777 100644 --- a/front/src/Apps/common/types/bsdTypes.ts +++ b/front/src/Apps/common/types/bsdTypes.ts @@ -44,7 +44,11 @@ import { TemporaryStorageDetail, Transporter, TransportMode, - BsdasriMetadata + BsdasriMetadata, + Broker, + Trader, + BsdaBroker, + FormCompany } from "@td/codegen-ui"; export enum BsdTypename { @@ -117,6 +121,9 @@ export interface BsdDisplay { updatedAt?: Maybe | Maybe; emittedByEcoOrganisme?: Maybe | Maybe; worker?: Maybe; + trader?: Maybe; + broker?: Maybe | Maybe; + intermediaries?: FormCompany[]; bsdWorkflowType?: | Maybe | BsdasriType @@ -127,7 +134,7 @@ export interface BsdDisplay { | Maybe> | Array | Maybe>; - groupedIn?: Maybe; + groupedIn?: Maybe | Maybe; forwardedIn?: Maybe; synthesizing?: Maybe>; temporaryStorageDetail?: Maybe; diff --git a/front/src/Apps/common/types/commonTypes.ts b/front/src/Apps/common/types/commonTypes.ts index f3f9ff82b6..c31ef6ce7e 100644 --- a/front/src/Apps/common/types/commonTypes.ts +++ b/front/src/Apps/common/types/commonTypes.ts @@ -11,4 +11,5 @@ export type BsdCurrentTab = | "reviewedTab" | "toReviewTab" | "collectedTab" + | "returnTab" | "allBsdsTab"; diff --git a/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts b/front/src/Apps/common/wordings/dashboard/wordingsDashboard.ts index 2e727f804d..a6cab17562 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"; @@ -77,6 +78,10 @@ sur l'état précis du bordereau.`; export const blankstate_reviews_title = "Il n'y a aucun bordereau en révision"; export const blankstate_reviews_desc = "Vous n'avez aucune révision en attente."; +export const blankstate_return_title = + 'Aucun bordereau dans le dossier "Retours"'; +export const blankstate_return_desc = + "Les bordereaux refusés, même partiellement ou ayant fait l'objet d'une information sur le retour à vide (ADR) ou de rinçage citerne, seront affichés dans ce dossier durant 48h après la réception de l'installation de destination, et ce afin de permettre de disposer du bordereau en cas de contrôle lors du retour. Si un BSD est absent vérifiez qu'il est bien complété."; export const blankstate_default_title = "Il n'y a aucun bordereau"; export const blankstate_default_desc = "Si vous le souhaitez, vous pouvez créer un bordereau depuis le menu de création ci-dessus"; @@ -135,7 +140,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"; @@ -190,6 +195,7 @@ export const COLLECTED = "Collectés"; export const REGISTER = "Registre"; export const TO_REVIEW = "En cours"; export const REVIEWED = "Révisés"; +export const RETURN = "Retours"; // Reviews export const GERER_REVISION = "Gérer la révision"; diff --git a/front/src/Apps/routes.ts b/front/src/Apps/routes.ts index 7d2e78cbb5..4c49e1e268 100644 --- a/front/src/Apps/routes.ts +++ b/front/src/Apps/routes.ts @@ -1,3 +1,7 @@ +export function getRelativeRoute(index, route) { + return route.replace(`${index}/`, ""); +} + const routes = { admin: { index: "/admin", @@ -8,9 +12,9 @@ const routes = { user: "/admin/user", impersonate: "/admin/impersonate", registry: "/admin/registry", - membersAdmin: "admin/members", - bsdAdmin: "admin/bsd", - massProfilesAdmin: "admin/mass-profile" + membersAdmin: "/admin/members", + bsdAdmin: "/admin/bsd", + massProfilesAdmin: "/admin/mass-profile" }, login: "/login", invite: "/invite", @@ -88,12 +92,14 @@ const routes = { transport: { index: "/dashboard/:siret/transport", toCollect: "/dashboard/:siret/transport/to-collect", - collected: "/dashboard/:siret/transport/collected" + collected: "/dashboard/:siret/transport/collected", + return: "/dashboard/:siret/transport/return" } }, account: { index: "/account", info: "/account/info", + notifications: "/account/notifications", // Old routes to keep integrations from breaking companies: { create: { @@ -127,8 +133,113 @@ const routes = { registry: "/registre" }; -export function getRelativeRoute(index, route) { - return route.replace(`${index}/`, ""); -} +export const titles = { + default: "Trackdéchets", + "/admin": "Panneau d'administration — Trackdéchets", + "/admin/verification": "Vérification d'entreprise — Trackdéchets", + "/admin/companies": "Données entreprises — Trackdéchets", + "/admin/anonymous-company": "Ajouter une entreprise anonyme — Trackdéchets", + "/admin/reindex": "Réindéxation de bordereaux — Trackdéchets", + "/admin/user": "Supprimer un utilisateur — Trackdéchets", + "/admin/impersonate": "Impersonation — Trackdéchets", + "/admin/registry": "Registres — Trackdéchets", + "/admin/members": "Gestion des membres — Trackdéchets", + "/admin/bsd": "Consultation BSD — Trackdéchets", + "/admin/mass-profile": "Gestion en masse des profils — Trackdéchets", + "/login": "Se connecter — Trackdéchets", + "/invite": "", + "/membership-request/:id": "", + "/signup": "Créer un compte — Trackdéchets", + "/signup/details": "Liste des codes déchets autorisés — Trackdéchets", + "/signup/activation": "Se connecter — Trackdéchets", + "/user-activation": "Activer mon compte utilisateur — Trackdéchets", + "/resend-activation-email": + "Renvoyer l'e-mail d'activation de mon compte — Trackdéchets", + "/password-reset-request": + "Demande de réinitialisation de mot de passe — Trackdéchets", + "/password-reset": "Réinitialiser mon mot de passe — Trackdéchets", + "/company/:orgId": "Fiche établissement — Trackdéchets", + "/wasteTree": "Liste des codes déchets — Trackdéchets", + "/dashboard/:siret": "Tableau de bord — Trackdéchets", + "/dashboard/:siret/road-control/:id": "Contrôle Routier — Trackdéchets", + "/dashboard/:siret/bsds/all": "Tous mes bordereaux — Trackdéchets", + "/dashboard/:siret/bsds/drafts": "Brouillons — Trackdéchets", + "/dashboard/:siret/bsds/act": "Pour action — Trackdéchets", + "/dashboard/:siret/bsds/follow": "Bordereaux suivis — Trackdéchets", + "/dashboard/:siret/bsds/history": "Bordereaux archivés — Trackdéchets", + "/dashboard/:siret/bsds/to-review/:bsdId?": + "Révisions en cours — Trackdéchets", + "/dashboard/:siret/bsds/reviewed": "Révisions passées — Trackdéchets", + "/dashboard/:siret/bsdds/create": "Créer un BSDD — Trackdéchets", + "/dashboard/:siret/bsdds/edit/:id": "Modifier le BSDD — Trackdéchets", + "/dashboard/:siret/bsdds/view/:id": "Aperçu du BSDD — Trackdéchets", + "/dashboard/:siret/bsdds/review/:id": "Réviser le BSDD — Trackdéchets", + "/dashboard/:siret/bsdasris/view/:id": "Aperçu du BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/create": "Créer un BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/edit/:id": "Modifier le BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/publish/:id": "Publier le BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/direct-takeover/:id": + "Signer l'emport direct du BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/synthesis-takeover/:id": + "Valider le bordereau de synthèse BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/emission/:id": "", + "/dashboard/:siret/bsdasris/sign-emitter-secret/:id": + "Signer le BSDASRI avec le code signature de l'émetteur — Trackdéchets", + "/dashboard/:siret/bsdasris/sign-transporter/:id": + "Signer l'enlèvement du BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/sign-reception/:id": + "Signer la réception du BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/sign-operation/:id": + "Signer l'opération du BSDASRI — Trackdéchets", + "/dashboard/:siret/bsdasris/review/:id": "Réviser le BSDASRI — Trackdéchets", + "/dashboard/:siret/bsvhus/create": "Créer un BSVHU — Trackdéchets", + "/dashboard/:siret/bsvhus/edit/:id": "Modifier le BSVHU — Trackdéchets", + "/dashboard/:siret/bsvhus/view/:id": "Aperçu du BSVHU — Trackdéchets", + "/dashboard/:siret/bsffs/create": "Créer un BSFF — Trackdéchets", + "/dashboard/:siret/bsffs/edit/:id": "Modifier le BSFF — Trackdéchets", + "/dashboard/:siret/bsffs/view/:id": "Aperçu du BSFF — Trackdéchets", + "/dashboard/:siret/bsdas/create": "Créer un BSDA — Trackdéchets", + "/dashboard/:siret/bsdas/edit/:id": "Modifier le BSDA — Trackdéchets", + "/dashboard/:siret/bsdas/view/:id": "Aperçu du BSDA — Trackdéchets", + "/dashboard/:siret/bsdas/review/:id": "Réviser le BSDA — Trackdéchets", + "/dashboard/:siret/bspaohs/create": "Créer un BSPAOH — Trackdéchets", + "/dashboard/:siret/bspaohs/edit/:id": "Modifier le BSPAOH — Trackdéchets", + "/dashboard/:siret/bspaohs/view/:id": "Aperçu du BSPAOH — Trackdéchets", + "/dashboard/:siret/transport": "Onglet transport — Trackdéchets", + "/dashboard/:siret/transport/to-collect": + "Bordereaux à collecter — Trackdéchets", + "/dashboard/:siret/transport/collected": + "Bordereaux collectés — Trackdéchets", + "/dashboard/:siret/transport/return": "Bordereaux en retour — Trackdéchets", + "/account": "Mon compte — Trackdéchets", + "/account/info": "Informations sur mon compte — Trackdéchets", + "/account/companies/new": + "Ajouter un établissement producteur de déchets — Trackdéchets", + "/account/companies/professional": + "Ajouter un établissement gestionnaire de déchets — Trackdéchets", + "/account/companies/foreign": + "Ajouter un transporteur étranger, Non-French carrier — Trackdéchets", + "/account/companies/join": "", + "/account/companies": "Mes établissements | Trackdéchets", + "/account/companies/create": "Ajouter un établissement — Trackdéchets", + "/account/applications": "Applications autorisées — Trackdéchets", + "/account/tokens/list": "Mes jetons API — Trackdéchets", + "/account/oauth2/create": + "Ajouter une application tierce sur la plateforme — Trackdéchets", + "/account/oauth2/edit/:id": + "Modifier une application tierce ajoutée sur la plateforme — Trackdéchets", + "/account/oauth2/list": "Mes applications — Trackdéchets", + "/companies": "Mes établissements | Trackdéchets", + "/companies/:siret": "Mon établissement | Trackdéchets", + "/companies/new": + "Ajouter un établissement producteur de déchets — Trackdéchets", + "/companies/professional": + "Ajouter un établissement gestionnaire de déchets — Trackdéchets", + "/companies/foreign": + "Ajouter un transporteur étranger, Non-French carrier — Trackdéchets", + "/companies/join": "", + "/companies/create": "Ajouter un établissement — Trackdéchets", + "/registre": "Mes registres — Trackdéchets" +}; export default routes; diff --git a/front/src/Pages/Dashboard.tsx b/front/src/Pages/Dashboard.tsx index 0785492467..5c3c5d6921 100644 --- a/front/src/Pages/Dashboard.tsx +++ b/front/src/Pages/Dashboard.tsx @@ -53,6 +53,7 @@ const DashboardPage = () => { const isReviewedTab = !!useMatch(routes.dashboard.bsds.reviewed); const isToCollectTab = !!useMatch(routes.dashboard.transport.toCollect); const isCollectedTab = !!useMatch(routes.dashboard.transport.collected); + const isReturnTab = !!useMatch(routes.dashboard.transport.return); const isAllBsdsTab = !!useMatch(routes.dashboard.bsds.index); const location = useLocation(); @@ -65,7 +66,8 @@ const DashboardPage = () => { isToCollectTab, isCollectedTab, isReviewedTab, - isToReviewTab + isToReviewTab, + isReturnTab }); const { siret } = useParams<{ siret: string }>(); const [areAdvancedFiltersOpen, setAreAdvancedFiltersOpen] = useState(false); @@ -95,7 +97,8 @@ const DashboardPage = () => { isCollectedTab, isAllBsdsTab, isReviewedTab, - isToReviewTab + isToReviewTab, + isReturnTab }), [ isActTab, @@ -106,7 +109,8 @@ const DashboardPage = () => { isFollowTab, isReviewedTab, isToCollectTab, - isToReviewTab + isToReviewTab, + isReturnTab ] ); @@ -207,7 +211,7 @@ const DashboardPage = () => { const isLoadingBsds = loading; return ( -
    +
    {permissions.includes(UserPermission.BsdCanCreate) && (
    diff --git a/front/src/Pages/Dashboard.utils.tsx b/front/src/Pages/Dashboard.utils.tsx index d6e972cde8..c7e7f0bb84 100644 --- a/front/src/Pages/Dashboard.utils.tsx +++ b/front/src/Pages/Dashboard.utils.tsx @@ -11,6 +11,8 @@ import { blankstate_follow_title, blankstate_history_desc, blankstate_history_title, + blankstate_return_desc, + blankstate_return_title, blankstate_reviews_desc, blankstate_reviews_title } from "../Apps/common/wordings/dashboard/wordingsDashboard"; @@ -27,6 +29,7 @@ export type Tabs = { isAllBsdsTab; isToReviewTab; isReviewedTab; + isReturnTab; }; export const getRoutePredicate = (props: Tabs & { siret }) => { @@ -40,7 +43,8 @@ export const getRoutePredicate = (props: Tabs & { siret }) => { isCollectedTab, isAllBsdsTab, isToReviewTab, - isReviewedTab + isReviewedTab, + isReturnTab } = props; if (isActTab) { @@ -91,6 +95,11 @@ export const getRoutePredicate = (props: Tabs & { siret }) => { isRevisedFor: [siret] }; } + if (isReturnTab) { + return { + isReturnFor: [siret] + }; + } }; export const getBlankslateTitle = (tabs: Tabs): string | undefined => { @@ -100,7 +109,8 @@ export const getBlankslateTitle = (tabs: Tabs): string | undefined => { isFollowTab, isArchivesTab, isReviewedTab, - isToReviewTab + isToReviewTab, + isReturnTab } = tabs; if (isActTab) { @@ -118,6 +128,9 @@ export const getBlankslateTitle = (tabs: Tabs): string | undefined => { if (isReviewedTab || isToReviewTab) { return blankstate_reviews_title; } + if (isReturnTab) { + return blankstate_return_title; + } return blankstate_default_title; }; @@ -127,7 +140,8 @@ export const getBlankslateDescription = ({ isFollowTab, isArchivesTab, isReviewedTab, - isToReviewTab + isToReviewTab, + isReturnTab }: Tabs) => { if (isActTab) { return blankstate_action_desc; @@ -151,6 +165,9 @@ export const getBlankslateDescription = ({ if (isReviewedTab || isToReviewTab) { return blankstate_reviews_desc; } + if (isReturnTab) { + return blankstate_return_desc; + } return blankstate_default_desc; }; diff --git a/front/src/Pages/dashboard.scss b/front/src/Pages/dashboard.scss index d2fa224432..2f0ef4af9f 100644 --- a/front/src/Pages/dashboard.scss +++ b/front/src/Pages/dashboard.scss @@ -26,6 +26,9 @@ .fr-btn { margin: 5px 0; width: 180px; + @include desktop { + width: 100%; + } justify-content: center; box-sizing: content-box; padding: 0px; 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() { -
    +
    status === container.status ) && - canAddAppendix1(formToBsdDisplay) && + hasAppendix1Cta(formToBsdDisplay, siret) && permissions.includes(UserPermission.BsdCanUpdate) && (